diff --git a/.dockerignore b/.dockerignore index b43cc58..1b4f9a0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,8 +5,10 @@ !uv.lock !README.md !src/ -!src/car_traker/ -!src/car_traker/** +!src/car_tracker/ +!src/car_tracker/** src/**/__pycache__/ src/**/*.pyc +# Keep locally generated packaging metadata out of the build context. +src/**/*.egg-info/ !searches.toml diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index b2f3a03..8608a49 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -15,15 +15,15 @@ jobs: env: POSTGRES_USER: postgres POSTGRES_PASSWORD: ci-test-only - POSTGRES_DB: car_traker_test + POSTGRES_DB: car_tracker_test options: >- - --health-cmd "pg_isready -U postgres -d car_traker_test" + --health-cmd "pg_isready -U postgres -d car_tracker_test" --health-interval 5s --health-timeout 5s --health-retries 12 env: # Disposable service only. Never replace this with the homelab database URL. - TEST_DATABASE_URL: postgresql://postgres:ci-test-only@postgres:5432/car_traker_test + TEST_DATABASE_URL: postgresql://postgres:ci-test-only@postgres:5432/car_tracker_test UV_NO_PROGRESS: "1" steps: - name: Check out repository @@ -55,6 +55,6 @@ jobs: - name: Check installed wheel and packaged migrations run: | - uv venv /tmp/car-traker-wheel - uv pip install --python /tmp/car-traker-wheel/bin/python --no-deps dist/*.whl - /tmp/car-traker-wheel/bin/python -c 'from importlib.resources import files; assert files("car_traker").joinpath("migrations", "001_silver.sql").is_file(), "SQL migration missing from wheel"' + uv venv /tmp/car-tracker-wheel + uv pip install --python /tmp/car-tracker-wheel/bin/python --no-deps dist/*.whl + /tmp/car-tracker-wheel/bin/python -c 'from importlib.resources import files; assert files("car_tracker").joinpath("migrations", "001_silver.sql").is_file(), "SQL migration missing from wheel"' diff --git a/Dockerfile b/Dockerfile index 102d631..4fc396d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,9 +22,15 @@ RUN uv sync --locked --no-dev --no-install-project \ && mkdir /data \ && chown app:app /data COPY src/ ./src/ -RUN uv sync --locked --no-dev --no-editable && rm -rf /tmp/.cache +# Fail the build early if an application executable or migration is missing. +RUN uv sync --locked --no-dev --no-editable \ + && rm -rf /tmp/.cache \ + && for script in car-tracker car-tracker-db car-tracker-pipeline; do \ + test -x "/opt/venv/bin/${script}" || { echo "missing console script: ${script}" >&2; exit 1; }; \ + done \ + && python -c 'from importlib.resources import files; assert files("car_tracker").joinpath("migrations", "001_silver.sql").is_file()' COPY searches.toml ./searches.toml USER 1000:1000 -ENTRYPOINT ["python", "-m", "car_traker.container_entrypoint"] -CMD ["car-traker-pipeline", "--headed", "--config", "/app/searches.toml", "--output", "/data", "--family", "saab-900", "--max-pages", "1", "--max-details", "1"] +ENTRYPOINT ["python", "-m", "car_tracker.container_entrypoint"] +CMD ["car-tracker-pipeline", "--headed", "--config", "/app/searches.toml", "--output", "/data", "--family", "saab-900", "--max-pages", "1", "--max-details", "1"] diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 79a4b9e..e8ecaec 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -10,7 +10,7 @@ and precise location below. Seller/contact objects and photos remain excluded; free text may still contain personal information and must remain private until reviewed/redacted for downstream publication. Source coordinates may be approximate. -The collector now lives in `src/car_traker/`, storing separate search/detail +The collector now lives in `src/car_tracker/`, storing separate search/detail observations in versioned compressed JSON Lines with a coverage manifest. See `README.md` for current commands, capture policy, retention, and verified live results. The original handoff below remains planning context, not a description @@ -25,7 +25,7 @@ connection setup remain user actions documented in `docs/POSTGRESQL.md`; no home database has been accessed or modified during implementation. The manual homelab job is now packaged in `Dockerfile` and `ops/app/compose.yaml`. -`car-traker-pipeline` performs a database preflight, bounded collection, and an +`car-tracker-pipeline` performs a database preflight, bounded collection, and an idempotent import of the exact returned manifest, retaining partial coverage and separate operational outcomes. There is no scheduler or automatic deployment. See `docs/DEPLOYMENT.md`; container tests use isolated PostgreSQL 17 and synthetic @@ -65,10 +65,10 @@ identified. - PostgreSQL already runs on a Debian VM; reuse it rather than require a second production database. - Repository hosted on the user's own Gitea instance. -- Current workspace: `/home/tonio/Workspace/perso/car-traker`. +- Current workspace: `/home/tonio/Workspace/perso/car-tracker`. - At inspection, the repository contained `.gitignore`, `.python-version`, `main.py`, `pyproject.toml`, and an empty `README.md`. -- `pyproject.toml` specifies Python >=3.13, project name `car-traker`, version +- `pyproject.toml` specifies Python >=3.13, project name `car-tracker`, version `0.1.0`, and no dependencies. - The worktree was clean before this handoff document was added. - No application implementation was changed during the planning discussion. diff --git a/README.md b/README.md index 63583af..d28c2e1 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,14 @@ Python 3.13+ and `uv`: ```sh uv sync uv run playwright install chromium -uv run car-traker --family saab-900 --max-pages 1 --max-details 1 --headed +uv run car-tracker --family saab-900 --max-pages 1 --max-details 1 --headed ``` The smoke command deliberately caps coverage and returns exit code 1 if further pages or details exist. Collect all configured searches and their individual ads with: ```sh -uv run car-traker --headed +uv run car-tracker --headed ``` Options: `--config searches.toml`, `--output data/raw/leboncoin`, `--family`, @@ -47,9 +47,9 @@ results are intentionally preserved with their query provenance. ## Project Structure ```text -src/car_traker/ # Installed Python package +src/car_tracker/ # Installed Python package __init__.py - __main__.py # python -m car_traker entry point + __main__.py # python -m car_tracker entry point collect.py # Collector and CLI database.py # PostgreSQL migrations and import CLI pipeline.py # Manual collect -> raw -> import job @@ -69,7 +69,7 @@ data/ # Local snapshots (ignored by Git) `uv sync` installs the package in editable mode from `src/`. Run commands from the repository root so the default configuration and data paths resolve correctly. -`uv run python -m car_traker` also works as an alternative to `uv run car-traker`. +`uv run python -m car_tracker` also works as an alternative to `uv run car-tracker`. ## Silver Import @@ -81,8 +81,8 @@ role/database setup, network access, and laptop connection configuration. Once the remote connection is configured: ```sh -uv run car-traker-db migrate -uv run car-traker-db import data/raw/leboncoin/2026-09-08/ed585bcc-188d-401e-bc9f-8bc1318d4ca6/manifest.json +uv run car-tracker-db migrate +uv run car-tracker-db import data/raw/leboncoin/2026-09-08/ed585bcc-188d-401e-bc9f-8bc1318d4ca6/manifest.json ``` Importing an unchanged run again is a no-op. Search and detail observations retain @@ -92,7 +92,7 @@ and validation issues. Generation/trim inference, redaction, and scheduling are ## Manual Homelab Job -`car-traker-pipeline` checks database connectivity/schema, collects raw observations, +`car-tracker-pipeline` checks database connectivity/schema, collects raw observations, and imports that exact finalized run. Partial/failed collections remain marked as such even when their import succeeds. A lock prevents overlapping pipeline jobs on the same output directory, and `pipeline.json` records the import outcome separately @@ -101,7 +101,7 @@ from the immutable raw manifest. For a laptop smoke run with the existing database connection configured: ```sh -uv run car-traker-pipeline --headed --family saab-900 --max-pages 1 --max-details 1 +uv run car-tracker-pipeline --headed --family saab-900 --max-pages 1 --max-details 1 ``` For Docker on the homelab, follow [manual deployment](docs/DEPLOYMENT.md) first to @@ -110,7 +110,7 @@ Then, from the homelab checkout: ```sh docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml build app -docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml run --rm app car-traker-db check +docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml run --rm app car-tracker-db check docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml run --rm app ``` diff --git a/docs/CI.md b/docs/CI.md index afab6fa..50c39a8 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -13,7 +13,7 @@ One Docker-backed job, selected by the runner label `python-ci`, performs: 2. Install uv 0.9.3 and Python 3.13, then `uv sync --locked --dev`. 3. Run Ruff's correctness-focused checks over `src` and `tests`. 4. Run the full unittest suite, including integration tests against a fresh - PostgreSQL 17 service named `postgres` and database `car_traker_test`, matching + PostgreSQL 17 service named `postgres` and database `car_tracker_test`, matching the homelab's PostgreSQL major version. 5. Build the source distribution and wheel, install that wheel in an independent environment, and verify that its SQL migrations are packaged. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 5e849dd..e95b687 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -110,8 +110,8 @@ necessary source/port through the firewall. Check `pg_hba_file_rules` for errors and reload authentication changes with `SELECT pg_reload_conf();` as an admin. Changing `listen_addresses` requires a PostgreSQL restart: coordinate downtime for the shared VM's clients rather than restarting casually. See -[PostgreSQL troubleshooting](POSTGRESQL.md#connection-troubleshooting) for details; -that document's `car_traker` names are examples, not this VM's `car_tracker` names. +[PostgreSQL troubleshooting](POSTGRESQL.md#connection-troubleshooting) for details. +Use the existing `car_tracker` database and role on this VM. ## Build And Smoke Run Validate without printing interpolated configuration, then build on the homelab: @@ -119,7 +119,7 @@ Validate without printing interpolated configuration, then build on the homelab: ```sh docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml config --quiet docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml build app -docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml run --rm app car-traker-db check +docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml run --rm app car-tracker-db check ``` `check` is read-only connectivity testing, not a schema or migration-privilege @@ -128,7 +128,7 @@ opening the browser. No new migrations are required for this milestone. If the existing packaged migrations have not yet been applied, apply them explicitly: ```sh -docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml run --rm app car-traker-db migrate +docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml run --rm app car-tracker-db migrate ``` Migrations are not automatic. Once DB access/schema are ready, run the image's @@ -144,7 +144,7 @@ smoke run, this is the explicit larger manual command: ```sh docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml run --rm app \ - car-traker-pipeline --headed --config /app/searches.toml --output /data \ + car-tracker-pipeline --headed --config /app/searches.toml --output /data \ --family saab-900 --max-pages 3 --max-details 5 ``` @@ -175,7 +175,7 @@ date/run ID printed by the pipeline: ```sh docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml run --rm app \ - car-traker-db import '/data///manifest.json' + car-tracker-db import '/data///manifest.json' ``` An identical replay reports `already_imported` without duplicating observations. diff --git a/docs/POSTGRESQL.md b/docs/POSTGRESQL.md index 2ed813c..ab4d45a 100644 --- a/docs/POSTGRESQL.md +++ b/docs/POSTGRESQL.md @@ -6,7 +6,7 @@ Development and CLI execution currently happen on your laptop. Raw files remain on the laptop, and only the database connection crosses the network: ```text -Laptop: raw JSONL -> Python silver importer -> Homelab VM: PostgreSQL/car_traker +Laptop: raw JSONL -> Python silver importer -> Homelab VM: PostgreSQL/car_tracker ``` You do not need to install PostgreSQL on the laptop or move the repository to the @@ -22,18 +22,18 @@ Create a dedicated database and an application login. Run these commands as your PostgreSQL administrator, for example with `sudo -u postgres psql` on the Debian VM: ```sql -CREATE ROLE car_traker LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION; +CREATE ROLE car_tracker LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION; SET password_encryption = 'scram-sha-256'; -\password car_traker -CREATE DATABASE car_traker OWNER car_traker; -REVOKE ALL ON DATABASE car_traker FROM PUBLIC; -\connect car_traker +\password car_tracker +CREATE DATABASE car_tracker OWNER car_tracker; +REVOKE ALL ON DATABASE car_tracker FROM PUBLIC; +\connect car_tracker REVOKE CREATE ON SCHEMA public FROM PUBLIC; ``` `\password` prompts locally without putting the password in SQL history. These are one-time provisioning commands; inspect existing roles/databases rather than -rerunning blindly if the names already exist. The importer connects as `car_traker`, +rerunning blindly if the names already exist. The importer connects as `car_tracker`, not `postgres`. Owning this dedicated database allows it to create its `silver` schema and run migrations without cluster-wide administrative privileges. @@ -47,7 +47,7 @@ Restrict the firewall to the importer machine's IP and PostgreSQL port, normally scoped `pg_hba.conf` rule, replacing `` with the actual client address: ```text -hostssl car_traker car_traker /32 scram-sha-256 +hostssl car_tracker car_tracker /32 scram-sha-256 ``` Rules are first-match; review existing rules and their order. Reload authentication @@ -69,8 +69,8 @@ outside the repository. Prefer a password file rather than a password in command ```sh export PGHOST=postgres.your-lan.example export PGPORT=5432 -export PGDATABASE=car_traker -export PGUSER=car_traker +export PGDATABASE=car_tracker +export PGUSER=car_tracker export PGSSLMODE=verify-full export PGSSLROOTCERT=/path/to/your/postgres-ca.crt export PGPASSFILE="$HOME/.pgpass" @@ -79,7 +79,7 @@ export PGPASSFILE="$HOME/.pgpass" The matching password-file entry has this form: ```text -postgres.your-lan.example:5432:car_traker:car_traker: +postgres.your-lan.example:5432:car_tracker:car_tracker: ``` Protect it with `chmod 600 "$HOME/.pgpass"`. Escape `:` and `\` in passwords using @@ -99,7 +99,7 @@ psql -c 'SELECT current_database(), current_user, version();' Alternatively, no local `psql` installation is needed for the read-only CLI check: ```sh -uv run car-traker-db check +uv run car-tracker-db check ``` It connects with the same settings as migrations/imports and executes only `SELECT 1`. @@ -114,7 +114,7 @@ contain credentials or private data. `no pg_hba.conf entry` means PostgreSQL was reached, but its access rules do not allow the client/database/role combination. The rule must use the **client IP seen by PostgreSQL**, which can be a VPN or NAT address rather than the laptop's Wi-Fi/LAN address. Use the actual database and -role names you created; `car_traker` in this document is an example, not a hardcoded +role names you created; `car_tracker` in this document is an example, not a hardcoded application requirement. On the homelab, locate the active configuration and inspect parsed rules as an @@ -130,7 +130,7 @@ Add the appropriate narrow `hostssl` rule in the file returned by `SHOW hba_file then run `SELECT pg_reload_conf();`. Check the `error` column and server logs if it still fails; a successful reload request alone does not prove every rule is valid. Do not change passwords or use `trust` to work around a missing HBA rule. Once access -is permitted, rerun `car-traker-db check` from the laptop before migrating. +is permitted, rerun `car-tracker-db check` from the laptop before migrating. Other categories point to DNS/VPN, refused/timed-out connections, authentication, TLS verification, missing databases, or insufficient privileges. This check does @@ -142,8 +142,8 @@ From the repository root, with the connection configured: ```sh uv sync -uv run car-traker-db migrate -uv run car-traker-db import data/raw/leboncoin/2026-09-08/ed585bcc-188d-401e-bc9f-8bc1318d4ca6/manifest.json +uv run car-tracker-db migrate +uv run car-tracker-db import data/raw/leboncoin/2026-09-08/ed585bcc-188d-401e-bc9f-8bc1318d4ca6/manifest.json ``` The example uses the existing 35-search/one-detail live run. Choose any finalized @@ -298,13 +298,13 @@ Database integration tests are skipped unless `TEST_DATABASE_URL` is set. They One local example (test password only; localhost-only port; ephemeral data): ```sh -docker run --detach --rm --name car-traker-silver-test \ - --env POSTGRES_PASSWORD=local-test-only --env POSTGRES_DB=car_traker_test \ +docker run --detach --rm --name car-tracker-silver-test \ + --env POSTGRES_PASSWORD=local-test-only --env POSTGRES_DB=car_tracker_test \ --publish 127.0.0.1:55432:5432 --tmpfs /var/lib/postgresql/data postgres:17 -docker exec car-traker-silver-test pg_isready -U postgres -d car_traker_test -TEST_DATABASE_URL='postgresql://postgres:local-test-only@127.0.0.1:55432/car_traker_test' \ +docker exec car-tracker-silver-test pg_isready -U postgres -d car_tracker_test +TEST_DATABASE_URL='postgresql://postgres:local-test-only@127.0.0.1:55432/car_tracker_test' \ uv run python -m unittest discover -s tests -v -docker stop car-traker-silver-test +docker stop car-tracker-silver-test ``` Wait until `pg_isready` succeeds before running the tests. This temporary container diff --git a/ops/app/compose.yaml b/ops/app/compose.yaml index 783f734..a6ecea7 100644 --- a/ops/app/compose.yaml +++ b/ops/app/compose.yaml @@ -1,11 +1,13 @@ -name: car-traker-app +name: car-tracker-app services: app: build: context: ../.. dockerfile: Dockerfile - image: car-traker:local + # The reconcile agent sets CAR_TRACKER_IMAGE to an immutable CI-built tag. + # The default keeps the documented local build-and-run path working. + image: "${CAR_TRACKER_IMAGE:-car-tracker:local}" # One-shot manual job only. Never restart a blocked scraper automatically. restart: "no" init: true diff --git a/pyproject.toml b/pyproject.toml index 7126cbd..e1e0691 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "car-traker" +name = "car-tracker" version = "0.1.0" description = "French youngtimer asking-price observatory" readme = "README.md" @@ -16,9 +16,9 @@ target-version = "py313" select = ["E4", "E7", "E9", "F"] [project.scripts] -car-traker = "car_traker.collect:main" -car-traker-db = "car_traker.database:main" -car-traker-pipeline = "car_traker.pipeline:main" +car-tracker = "car_tracker.collect:main" +car-tracker-db = "car_tracker.database:main" +car-tracker-pipeline = "car_tracker.pipeline:main" [build-system] requires = ["setuptools>=77"] @@ -28,4 +28,4 @@ build-backend = "setuptools.build_meta" where = ["src"] [tool.setuptools.package-data] -car_traker = ["migrations/*.sql"] +car_tracker = ["migrations/*.sql"] diff --git a/src/car_tracker/__init__.py b/src/car_tracker/__init__.py new file mode 100644 index 0000000..5fd72ce --- /dev/null +++ b/src/car_tracker/__init__.py @@ -0,0 +1 @@ +"""Car-tracker collection and database tools.""" diff --git a/src/car_traker/__main__.py b/src/car_tracker/__main__.py similarity index 100% rename from src/car_traker/__main__.py rename to src/car_tracker/__main__.py diff --git a/src/car_traker/collect.py b/src/car_tracker/collect.py similarity index 100% rename from src/car_traker/collect.py rename to src/car_tracker/collect.py diff --git a/src/car_traker/container_entrypoint.py b/src/car_tracker/container_entrypoint.py similarity index 81% rename from src/car_traker/container_entrypoint.py rename to src/car_tracker/container_entrypoint.py index 247b751..6429a1b 100644 --- a/src/car_traker/container_entrypoint.py +++ b/src/car_tracker/container_entrypoint.py @@ -41,8 +41,16 @@ def main(): child.send_signal(stopped) code = child.wait() return code if code >= 0 else 128 - code + except FileNotFoundError: + # Do not echo caller-supplied paths or arguments. + message = ("Virtual display executable could not be found." if display is None + else "Container command could not be found; check the executable name.") + print(message, file=sys.stderr) + return 3 except (OSError, ValueError): - print("Could not start the virtual display or container command.", file=sys.stderr) + message = ("Could not start the virtual display." if display is None + else "Could not start or wait for the container command.") + print(message, file=sys.stderr) return 3 finally: # Only clean up descendants after the command has finalized its files/transaction. diff --git a/src/car_traker/database.py b/src/car_tracker/database.py similarity index 99% rename from src/car_traker/database.py rename to src/car_tracker/database.py index be4b933..b033b87 100644 --- a/src/car_traker/database.py +++ b/src/car_tracker/database.py @@ -263,7 +263,7 @@ def migrate(conn): name text PRIMARY KEY, sha256 text NOT NULL, applied_at timestamptz NOT NULL DEFAULT now() )""") applied = dict(conn.execute("SELECT name, sha256 FROM silver.schema_migrations").fetchall()) - migrations = sorted(files("car_traker").joinpath("migrations").iterdir(), key=lambda p: p.name) + migrations = sorted(files("car_tracker").joinpath("migrations").iterdir(), key=lambda p: p.name) known = {m.name for m in migrations if m.name.endswith(".sql")} if set(applied) - known: raise InputError("database_has_unknown_migrations") @@ -367,7 +367,7 @@ def database_error_hint(exc): if exc.sqlstate == "42501": return "The database role lacks required privileges. Use the dedicated database owner for migrations/imports, not a superuser." if exc.sqlstate in ("42P01", "3F000"): - return "A required database table/schema is missing. Run car-traker-db migrate against the same database first." + return "A required database table/schema is missing. Run car-tracker-db migrate against the same database first." return "Check the connection configuration and PostgreSQL server logs. Raw error details are withheld to protect credentials and source data." diff --git a/src/car_traker/migrations/001_silver.sql b/src/car_tracker/migrations/001_silver.sql similarity index 100% rename from src/car_traker/migrations/001_silver.sql rename to src/car_tracker/migrations/001_silver.sql diff --git a/src/car_traker/pipeline.py b/src/car_tracker/pipeline.py similarity index 100% rename from src/car_traker/pipeline.py rename to src/car_tracker/pipeline.py diff --git a/src/car_traker/transform.py b/src/car_tracker/transform.py similarity index 100% rename from src/car_traker/transform.py rename to src/car_tracker/transform.py diff --git a/src/car_traker/__init__.py b/src/car_traker/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_browser_pipeline.py b/tests/test_browser_pipeline.py index 1ab47e8..48f301d 100644 --- a/tests/test_browser_pipeline.py +++ b/tests/test_browser_pipeline.py @@ -13,8 +13,8 @@ from unittest.mock import patch from psycopg import sql -from car_traker import database, pipeline -from car_traker.collect import search_url +from car_tracker import database, pipeline +from car_tracker.collect import search_url import test_database_integration as database_tests @@ -92,7 +92,7 @@ class BrowserPipelineTests(unittest.TestCase): environment["DATABASE_URL"] = os.environ["TEST_DATABASE_URL"] with patch.dict(os.environ, environment, clear=True), patch.object( BrowserContext, "new_page", new_page, - ), patch("car_traker.collect.time.sleep"), redirect_stdout(stdout), redirect_stderr(stderr): + ), patch("car_tracker.collect.time.sleep"), redirect_stdout(stdout), redirect_stderr(stderr): code = pipeline.main([ "--headed", "--config", str(self.config), "--output", str(self.output), "--delay", "1", "--max-pages", "1", "--max-details", "1", diff --git a/tests/test_collect.py b/tests/test_collect.py index 5012e58..3bdcd7f 100644 --- a/tests/test_collect.py +++ b/tests/test_collect.py @@ -5,7 +5,7 @@ import unittest from pathlib import Path from unittest.mock import patch -from car_traker.collect import SourceAccessError, collect, minimize, parse_detail, parse_search, search_url +from car_tracker.collect import SourceAccessError, collect, minimize, parse_detail, parse_search, search_url def payload(ids=(123,), total=1, pages=1): @@ -85,7 +85,7 @@ class CollectorTests(unittest.TestCase): raise result return result - with patch("car_traker.collect.time.sleep"): + with patch("car_tracker.collect.time.sleep"): kwargs.setdefault("search_only", True) path, status = collect(fetch, [{"family": "synthetic", "text": "test"}], Path(temporary.name), **kwargs) @@ -221,7 +221,7 @@ class CollectorTests(unittest.TestCase): def test_interrupt_during_delay_does_not_count_as_detail_attempt(self): with tempfile.TemporaryDirectory() as directory: - with patch("car_traker.collect.time.sleep", side_effect=KeyboardInterrupt()): + with patch("car_tracker.collect.time.sleep", side_effect=KeyboardInterrupt()): path, status = collect(lambda url: payload(), [{"family": "synthetic", "text": "test"}], Path(directory)) manifest = json.loads(path.read_text()) @@ -233,7 +233,7 @@ class CollectorTests(unittest.TestCase): with tempfile.TemporaryDirectory() as directory: searches = [{"family": "synthetic", "text": "test"}, {"family": "synthetic", "text": "test-2"}] - with patch("car_traker.collect.time.sleep") as sleep: + with patch("car_tracker.collect.time.sleep") as sleep: responses = iter([payload(), detail_payload(), payload()]) urls = [] diff --git a/tests/test_collect_browser.py b/tests/test_collect_browser.py index dfacf9d..8d3f0c1 100644 --- a/tests/test_collect_browser.py +++ b/tests/test_collect_browser.py @@ -5,7 +5,7 @@ from tempfile import TemporaryDirectory import unittest from unittest.mock import patch -from car_traker.collect import collect_browser +from car_tracker.collect import collect_browser class BrowserCleanupTests(unittest.TestCase): @@ -18,7 +18,7 @@ class BrowserCleanupTests(unittest.TestCase): max_pages=1, delay=1, search_only=True, max_details=None) self.playwright = self.enterContext(patch("playwright.sync_api.sync_playwright")) self.browser = self.playwright.return_value.__enter__.return_value.chromium.launch.return_value - self.collect = self.enterContext(patch("car_traker.collect.collect", return_value=(self.path, "complete"))) + self.collect = self.enterContext(patch("car_tracker.collect.collect", return_value=(self.path, "complete"))) self.stderr = self.enterContext(patch("sys.stderr", new_callable=io.StringIO)) def test_browser_close_failure_retains_finalized_result(self): diff --git a/tests/test_container_entrypoint.py b/tests/test_container_entrypoint.py index 9c4aad2..accfa7c 100644 --- a/tests/test_container_entrypoint.py +++ b/tests/test_container_entrypoint.py @@ -5,9 +5,9 @@ import signal import subprocess import sys import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch -from car_traker import container_entrypoint +from car_tracker import container_entrypoint class ContainerEntrypointTests(unittest.TestCase): @@ -15,12 +15,35 @@ class ContainerEntrypointTests(unittest.TestCase): with patch("sys.argv", ["entrypoint"]), patch("sys.stderr", new_callable=io.StringIO): self.assertEqual(container_entrypoint.main(), 3) + def test_missing_display_has_distinct_safe_error(self): + error = FileNotFoundError(2, "missing", "/private/display") + with patch("sys.argv", ["entrypoint", "command"]), \ + patch("sys.stderr", new_callable=io.StringIO) as stderr, \ + patch.object(container_entrypoint.subprocess, "Popen", side_effect=error): + self.assertEqual(container_entrypoint.main(), 3) + self.assertIn("Virtual display executable could not be found", stderr.getvalue()) + self.assertNotIn("/private/display", stderr.getvalue()) + + def test_missing_command_has_distinct_safe_error_and_cleans_display(self): + display = Mock() + error = FileNotFoundError(2, "missing", "/private/command") + with patch("sys.argv", ["entrypoint", "/private/command"]), \ + patch("sys.stderr", new_callable=io.StringIO) as stderr, \ + patch.object(container_entrypoint.subprocess, "Popen", side_effect=[display, error]), \ + patch.object(container_entrypoint.select, "select", return_value=([3], [], [])), \ + patch.object(container_entrypoint.os, "read", return_value=b"0\n"): + self.assertEqual(container_entrypoint.main(), 3) + self.assertIn("Container command could not be found", stderr.getvalue()) + self.assertNotIn("/private/command", stderr.getvalue()) + display.terminate.assert_called_once() + display.wait.assert_called_once() + @unittest.skipUnless(os.environ.get("RUN_BROWSER_TESTS") == "1", "Xvfb container tests are opt-in") class VirtualDisplayTests(unittest.TestCase): def test_child_exit_code_is_preserved(self): result = subprocess.run([ - sys.executable, "-m", "car_traker.container_entrypoint", + sys.executable, "-m", "car_tracker.container_entrypoint", sys.executable, "-c", "raise SystemExit(7)", ], capture_output=True, text=True, timeout=20) self.assertEqual(result.returncode, 7, result.stderr) @@ -39,7 +62,7 @@ while True: signal.pause() """ process = subprocess.Popen([ - sys.executable, "-m", "car_traker.container_entrypoint", + sys.executable, "-m", "car_tracker.container_entrypoint", sys.executable, "-c", command, ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) try: diff --git a/tests/test_database_cli.py b/tests/test_database_cli.py index 32beb7c..4ed3ed9 100644 --- a/tests/test_database_cli.py +++ b/tests/test_database_cli.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch import psycopg -from car_traker.database import database_error_hint, main +from car_tracker.database import database_error_hint, main class DatabaseCliTests(unittest.TestCase): @@ -37,10 +37,10 @@ class DatabaseCliTests(unittest.TestCase): def test_check_only_runs_select(self): connection = MagicMock() with patch.dict("os.environ", {"PGHOST": "synthetic-host"}, clear=True), patch( - "sys.argv", ["car-traker-db", "check"] - ), patch("car_traker.database.psycopg.connect") as connect, patch( + "sys.argv", ["car-tracker-db", "check"] + ), patch("car_tracker.database.psycopg.connect") as connect, patch( "sys.stdout", new_callable=io.StringIO - ) as output, patch("car_traker.database.migrate") as migrate: + ) as output, patch("car_tracker.database.migrate") as migrate: connect.return_value.__enter__.return_value = connection self.assertEqual(main(), 0) connection.execute.assert_called_once_with("SELECT 1") @@ -50,8 +50,8 @@ class DatabaseCliTests(unittest.TestCase): def test_hba_failure_is_actionable_without_echoing_credentials(self): exc = psycopg.OperationalError('no pg_hba.conf entry for host "private-host"; password=synthetic-secret') with patch.dict("os.environ", {"PGHOST": "synthetic-host"}, clear=True), patch( - "sys.argv", ["car-traker-db", "check"] - ), patch("car_traker.database.psycopg.connect", side_effect=exc), patch( + "sys.argv", ["car-tracker-db", "check"] + ), patch("car_tracker.database.psycopg.connect", side_effect=exc), patch( "sys.stderr", new_callable=io.StringIO ) as output: self.assertEqual(main(), 1) @@ -61,9 +61,9 @@ class DatabaseCliTests(unittest.TestCase): def test_no_implicit_local_connection(self): with patch.dict("os.environ", {}, clear=True), patch( - "sys.argv", ["car-traker-db", "check"] + "sys.argv", ["car-tracker-db", "check"] ), patch("sys.stderr", new_callable=io.StringIO), patch( - "car_traker.database.psycopg.connect" + "car_tracker.database.psycopg.connect" ) as connect, self.assertRaises(SystemExit): main() connect.assert_not_called() diff --git a/tests/test_database_input.py b/tests/test_database_input.py index 31a14c7..a2d2b05 100644 --- a/tests/test_database_input.py +++ b/tests/test_database_input.py @@ -6,8 +6,8 @@ from tempfile import TemporaryDirectory import unittest from unittest.mock import patch -from car_traker.collect import collect, write_json -from car_traker.database import InputError, load_json, load_run, prepare_record +from car_tracker.collect import collect, write_json +from car_tracker.database import InputError, load_json, load_run, prepare_record class RawInputTests(unittest.TestCase): @@ -22,7 +22,7 @@ class RawInputTests(unittest.TestCase): "list_id": 123, "price": [1000], "body": "Synthetic\u2028text\u2029only", }], }}}}) - with patch("car_traker.collect.time.sleep"): + with patch("car_tracker.collect.time.sleep"): path, _ = collect(lambda url: source, [{"family": "synthetic", "text": "test"}], self.root, search_only=True) return path, json.loads(path.read_text()) diff --git a/tests/test_database_integration.py b/tests/test_database_integration.py index 343e206..067fe15 100644 --- a/tests/test_database_integration.py +++ b/tests/test_database_integration.py @@ -19,8 +19,8 @@ import psycopg from psycopg import sql from psycopg.conninfo import conninfo_to_dict -from car_traker import database -from car_traker.collect import search_url, write_json, write_records +from car_tracker import database +from car_tracker.collect import search_url, write_json, write_records @unittest.skipUnless(os.environ.get("TEST_DATABASE_URL"), "TEST_DATABASE_URL is not set") @@ -247,7 +247,7 @@ class DatabaseIntegrationTests(unittest.TestCase): def test_rejected_record_replay_keeps_nonzero_cli_exit(self): path, _ = self.fixture(pages=[[{"list_id": "invalid"}]]) with patch.dict(os.environ, {"DATABASE_URL": os.environ["TEST_DATABASE_URL"]}), patch( - "sys.argv", ["car-traker-db", "import", str(path)] + "sys.argv", ["car-tracker-db", "import", str(path)] ), patch("sys.stdout", new_callable=io.StringIO) as output: self.assertEqual(database.main(), 2) self.assertEqual(database.main(), 2) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 924c866..dcd9606 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -10,7 +10,7 @@ import tempfile import unittest from unittest.mock import MagicMock, patch -from car_traker import pipeline +from car_tracker import pipeline class PipelineTests(unittest.TestCase): diff --git a/tests/test_transform.py b/tests/test_transform.py index 910eacc..7234f05 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone from decimal import Decimal, localcontext import unittest -from car_traker.transform import clean_observation +from car_tracker.transform import clean_observation OBSERVED_AT = datetime(2026, 9, 8, tzinfo=timezone.utc) diff --git a/uv.lock b/uv.lock index bb80ca6..01c2791 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.13" [[package]] -name = "car-traker" +name = "car-tracker" version = "0.1.0" source = { editable = "." } dependencies = [