Dev notes for beetkeeper — a self-hosted FastAPI web app for managing beets.
Source root is src/python (package: src/python/beetkeeper).
- Pants 2.32 is the build system (
pants.toml). Runpants <goal> ::for everything. - Pants resolves with pex (
[python] resolver = "pex",enable_resolves = true): app deps come from thebeetkeeper-resolvelockfile, tools (mypy, bandit, pytest, ...) fromtools-resolve— both under3rdparty/, regenerated withpants generate-lockfiles. - uv manages the dev venv and workspace: root
pyproject.tomlis the workspace root;src/python/pyproject.tomlis the member that holds the real distribution[project]. Keepuv.lockin sync (uv lock).
- Root
pyproject.toml: tool config only (ruff, mypy, pytest, bandit),[dependency-groups]dev deps, and[tool.uv.workspace]. No[project]table here. src/python/pyproject.toml: the distribution's[project](name, deps, scripts, metadata)[build-system]. It lives in the source root so setuptools' PEP 517 backend (run there by Pants) reads it. Version is dynamic from thevMAJOR.MINOR.PATCHgit tag via Pants'vcs_versiontargets (setuptools-scm under the hood), which generate_scm_version.pymodules that each package's committed_version.pyre-exports (falling back to0.0.0.dev0outside Pants, e.g. uv dev builds).
- The wheel is built with
generate_setup=False(seesrc/python/BUILD), so Pants does NOT inject metadata — runtime deps/scripts are maintained by hand insrc/python/pyproject.tomland must stay consistent with what the code imports.
- Tests run through Pants' built-in pytest:
pants test ::runs eachpython_testsfile natively.[python] enable_resolves = true(resolver: pex) supplies 3rd-party deps from thebeetkeeper-resolvelockfile (3rdparty/python/beetkeeper-lockfile.json); pytest itself + its plugins install fromtools-resolvevia[pytest].requirementsinpants.toml— any plugin whose CLI options we pass (e.g.pytest-socket) must be listed there. pants test ::also runs thehooks:uv-lockfile-checktest_shell_command(defined via thetest_cmdmacro inbuild_scripts/pants_macros.py).- mypy runs via
pants check ::and bandit viapants lint ::(both are Pants backends configured inpants.toml, reading tool config from the rootpyproject.toml). uv run --all-groups pytest ...also works for ad-hoc local runs against the uv dev venv.
pants test :: # pytest (per-file, native Pants) + uv-lockfile check hook
pants lint :: # ruff, bandit, shellcheck, shfmt, hadolint, taplo, yamllint, visibility
pants check :: # mypy
pants package src/python:beetkeeper-whl # build the wheel -> dist/
pants package //:beetkeeper-server-image # build app docker image
pants package :: # Packages all pants targets which support the package command.
pants generate-lockfiles # regenerate the Pants resolve lockfiles under 3rdparty/
uv lock # regenerate uv.lock after dep changes
uv lock --check # verify uv.lock is current (gated in prek + CI)prek hooks (.pre-commit-config.yaml) call the same Pants goals (lint fmt, check, test hooks:...)
plus actionlint and an MkDocs build check. Install git hooks with prek install.
- The
Dockerfilehas anffmpegfetch stage + the finalappstage; onlyappis packaged, as the single//:beetkeeper-server-imagedocker_imagetarget in rootBUILD. The image is named/pushed via the@ghcrregistry (ghcr.io/zach-overflow/beetkeeper); seepants.toml[docker.registries.ghcr]and theenv("RELEASE_TAG", "dev")tag inBUILD. - The
appstage runs nouv/resolve — it just COPYs a thin, single-arch PEX (//:beetkeeper-linux-<arch>, one per linux arch viacomplete_platforms, selected byARG TARGETARCH).pants packagebuilds only the host arch; CI builds the image per-arch on native runners (no QEMU) and stitches a multi-arch manifest list — see.github/workflows/publish.yml.
- Versioning: no committed version.
vcs_versiontargets (src/python/beetkeeper/BUILD,src/beetsplug/beetkeeper_plugin/BUILD) generate_scm_version.pyfrom git via setuptools-scm; each package's committed_version.pyre-exports it, falling back to0.0.0.dev0outside Pants (uv builds). An exactvMAJOR.MINOR.PATCHtag yields a clean version; anything else is a dev version. - Flow (details:
docs/contributor_docs/release_management.md): releases are driven by conventional commits via cocogitto (cog.toml; only commits since the latestvtag count). PRs are squash-merged, so the PR title is the conventional commit that lands onmain(enforced by.github/workflows/pr-title.ymlrunningcog verify). Run theReleaseworkflow frommain(checks commits, validates + builds everything, publishes nothing) → approve thereleaseenvironment gate, wherecog bump --autocomputes the next semver and pushes avX.Y.Ztag onto the validated commit (tag-only bump — no commit orCHANGELOG.mdis ever pushed tomain, so branch protection can't conflict), and the GitHub release is uploaded with the cog-generated changelog →Releasedispatchespublish.yml(workflow_dispatch; a GITHUB_TOKEN-created tag can't firepushtriggers, andworkflow_callwould break PyPI trusted publishing), which builds wheels/image/docs in parallel from the tag, then publishes them in parallel (PyPI + Pages keep their environment gates).
Any code which interacts with the beets database (NOT the beetkeeper database) should be done carefully, and must take into consideration the official beets docs on their programming interface for the db here.
Additionally, this beets blog post is helpful for details on the threading and locking model beets enforces for programmatic access to their DB (aka Library).
- All recorded beets listener events originate exclusively from the
beetkeeperbeets plugin (src/beetsplug/beetkeeper_plugin), which POSTs them to the/api/events/*endpoints. The API parses those requests and stores them (ListenerEvent+AlbumEvent/TrackEventrows). The server must never synthesize or "fill in" event records itself — not even for imports it runs. If no POST arrived, no record exists, period. (The server distribution depends onbeetkeeper-pluginfor exactly this reason.) - The import worker registers its own in-process beets listener (
core.import_worker._ImportNarrator), but it is narration-only: it feeds the job's human-readable output log and never writes event rows. - Consequence: beetkeeper-run imports appear on the events page only when the beets config loads the
beetkeeperplugin and its pushes succeed (correctserver_url, andapi_tokenwhen login protection is enabled). - The shared event-type vocabulary is
beetkeeper.constants.BeetsEventType. It lives at the top level (not underbeetkeeper.api) becausebeetkeeper.api.__init__pulls in the whole FastAPI app, whose routers importbeetkeeper.db.models— the db layer importing it frombeetkeeper.api.*would be a circular import. Integration tests (src/integration_tests/events_plugin_integration_tests/) keep it in sync with the plugin's_EVENT_PAYLOAD_KEYSand beets' ownbeets.plugins.EventTypeliterals.
- Pants: https://www.pantsbuild.org/stable/docs/introduction/welcome-to-pants
python_distributiontarget: https://www.pantsbuild.org/stable/reference/targets/python_distribution- Pants pytest subsystem (why we avoid it): https://www.pantsbuild.org/stable/reference/subsystems/pytest#requirements
- uv workspaces: https://docs.astral.sh/uv/concepts/projects/workspaces/
- beets developer docs index page: https://beets.readthedocs.io/en/v2.12.0/dev/
- beets API reference: https://beets.readthedocs.io/en/v2.12.0/api/index.html
- Overview of beets' internal API for its core database features.
- FastAPI docs
In-code comments (#, /* */, etc., across Python, BUILD, config, CI, and CSS) are discouraged. Write
self-explanatory code and let names, types, and docstrings carry the intent. Only add a comment when it
explains something genuinely unexpected or unintuitive — a workaround, a subtle gotcha, or a non-obvious
constraint a competent reader couldn't infer from the code. Such cases should be rare. When one is warranted,
keep it brief (ideally one line) and, where useful, link out (GitHub issue thread, doc, etc.) rather than
explaining at length. (Python docstrings are documentation, not comments, and are encouraged.)
- Write code with the expectation that it may be running within an asynchronous event loop.
- Use the anyio library instead of the builtin
asynciolibrary. - Prefer async coroutine definitions for FastAPI route definitions.
- Use the anyio library instead of the builtin
- Whenever possible, aim to keep the code modular. Avoid monolithic files in preference of breaking out into functional domains.
- The source code under
src/python/beetkeepershows a starting point for this structure, but feel free to create or consolidate things if needed.
- The source code under
- Type hints are required.
- Test code should live under
src/python/tests, and not colocated with the source code, as some Pantsbuild examples show.
- All FastAPI code lives under
src/python/beetkeeper/api - The app is created from a factory function in
src/python/beetkeeper/api/fastapi_app.py - All custom FastAPI Dependencies should live in
src/python/beetkeeper/api/dependencies.py - All public REST API endpoints are defined in FastAPI
APIRouterinstances created undersrc/python/beetkeeper/api/api_routes - All ui-related endpoints are defined in FastAPI
APIRouterinstances created undersrc/python/beetkeeper/api/ui_routes
-
The frontend should be handled ONLY by the following, both for any static components, as well as for dynamic HTML + event-based DOM manipulation:
- A monolithic classless CSS file at
src/python/beetkeeper/api/static/css/classless.css - HTMX (vendored in-repo, and baked into the common base HTML template at
src/python/beetkeeper/api/static/html_templates/base_template.html.) - Any pure, simple javascript -- only if absolutely needed -- and should be defined in the common shared base HTML template within a `<script> block.
- A monolithic classless CSS file at
-
Read the docstring at
src/python/beetkeeper/api/ui_routes/__init__.pyfor details on the frontend code structure expectations. -
Do not use ANY javascript framework or any other additional frontend library other than the vendored HTMX (
src/python/beetkeeper/api/static/js/htmx.min.js). No CDN scripts, no npm/build step, no CSS frameworks beyond the classless stylesheet above.
- Use
pytest. Do not use theunittestbuiltin library. - Use of
pytest.mark.parametrizeandpytest.fixtureare the preferred ways to generate test cases and reduce test code repetition. - Type hints are required in test code too.
- This is true even when using pytest's "built in" fixtures, such as
tmp_path, ormockerfrompytest-mock.
- This is true even when using pytest's "built in" fixtures, such as
- Use any mock tooling from
pytest-mock. Avoid using features fromunittest.mockunless necessary. - Do not use mock decorators (e.g.
@patch(...)). - Use
@pytest.mark.anyiofor async tests. Do not usepytest-asyncio. - Tests should never make real network calls.
- When testing any FastAPI route functions, use testing tools FastAPI offers. (see here, and the relevant testing pages under the advanced user guide.)