seting up contaner for ebook search #40
@@ -0,0 +1,28 @@
|
|||||||
|
.git
|
||||||
|
.direnv
|
||||||
|
.mypy_cache
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
.venv
|
||||||
|
**/.venv
|
||||||
|
.env
|
||||||
|
.cache
|
||||||
|
.claude
|
||||||
|
.coverage
|
||||||
|
.vscode
|
||||||
|
.stfolder
|
||||||
|
.literotica_data
|
||||||
|
esphome
|
||||||
|
htmlcov
|
||||||
|
data
|
||||||
|
ebooks
|
||||||
|
__pycache__
|
||||||
|
**/__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.ebook_search_bm25
|
||||||
|
result
|
||||||
|
result-*
|
||||||
|
*.egg-info
|
||||||
|
dist
|
||||||
|
build
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: test ebook search
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
UV_PYTHON_DOWNLOADS: never
|
||||||
|
UV_CACHE_DIR: /var/cache/uv
|
||||||
|
UV_LINK_MODE: copy
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-ebook-search:
|
||||||
|
runs-on: self-hosted
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install dependencies
|
||||||
|
run: nix develop .#devShells.x86_64-linux.ebook-search -c uv sync --locked --project python/ebook_search/docker
|
||||||
|
- name: Run ebook search tests
|
||||||
|
run: nix develop .#devShells.x86_64-linux.ebook-search -c uv run --project python/ebook_search/docker --no-sync pytest tests/ebook_search --override-ini addopts="-n auto -ra"
|
||||||
+1
-33
@@ -17,45 +17,17 @@
|
|||||||
|
|
||||||
python-env = final: _prev: {
|
python-env = final: _prev: {
|
||||||
my_python = final.python314.withPackages (
|
my_python = final.python314.withPackages (
|
||||||
ps:
|
ps: with ps; [
|
||||||
let
|
|
||||||
bm25s = ps.buildPythonPackage rec {
|
|
||||||
pname = "bm25s";
|
|
||||||
version = "0.3.9";
|
|
||||||
pyproject = true;
|
|
||||||
|
|
||||||
src = final.fetchPypi {
|
|
||||||
inherit pname version;
|
|
||||||
hash = "sha256-iVxnnZUrfeg1XttfPhpiCh4vKU0dQrkZvwghzOLi9Zc=";
|
|
||||||
};
|
|
||||||
|
|
||||||
build-system = [ ps.setuptools ];
|
|
||||||
dependencies = with ps; [
|
|
||||||
numpy
|
|
||||||
scipy
|
|
||||||
];
|
|
||||||
|
|
||||||
pythonImportsCheck = [ "bm25s" ];
|
|
||||||
};
|
|
||||||
in
|
|
||||||
with ps;
|
|
||||||
[
|
|
||||||
alembic
|
alembic
|
||||||
apprise
|
apprise
|
||||||
apscheduler
|
apscheduler
|
||||||
beautifulsoup4
|
|
||||||
ebooklib
|
|
||||||
fastapi
|
fastapi
|
||||||
fastapi-cli
|
fastapi-cli
|
||||||
httpx
|
httpx
|
||||||
mypy
|
mypy
|
||||||
numpy
|
|
||||||
orjson
|
|
||||||
pgvector
|
pgvector
|
||||||
polars
|
|
||||||
psycopg
|
psycopg
|
||||||
pydantic
|
pydantic
|
||||||
pydantic-settings
|
|
||||||
pyfakefs
|
pyfakefs
|
||||||
pytest
|
pytest
|
||||||
pytest-cov
|
pytest-cov
|
||||||
@@ -63,12 +35,8 @@
|
|||||||
pytest-xdist
|
pytest-xdist
|
||||||
python-multipart
|
python-multipart
|
||||||
ruff
|
ruff
|
||||||
scalene
|
|
||||||
sqlalchemy
|
sqlalchemy
|
||||||
bm25s
|
|
||||||
tenacity
|
tenacity
|
||||||
textual
|
|
||||||
tiktoken
|
|
||||||
tinytuya
|
tinytuya
|
||||||
typer
|
typer
|
||||||
websockets
|
websockets
|
||||||
|
|||||||
+9
-1
@@ -12,9 +12,14 @@ dependencies = [
|
|||||||
"alembic",
|
"alembic",
|
||||||
"apprise",
|
"apprise",
|
||||||
"apscheduler",
|
"apscheduler",
|
||||||
|
"beautifulsoup4",
|
||||||
|
"bm25s",
|
||||||
|
"ebooklib",
|
||||||
"fastapi",
|
"fastapi",
|
||||||
"fastapi-cli",
|
"fastapi-cli",
|
||||||
"httpx",
|
"httpx",
|
||||||
|
"jinja2",
|
||||||
|
"pgvector",
|
||||||
"polars",
|
"polars",
|
||||||
"psycopg[binary]",
|
"psycopg[binary]",
|
||||||
"pydantic",
|
"pydantic",
|
||||||
@@ -22,9 +27,12 @@ dependencies = [
|
|||||||
"python-multipart",
|
"python-multipart",
|
||||||
"sqlalchemy",
|
"sqlalchemy",
|
||||||
"tenacity",
|
"tenacity",
|
||||||
|
"tiktoken",
|
||||||
"tinytuya",
|
"tinytuya",
|
||||||
"typer",
|
"typer",
|
||||||
|
"uvicorn",
|
||||||
"websockets",
|
"websockets",
|
||||||
|
"yake",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -109,6 +117,6 @@ exclude_lines = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = "-n auto -ra"
|
addopts = "-n auto -ra --ignore=tests/ebook_search"
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
# --cov=system_tools --cov-report=term-missing --cov-report=xml --cov-report=html --cov-branch
|
# --cov=system_tools --cov-report=term-missing --cov-report=xml --cov-report=html --cov-branch
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
FROM python:3.14-slim AS base
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /uvx /bin/
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
APP_DIR=/home/richie/dotfiles \
|
||||||
|
UV_PROJECT_ENVIRONMENT=/opt/venv \
|
||||||
|
UV_PYTHON_DOWNLOADS=never \
|
||||||
|
UV_NO_CACHE=1
|
||||||
|
|
||||||
|
# Separate ENV instruction so ${APP_DIR} and ${PATH} from above resolve.
|
||||||
|
ENV PYTHONPATH=${APP_DIR} \
|
||||||
|
PATH=/opt/venv/bin:${PATH}
|
||||||
|
|
||||||
|
WORKDIR ${APP_DIR}
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY python/ebook_search/docker/pyproject.toml python/ebook_search/docker/uv.lock ./
|
||||||
|
|
||||||
|
RUN uv sync --locked --no-dev
|
||||||
|
|
||||||
|
|
||||||
|
FROM base AS test
|
||||||
|
|
||||||
|
RUN uv sync --locked
|
||||||
|
|
||||||
|
COPY python ./python
|
||||||
|
COPY tests/__init__.py ./tests/__init__.py
|
||||||
|
COPY tests/ebook_search ./tests/ebook_search
|
||||||
|
|
||||||
|
CMD ["pytest"]
|
||||||
|
|
||||||
|
|
||||||
|
FROM base AS runtime
|
||||||
|
|
||||||
|
ENV EBOOK_SEARCH_HOST=0.0.0.0 \
|
||||||
|
EBOOK_SEARCH_PORT=8070 \
|
||||||
|
EBOOK_SEARCH_BM25_INDEX_DIR=/data/bm25
|
||||||
|
|
||||||
|
COPY python ./python
|
||||||
|
|
||||||
|
RUN useradd --create-home --uid 10001 app \
|
||||||
|
&& mkdir -p /data \
|
||||||
|
&& chown -R app:app /home/richie /data
|
||||||
|
|
||||||
|
USER app
|
||||||
|
|
||||||
|
EXPOSE 8070
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "exec python -m python.ebook_search.api.main --host \"${EBOOK_SEARCH_HOST}\" --port \"${EBOOK_SEARCH_PORT}\" --log-level \"${EBOOK_SEARCH_LOG_LEVEL:-INFO}\""]
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Ebook Search Docker
|
||||||
|
|
||||||
|
Run the EPUB search app against the existing Postgres database on `jeeves`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m python.ebook_search.docker.containers start --library-path /path/to/epubs --build
|
||||||
|
```
|
||||||
|
|
||||||
|
All ebook-search Docker files live in this directory:
|
||||||
|
|
||||||
|
- `Dockerfile` — multi-stage: `test` (runs pytest) and `runtime` (default target, the app image)
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- `containers.py` — Typer lifecycle CLI
|
||||||
|
- `pyproject.toml` / `uv.lock` — the container's uv-locked dependencies
|
||||||
|
|
||||||
|
The app listens on `http://localhost:8070`.
|
||||||
|
|
||||||
|
Useful lifecycle commands:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m python.ebook_search.docker.containers build
|
||||||
|
python -m python.ebook_search.docker.containers start --library-path /path/to/epubs
|
||||||
|
python -m python.ebook_search.docker.containers test
|
||||||
|
python -m python.ebook_search.docker.containers logs
|
||||||
|
python -m python.ebook_search.docker.containers ps
|
||||||
|
python -m python.ebook_search.docker.containers stop
|
||||||
|
```
|
||||||
|
|
||||||
|
Direct compose usage from the repo root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose -f python/ebook_search/docker/docker-compose.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
The image builds its environment with uv from `pyproject.toml` + `uv.lock` in this
|
||||||
|
directory — this is the source of truth for the container's dependencies. To add or
|
||||||
|
update a dependency, edit `pyproject.toml` here and regenerate the lock (uv is
|
||||||
|
available in the `ebook-search` dev shell):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix develop .#ebook-search -c uv lock --project python/ebook_search/docker
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
The main pytest suite excludes `tests/ebook_search` (its dependencies are no longer
|
||||||
|
in the nix dev shell). The `test ebook search` CI workflow runs them in a uv env
|
||||||
|
built from the lockfile in this directory — same commands work locally from the
|
||||||
|
repo root (the `--override-ini` drops the main suite's ignore):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv sync --locked --project python/ebook_search/docker
|
||||||
|
uv run --project python/ebook_search/docker --no-sync pytest tests/ebook_search --override-ini addopts="-n auto -ra"
|
||||||
|
```
|
||||||
|
|
||||||
|
They can also run inside the Docker `test` image, which validates the image itself:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m python.ebook_search.docker.containers test
|
||||||
|
```
|
||||||
|
|
||||||
|
or the raw docker equivalent:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build --file python/ebook_search/docker/Dockerfile --target test --tag ebook-search:test .
|
||||||
|
docker run --rm ebook-search:test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The compose service loads the repo root `.env` into the container via `env_file`.
|
||||||
|
|
||||||
|
Mount your EPUB directory by setting `EBOOK_LIBRARY_HOST_PATH` in an env file or on the command line. The container sees it as `/library`, and `EBOOK_SEARCH_LIBRARY_PATHS` is set to `/library` inside the container.
|
||||||
|
|
||||||
|
Database connection settings are controlled by `RICHIE_DB`, `RICHIE_HOST`, `RICHIE_PORT`, `RICHIE_USER`, and `RICHIE_PASSWORD`. The default host is `jeeves`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Docker packaging and lifecycle tooling for ebook search."""
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""Docker container lifecycle management for ebook search."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from python.common import configure_logger, get_repo_dir
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_compose_file() -> Path:
|
||||||
|
"""Return the path to the docker-compose.yml file."""
|
||||||
|
return Path(__file__).resolve().with_name("docker-compose.yml")
|
||||||
|
|
||||||
|
|
||||||
|
def compose_base_args() -> list[str]:
|
||||||
|
"""Return the common docker compose arguments for the ebook search stack."""
|
||||||
|
return ["compose", "-f", str(get_compose_file())]
|
||||||
|
|
||||||
|
|
||||||
|
def docker_run(
|
||||||
|
arguments: list[str],
|
||||||
|
*,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
capture_output: bool = False,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Run docker with repo-root cwd and consistent error handling."""
|
||||||
|
logger.info("docker %s", " ".join(arguments))
|
||||||
|
return subprocess.run(
|
||||||
|
["docker", *arguments],
|
||||||
|
cwd=get_repo_dir(),
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
capture_output=capture_output,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compose_env(*, library_path: Path | None = None, port: int | None = None) -> dict[str, str]:
|
||||||
|
"""Return environment variables passed to docker compose."""
|
||||||
|
env = os.environ.copy()
|
||||||
|
if library_path is not None:
|
||||||
|
resolved_library = library_path.expanduser().resolve()
|
||||||
|
if not resolved_library.exists():
|
||||||
|
msg = f"EPUB library path does not exist: {resolved_library}"
|
||||||
|
raise FileNotFoundError(msg)
|
||||||
|
env["EBOOK_LIBRARY_HOST_PATH"] = str(resolved_library)
|
||||||
|
if port is not None:
|
||||||
|
env["EBOOK_SEARCH_PORT"] = str(port)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_compose_file() -> None:
|
||||||
|
"""Raise if the ebook search compose file is missing."""
|
||||||
|
if not get_compose_file().is_file():
|
||||||
|
msg = f"Compose file not found: {get_compose_file()}"
|
||||||
|
raise FileNotFoundError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def build_image() -> None:
|
||||||
|
"""Build the ebook search app image."""
|
||||||
|
ensure_compose_file()
|
||||||
|
result = docker_run([*compose_base_args(), "build"])
|
||||||
|
if result.returncode != 0:
|
||||||
|
msg = "Failed to build ebook search image"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def build_test_image() -> None:
|
||||||
|
"""Build the ebook search test Docker image."""
|
||||||
|
dockerfile = Path(__file__).resolve().with_name("Dockerfile")
|
||||||
|
result = docker_run(["build", "--file", str(dockerfile), "--target", "test", "--tag", "ebook-search:test", "."])
|
||||||
|
if result.returncode != 0:
|
||||||
|
msg = "Failed to build ebook search test image"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def run_test_image() -> None:
|
||||||
|
"""Run the ebook search test suite inside Docker."""
|
||||||
|
result = docker_run(["run", "--rm", "ebook-search:test"])
|
||||||
|
if result.returncode != 0:
|
||||||
|
msg = f"Ebook search tests failed with code {result.returncode}"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def start_stack(
|
||||||
|
*,
|
||||||
|
library_path: Path | None = None,
|
||||||
|
port: int | None = None,
|
||||||
|
build: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Start the ebook search Docker compose stack."""
|
||||||
|
ensure_compose_file()
|
||||||
|
env = compose_env(library_path=library_path, port=port)
|
||||||
|
if build:
|
||||||
|
build_image()
|
||||||
|
result = docker_run(
|
||||||
|
[*compose_base_args(), "up", "-d"],
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
msg = f"Ebook search stack failed to start with code {result.returncode}"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
logger.info("Ebook search started.")
|
||||||
|
|
||||||
|
|
||||||
|
def stop_stack(
|
||||||
|
*,
|
||||||
|
volumes: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Stop and remove ebook search containers."""
|
||||||
|
ensure_compose_file()
|
||||||
|
command = [*compose_base_args(), "down"]
|
||||||
|
if volumes:
|
||||||
|
command.append("-v")
|
||||||
|
result = docker_run(command)
|
||||||
|
if result.returncode != 0:
|
||||||
|
msg = f"Ebook search stack failed to stop with code {result.returncode}"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def logs_stack(
|
||||||
|
*,
|
||||||
|
service: str | None = None,
|
||||||
|
tail: int = 100,
|
||||||
|
follow: bool = False,
|
||||||
|
) -> str | None:
|
||||||
|
"""Return recent logs from the ebook search stack."""
|
||||||
|
ensure_compose_file()
|
||||||
|
command = [*compose_base_args(), "logs", "--tail", str(tail)]
|
||||||
|
if follow:
|
||||||
|
command.append("--follow")
|
||||||
|
if service:
|
||||||
|
command.append(service)
|
||||||
|
result = docker_run(command, capture_output=not follow)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
if follow:
|
||||||
|
return ""
|
||||||
|
return result.stdout + result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def ps_stack() -> str | None:
|
||||||
|
"""Return docker compose ps output for the ebook search stack."""
|
||||||
|
ensure_compose_file()
|
||||||
|
result = docker_run([*compose_base_args(), "ps"], capture_output=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
return result.stdout + result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
app = typer.Typer(help="Ebook search Docker container management.", no_args_is_help=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def build() -> None:
|
||||||
|
"""Build the ebook search Docker image."""
|
||||||
|
build_image()
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def start(
|
||||||
|
library_path: Annotated[Path | None, typer.Option(help="Override host path containing EPUB files.")] = None,
|
||||||
|
port: Annotated[int | None, typer.Option(help="Override host port for the web UI.")] = None,
|
||||||
|
*,
|
||||||
|
build: Annotated[bool, typer.Option("--build", help="Build the image before starting.")] = False,
|
||||||
|
log_level: Annotated[str, typer.Option(help="Log level.")] = "INFO",
|
||||||
|
) -> None:
|
||||||
|
"""Start the ebook search container."""
|
||||||
|
configure_logger(log_level)
|
||||||
|
start_stack(
|
||||||
|
library_path=library_path,
|
||||||
|
port=port,
|
||||||
|
build=build,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def stop(
|
||||||
|
*,
|
||||||
|
volumes: Annotated[bool, typer.Option("--volumes", help="Also remove ebook search data volumes.")] = False,
|
||||||
|
log_level: Annotated[str, typer.Option(help="Log level.")] = "INFO",
|
||||||
|
) -> None:
|
||||||
|
"""Stop and remove ebook search containers."""
|
||||||
|
configure_logger(log_level)
|
||||||
|
stop_stack(volumes=volumes)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def restart(
|
||||||
|
library_path: Annotated[Path | None, typer.Option(help="Override host path containing EPUB files.")] = None,
|
||||||
|
port: Annotated[int | None, typer.Option(help="Override host port for the web UI.")] = None,
|
||||||
|
*,
|
||||||
|
build: Annotated[bool, typer.Option("--build", help="Build the image before starting.")] = False,
|
||||||
|
log_level: Annotated[str, typer.Option(help="Log level.")] = "INFO",
|
||||||
|
) -> None:
|
||||||
|
"""Restart the ebook search stack."""
|
||||||
|
configure_logger(log_level)
|
||||||
|
stop_stack()
|
||||||
|
start_stack(
|
||||||
|
library_path=library_path,
|
||||||
|
port=port,
|
||||||
|
build=build,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def logs(
|
||||||
|
service: Annotated[str | None, typer.Option(help="Service name, or omit for all services.")] = None,
|
||||||
|
tail: Annotated[int, typer.Option(help="Number of recent log lines.")] = 100,
|
||||||
|
*,
|
||||||
|
follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow logs.")] = False,
|
||||||
|
) -> None:
|
||||||
|
"""Show recent ebook search container logs."""
|
||||||
|
output = logs_stack(service=service, tail=tail, follow=follow)
|
||||||
|
if output is None:
|
||||||
|
typer.echo("No ebook search containers found.")
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
if output:
|
||||||
|
typer.echo(output)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("test")
|
||||||
|
def run_tests(
|
||||||
|
*,
|
||||||
|
build: Annotated[bool, typer.Option("--build/--no-build", help="Build the test image before running.")] = True,
|
||||||
|
log_level: Annotated[str, typer.Option(help="Log level.")] = "INFO",
|
||||||
|
) -> None:
|
||||||
|
"""Run ebook search tests inside the Docker test image."""
|
||||||
|
configure_logger(log_level)
|
||||||
|
if build:
|
||||||
|
build_test_image()
|
||||||
|
run_test_image()
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("ps")
|
||||||
|
def ps() -> None:
|
||||||
|
"""Show ebook search container status."""
|
||||||
|
output = ps_stack()
|
||||||
|
if output is None:
|
||||||
|
typer.echo("No ebook search containers found.")
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
typer.echo(output)
|
||||||
|
|
||||||
|
|
||||||
|
def cli() -> None:
|
||||||
|
"""Typer entry point."""
|
||||||
|
app()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli()
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: ebook-search
|
||||||
|
|
||||||
|
services:
|
||||||
|
ebook-search:
|
||||||
|
build:
|
||||||
|
context: ../../..
|
||||||
|
dockerfile: python/ebook_search/docker/Dockerfile
|
||||||
|
image: ebook-search:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${EBOOK_SEARCH_PORT:-8070}:8070"
|
||||||
|
extra_hosts:
|
||||||
|
- "jeeves:192.168.90.40"
|
||||||
|
env_file:
|
||||||
|
- ../../../.env
|
||||||
|
environment:
|
||||||
|
EBOOK_SEARCH_HOST: "0.0.0.0"
|
||||||
|
EBOOK_SEARCH_PORT: "8070"
|
||||||
|
EBOOK_SEARCH_LIBRARY_PATHS: "/library"
|
||||||
|
EBOOK_SEARCH_BM25_INDEX_DIR: "/data/bm25"
|
||||||
|
volumes:
|
||||||
|
- "${EBOOK_LIBRARY_HOST_PATH:-/home/richie/ebooks}:/library:ro"
|
||||||
|
- ebook-search-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"curl -fsS http://127.0.0.1:8070/health >/dev/null || exit 1",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ebook-search-data:
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
[project]
|
||||||
|
name = "ebook-search"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Locked runtime environment for the ebook search container."
|
||||||
|
requires-python = "~=3.14.0"
|
||||||
|
dependencies = [
|
||||||
|
"alembic",
|
||||||
|
"beautifulsoup4",
|
||||||
|
"bm25s",
|
||||||
|
"ebooklib",
|
||||||
|
"fastapi",
|
||||||
|
"httpx",
|
||||||
|
"jinja2",
|
||||||
|
"pgvector",
|
||||||
|
"psycopg[binary]",
|
||||||
|
"pydantic",
|
||||||
|
"pydantic-settings",
|
||||||
|
"python-multipart",
|
||||||
|
"sqlalchemy",
|
||||||
|
"tiktoken",
|
||||||
|
"typer",
|
||||||
|
"uvicorn[standard]",
|
||||||
|
"yake",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest",
|
||||||
|
"pytest-mock",
|
||||||
|
"pytest-xdist",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = false
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
addopts = "-n auto -ra"
|
||||||
|
testpaths = ["tests/ebook_search"]
|
||||||
Generated
+1109
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,20 @@
|
|||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
{
|
{
|
||||||
|
# For running the uv-managed ebook-search venv outside the container:
|
||||||
|
# PyPI manylinux wheels (numpy via bm25s) expect libstdc++.so.6 on the
|
||||||
|
# loader path, which NixOS does not provide globally.
|
||||||
|
ebook-search = pkgs.mkShell {
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
my_python
|
||||||
|
uv
|
||||||
|
];
|
||||||
|
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
|
||||||
|
pkgs.stdenv.cc.cc.lib
|
||||||
|
pkgs.zlib
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
default = pkgs.mkShell {
|
default = pkgs.mkShell {
|
||||||
NIX_CONFIG = "extra-experimental-features = nix-command flakes ca-derivations";
|
NIX_CONFIG = "extra-experimental-features = nix-command flakes ca-derivations";
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{ ... }:
|
{ ... }:
|
||||||
{
|
{
|
||||||
imports = [ ./nix_builder.nix ];
|
imports = [
|
||||||
|
./nix_builder.nix
|
||||||
|
./uv_cache_prune.nix
|
||||||
|
];
|
||||||
|
|
||||||
services.nix_builder.containers = {
|
services.nix_builder.containers = {
|
||||||
nix-builder-00.enable = true;
|
nix-builder-00.enable = true;
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ in
|
|||||||
mountPoint = "/run/secrets/gitea-runners";
|
mountPoint = "/run/secrets/gitea-runners";
|
||||||
isReadOnly = true;
|
isReadOnly = true;
|
||||||
};
|
};
|
||||||
|
uv-cache = {
|
||||||
|
hostPath = vars.uv_cache;
|
||||||
|
mountPoint = "/var/cache/uv";
|
||||||
|
isReadOnly = false;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
config =
|
config =
|
||||||
{
|
{
|
||||||
@@ -153,7 +158,8 @@ in
|
|||||||
}
|
}
|
||||||
) cfg.containers;
|
) cfg.containers;
|
||||||
|
|
||||||
systemd.services = builtins.listToAttrs (
|
systemd = {
|
||||||
|
services = builtins.listToAttrs (
|
||||||
map (name: {
|
map (name: {
|
||||||
name = "container@${name}";
|
name = "container@${name}";
|
||||||
value = {
|
value = {
|
||||||
@@ -162,5 +168,10 @@ in
|
|||||||
};
|
};
|
||||||
}) (builtins.attrNames (filterAttrs (_: c: c.enable) cfg.containers))
|
}) (builtins.attrNames (filterAttrs (_: c: c.enable) cfg.containers))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
tmpfiles.rules = [
|
||||||
|
"d ${vars.uv_cache} 0755 ${runnerUsername} ${runnerUsername} - -"
|
||||||
|
];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{ pkgs, ... }:
|
||||||
|
let
|
||||||
|
vars = import ../vars.nix;
|
||||||
|
runnerUsername = "gitea-runner";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
systemd = {
|
||||||
|
services.uv-cache-prune = {
|
||||||
|
description = "Prune the shared gitea runner uv cache";
|
||||||
|
environment.UV_CACHE_DIR = vars.uv_cache;
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
User = runnerUsername;
|
||||||
|
Group = runnerUsername;
|
||||||
|
ExecStart = "${pkgs.uv}/bin/uv cache prune";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
timers.uv-cache-prune = {
|
||||||
|
description = "Monthly prune of the shared gitea runner uv cache";
|
||||||
|
wantedBy = [ "timers.target" ];
|
||||||
|
timerConfig = {
|
||||||
|
OnCalendar = "*-*-01 04:00:00";
|
||||||
|
Persistent = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ sudo zfs create media/secure/share -o mountpoint=/zfs/media/share -o exec=off
|
|||||||
# scratch datasets
|
# scratch datasets
|
||||||
sudo zfs create scratch/kafka -o mountpoint=/zfs/scratch/kafka -o recordsize=1M
|
sudo zfs create scratch/kafka -o mountpoint=/zfs/scratch/kafka -o recordsize=1M
|
||||||
sudo zfs create scratch/transmission -o mountpoint=/zfs/scratch/transmission -o recordsize=16k -o sync=disabled -o redundant_metadata=none
|
sudo zfs create scratch/transmission -o mountpoint=/zfs/scratch/transmission -o recordsize=16k -o sync=disabled -o redundant_metadata=none
|
||||||
|
sudo zfs create scratch/uv_cache -o mountpoint=/zfs/scratch/uv_cache
|
||||||
|
|
||||||
# storage datasets
|
# storage datasets
|
||||||
sudo zfs create storage/ollama -o recordsize=1M -o compression=zstd-19 -o sync=disabled
|
sudo zfs create storage/ollama -o recordsize=1M -o compression=zstd-19 -o sync=disabled
|
||||||
|
|||||||
@@ -121,3 +121,9 @@ monthly = 2
|
|||||||
hourly = 0
|
hourly = 0
|
||||||
daily = 0
|
daily = 0
|
||||||
monthly = 0
|
monthly = 0
|
||||||
|
|
||||||
|
["scratch/uv_cache"]
|
||||||
|
15_min = 2
|
||||||
|
hourly = 0
|
||||||
|
daily = 0
|
||||||
|
monthly = 0
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ in
|
|||||||
transmission = "${zfs_storage}/transmission";
|
transmission = "${zfs_storage}/transmission";
|
||||||
ollama = "${zfs_storage}/ollama";
|
ollama = "${zfs_storage}/ollama";
|
||||||
transmission_scratch = "${zfs_scratch}/transmission";
|
transmission_scratch = "${zfs_scratch}/transmission";
|
||||||
|
uv_cache = "${zfs_scratch}/uv_cache";
|
||||||
kafka = "${zfs_scratch}/kafka";
|
kafka = "${zfs_scratch}/kafka";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user