Skip to content

Latest commit

 

History

History
364 lines (283 loc) · 14.6 KB

File metadata and controls

364 lines (283 loc) · 14.6 KB

Development

Everything needed to work on TrackVault. The README is for running it; this is for changing it.

The rules every change follows are in agent-rules.md — the single canonical source for both automated agents and human contributors.

Requirements

  • Python 3.13, exactly: the supported range is >=3.13,<3.14. Packaging metadata, .python-version, the Ruff and mypy targets, the container base image and CI all state that range, and a contract test keeps them aligned.
  • uv, pinned to the same patch version in CI and in the container build.
  • Node 24 for the browser application.
  • Docker, optional, for the container workflow and the opt-in Docker tests.

Setup

uv sync
cd web && npm ci

Running it locally

uv run uvicorn trackvault.main:app --reload --port 8080

Interactive API documentation: http://127.0.0.1:8080/docs.

In another terminal, the browser application with hot reloading:

cd web
npm run dev            # http://127.0.0.1:5173, proxying /api to :8080

The development server proxies /api and /healthz to the backend, so development and production are the same origin. A CORS configuration that exists only in development is a configuration nobody exercises before it is needed.

Generating the API types

The browser's types come from the backend's own OpenAPI document. A hand-written TrackResponse that has drifted from the server is a bug that type-checks, and it is the failure the whole architecture is arranged against.

cd web
npm run generate:api   # writes openapi.json and src/api/schema.ts

CI regenerates both and fails on a non-empty diff of src/api/schema.ts.

openapi.json is a generated intermediate and is not committed. Its info.version is the release the document was generated by, which changes with every commit now that the version comes from the Git tag -- a committed copy would be either permanently stale or permanently in the way. The generated types carry no version, so they are the artifact the drift gate can hold still.

Tests

uv run pytest                  # default suite: no network, no Docker, no real GPS files
                               # "no network" is enforced: every connection that
                               # would leave this machine is refused, loopback
                               # excepted. See tests/support/network.py.
uv run pytest -m contract      # documented project and API contracts
uv run pytest -m analysis      # the analysis algorithms and their versioning
uv run pytest -m statistics    # aggregation, scopes and periods
uv run pytest -m persistence   # SQLite, migrations and managed raw storage
uv run pytest -m gpx           # the GPX adapter
uv run pytest -m reprocessing  # reprocessing and the current generation
uv run pytest -m maps          # offline map packages, delivery and providers
uv run pytest -m docker        # opt-in: needs a running Docker daemon
uv run pytest -m local_tracks  # opt-in: needs private files under ./import-tracks
uv run pytest --cov            # with coverage

The default suite composes every application against tmp_path, so nothing touches ./data, /data or a home directory. Committed fixtures under tests/fixtures/gpx/ are synthetic throughout.

./import-tracks/ is the local reference directory: real private recordings a developer keeps to explore against. The whole directory is git-ignored, nothing in the suite requires it, and the tests that read it skip when it is absent.

The browser suites

cd web
npm run typecheck      # strict TypeScript, no `any` escape hatch
npm run lint           # ESLint, type-aware
npm test               # unit and component tests (Vitest)
npm run build          # production bundle
npm run test:e2e       # Playwright, Chromium
npm run licenses       # dependency licence audit

Playwright starts two archives: a seeded synthetic one and an empty one on its own port. A fresh installation is the first thing anybody sees and the state most likely to render as NaN km, and it cannot be reached by filtering a seeded archive. The seeded one also gets a synthetic map package installed through the real pipeline (web/scripts/seed-map.py), so e2e/offline-maps.spec.ts is a test of a rendered map rather than of a grey rectangle.

Neither browser suite ever touches a real recording. A private track in a screenshot or a trace is personal movement data leaving the machine it belongs to, and no assertion is worth that.

The documentation screenshots

cd web
npm run build          # the screenshots are of the built page, not of Vite
npm run screenshots    # regenerates docs/images/

The pictures in docs/user/guide.md and in the README are of the real application, driven in a real browser, over an archive that is nobody's: scripts/demo_region.py describes an invented island, and scripts/demo_archive.py walks it, writes GPX documents, imports them through the ordinary import path and installs a hand-drawn map package through the ordinary installation path.

Run it after any change to a page the guide shows, so a screenshot cannot quietly become a description of a version that is gone. The generated archive is kept in DEMO_DATA_DIR and reused, because building it takes a few minutes and the interface is what changed; delete that directory to rebuild it. The browser comes from npx playwright install chromium, or from CHROMIUM where a machine already has one.

The offline maps page is the one picture that reads the real Geofabrik catalog, which is the same request the Refresh button makes. Without a network that panel says the provider is unreachable and the picture stops at the installed package; nothing else in the run needs the internet.

Working on offline maps

The design and its rejected alternatives are in docs/adr/0010-offline-map-packages-and-local-basemap-delivery.md. What follows is how to work on it.

The shape

domain/maps/            identity, bounds, attribution, coverage selection
application/maps/       use cases and four ports
infrastructure/maps/    storage, MBTiles, HTTP transfer, one provider, jobs
api/maps.py             the HTTP projection
web/src/map/style.ts    coverage -> a MapLibre style
web/src/pages/Maps/     the manager

Adding a provider

One module in infrastructure/maps/, implementing MapPackageProvider:

It owns It must
the index URL and how to parse it validate everything; drop what it cannot express rather than repairing it
the package URL convention derive it from a MapRegionId, never from a caller
the hosts it may talk to declare them, so a redirect elsewhere is refused
its slug and display name match the slug to the region identities it produces

Then one line in build_map_services. Nothing above infrastructure changes: the use cases, the routes, the manager and the styles all work through ports.

A package still has to validate as a schema the styles are written against. If the new provider ships a different vocabulary, that is a style and an inspector change as well, and MapTileSchema is where the two are kept honest.

Test fixtures

tests/support/map_packages.py builds a real Shortbread-shaped MBTiles archive -- gzipped vector tiles with actual geometry, in a SQLite container with the metadata the inspector reads. A few kilobytes, generated at test time. It can also build a deliberately broken one, which is most of what the validation tests need:

build_package(path, bounds=(7.4, 43.48, 7.6, 43.76))  # valid
build_package(path, bounds=..., licence=None)  # no licence
build_package(path, bounds=..., layers=("buildings",))  # wrong schema
build_package(path, bounds=..., with_tiles=False)  # empty

tests/support/fake_provider.py has two halves. serve_packages is a real loopback HTTP server that can misbehave in the specific ways a provider might -- declare a length it does not deliver, hang up mid-body, redirect off-host -- and is what the transfer contracts run against. FakeMapProvider reads a package off disk and is what the install pipeline tests use, because making every one of those pay for a socket would buy no coverage.

No downloaded map is ever committed. A country package is hundreds of megabytes; the whole point of the builder is that the suite makes its own.

Regenerating the label glyphs

Only when a script range or the font changes:

uv run --with pillow --with fonttools python scripts/generate_glyphs.py \
    --regular /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf \
    --bold    /usr/share/fonts/truetype/noto/NotoSans-Bold.ttf

The rasteriser is installed for the length of one run rather than added to the lock file the runtime image is built from. The output goes to web/public/fonts/ and is committed; Vite copies it into the build, the Dockerfile copies web/public into the Node stage, and a Docker smoke test asserts the glyphs are in the final image. Forgetting any of those three renders every place name as nothing.

Trying it against the real provider

Not part of any suite -- it downloads from somebody else's server. Monaco is 1.7 MB and is the right size for a manual check:

uv run trackvault --help          # start the app, then use the /maps page

Pick Europe → Monaco → Download, open a track in that area, then disconnect the network and reload. Remove it afterwards unless you want to keep it.

Quality gates

Before reporting anything as done:

uv lock --check
uv run ruff check .
uv run ruff format --check .
uv run mypy src
uv run pytest
uv run python -m compileall -q src tests
git diff --check

Ruff is the only formatter and linter, mypy the only type checker, pytest the only test runner. Do not add a second tool for a job that already has one.

Releasing

A release is a tag, and nothing else:

git tag v1.2.3
git push origin v1.2.3

There is no version to bump first. pyproject.toml declares dynamic = ["version"] and hatch-vcs reads the tag while the wheel is built, so the number reaches the distribution metadata, trackvault.__version__, the OpenAPI document and the image label from one place. A tag beginning with v starts the release workflow, which runs the full quality gate on that exact commit before anything is published; a tag containing - (v1.2.3-rc1) is published as a pre-release and does not become latest.

Two consequences worth knowing:

  • A working tree between tags reports a development version, such as 1.2.4.dev3+g2c1a524, and a dirty tree adds a date. That is the honest answer -- the build is not any release.

  • The version is resolved when the project is installed, not when it is imported. After tagging, uv run trackvault --version keeps reporting the previous number until the environment is refreshed:

    uv sync --reinstall-package trackvault

The rolling build

Every commit that lands on main is published as ghcr.io/basecubedev/trackvault:edge, replacing the one before it. It runs the same quality gate a release runs -- the edge workflow calls ci.yml rather than restating it -- and it is labelled with the development version the commit is, not with the name of the tag it was pushed under.

latest stays the newest release. It is what an unpinned deployment pulls and what the installer defaults to, so a commit on main must never reach it; tests/contract/test_release_pipeline_contract.py is what keeps that true. Deploy the rolling build deliberately:

sh install-docker.sh --tag edge

Builds that cannot see the tags do not guess. The container build is the case that matters -- its context carries no .git -- so it is handed the version through SETUPTOOLS_SCM_PRETEND_VERSION, fed by the TRACKVAULT_VERSION build argument that also writes the image label. An ordinary docker compose build passes nothing and labels itself 0.0.0+unknown.

Licences

Two audits read the resolved trees rather than the intent files, because what ends up in a container is what was resolved:

uv run python scripts/audit_licenses.py
cd web && npm run licenses

Both read one license-policy.json at the repository root, so a licence decision is made once for the repository rather than once per ecosystem. A manually reviewed exception names the licence it was granted for, and stops applying if the package is relicensed.

docs/legal/third-party-notices.md is the canonical dependency list, and tests/contract/test_third_party_notice_contract.py checks every direct dependency in it against uv.lock and package-lock.json for existence, version, licence and a stated purpose. Bumping a dependency therefore means updating that document in the same commit.

Measuring a large track

uv run python scripts/benchmark_projection.py
uv run python scripts/benchmark_projection.py --sizes 1000 100000 --repeat 3

A developer tool, deliberately not a test: a wall-clock threshold on a shared runner is noise with a red cross attached. What the suite asserts is the property those measurements exist to protect — that the responses stay bounded however long the track is (tests/integration/test_large_track_projection.py).

The stages are measured separately, because "the profile takes two seconds" is the absence of a finding rather than one. On a 250 000-position synthetic recording the movement window is roughly 70% of the cost, and profiling it shows no redundancy to remove: the sliding spread already runs as a monotonic queue and the rest is per-position work. Nothing here is cached — a cache would need binding to the processing generation, the analysis profile, the projection version and the sample budget, which is a fourth derived-state lifecycle bought to avoid an honest linear cost.

Tooling

GitNexus answers "what can this change affect?"; Serena answers "what is the working tree right now?". Both complement tests and reading the code — they never replace them. Their local data is never committed, and the generated rule block gitnexus analyze appends to AGENTS.md and CLAUDE.md is removed again: a second rule source is exactly what the single-source-of-truth rule forbids.

Documentation

Document Purpose
agent-rules.md The canonical rules for every change
architecture.md Layers, boundaries, authorities
contracts.md The business invariants
adr/ Why each slice looks the way it does
third-party-notices.md Dependencies and licences