Development guide¶
Start here to work on genplis: set up a checkout, run the checks, and find the code responsible for your change. For installation and normal use, see the user documentation.
Setup¶
You need Python 3.12 or later and uv:
git clone https://github.com/haplo/genplis.git
cd genplis
uv sync
uv run pre-commit install
uv sync installs runtime and development dependencies into .venv.
The pre-commit hook runs checks automatically when you commit.
Running¶
Run from the checkout with uv run:
uv run genplis ~/Music
uv run genplis --verbose /path/to/song.mp3
See Usage for playlist commands and Filter debugging for rule trees and traces.
Tests¶
| Task | Command |
|---|---|
| Whole suite | uv run python -m pytest |
| One file | uv run python -m pytest tests/test_m3ug.py |
| One test | uv run python -m pytest tests/test_m3ug.py::test_multiple_rules |
| Verbose results | uv run python -m pytest -v |
| Branch coverage | uv run python -m pytest --cov=genplis --cov-branch --cov-report=term-missing |
Testing patterns¶
- Reuse the
file_mp3,file_ogg, andfile_m3ugsession fixtures fromtests/conftest.py. Copy them totmp_pathbefore writing playlists or changing modification times; don't modifytests/files/. - Use
genplis_dbfor a temporary database andmake_argsto buildcore.Argswithout invoking argparse. - Use
retain_tags=Truewithprocess_directoryonly when a test needs the returnedall_tagsmap. Normal runs release tags after filtering. - Monkeypatch
genplis.tags.TinyTagfor synthetic tag data; see_patch_tinytagintests/test_tags.py. - Cover success, missing values, and error paths.
tests/test_m3ug.pyexercises rule semantics; the suite also validatesexamples/config.ini.
Linting, formatting, and typing¶
uv run ruff check --fix .
uv run ruff format .
uv run ty check
pyproject.toml configures Ruff and ty for Python 3.12. Keep function
signatures typed, imports ordered, and use the shared TagValue and Tags
aliases from src/genplis/types.py rather than ad-hoc types or Any.
See AGENTS.md
for the full coding guidelines.
Ruff and ty are intentionally unpinned in the development dependencies and
pre-commit configuration. [tool.uv] upgrade-package lets lockfile updates
refresh them; keep the seven-day dependency cutoff used by CI.
Pre-commit¶
After staging changes, run the hooks before committing:
uv run pre-commit run
Fix failures, re-stage affected files, and run the hooks again. Repeat this after staging more changes. When hooks or configuration change, also run:
uv run pre-commit run --all-files
The hooks cover whitespace, TOML, large files, the uv lockfile, Ruff, and ty.
The ty hook checks the whole project even when only a few files are staged.
Definitions live in .pre-commit-config.yaml.
CI¶
Pull requests and pushes to main run:
| Check | Coverage |
|---|---|
| Pre-commit | Locked dependencies and hooks on all files |
| Tests | Python 3.12, 3.13, 3.14, and 3.15; Python 3.12 also produces coverage reports |
| Website | Strict build and local link, asset, and anchor validation |
See ci.yml
and pages.yml.
Both workflows use UV_EXCLUDE_NEWER="7 days" to match the lockfile policy.
Project layout¶
Application modules live under src/genplis/:
| Module | Responsibility |
|---|---|
__main__.py |
Entry point |
core.py |
CLI resolution, directory scans, filtering, selection |
config.py |
INI loading and validation |
executors.py |
Sequential and worker-process tag parsing |
tags.py |
Tag extraction and calculated properties |
db.py |
SQLite cache and batched writes |
m3ug.py |
Rule trees, directives, includes, tag lookup |
sorting.py |
Sort grammar, normalization, comparison |
m3u.py |
Playlist writing |
diagnostics.py |
Rule trees, evaluation traces, selection output |
output.py |
Output streams, palettes, terminal detection, progress |
stats.py |
Tag statistics collection and rendering |
skill.py |
Safe installation of the packaged M3UG authoring skill |
types.py |
Shared types |
files.py |
File helpers |
json.py |
JSON serialization |
exceptions.py |
Exception classes |
Tests and media fixtures live in tests/; copyable filters and the annotated
config live in examples/. Website sources are covered below.
Data flow¶
A normal directory run in core.process_directory:
- Uses the resolved CLI/config settings and the main process's database connection.
- Discovers and parses
.m3ugfiles, or uses the filters passed explicitly throughfilters=. Excluded directories are pruned. - Walks audio files: applies filters immediately to cache hits and queues missing or stale files for parsing.
- Parses the queue, writes cache updates, and evaluates each result against the filters.
- Sorts matches, removes duplicates, applies count/per-tag/duration limits,
and writes playlists with selected entries.
@NO-PLAYLISTfiles are skipped.
Each Match retains only the playlist entry, walk order, selection keys,
and duration—not the whole tag map. retain_tags=True is an opt-in exception
for tests and debugging. See the selection rules
for user-visible behavior.
CLI modes¶
resolve_args chooses one core.Mode: PROCESS, TAG_STATS, SHOW_RULES,
TRACE, or TRACE_FULL.
--install-skillis a standalone early exit before loading configuration or resolving collection arguments; it never accesses the database.SHOW_RULESbranches before database setup.- Trace modes load the target through the cache. Full traces use
dry_runandSelectionTraceto record selection decisions without writing playlists; cache updates still happen. - Statistics reuse the walk/cache pipeline with an empty filter map.
TagStatsCollectorkeeps aggregate counts, not song mappings. Explicitly requested tags have uncapped distinct-value counts. --cleanis a post-processing flag, not a mode: with a collection path, normal processing happens before cleaning.
Parsing pipeline¶
Cache misses and stale files are queued during the walk so progress reporting
knows the parsing total. With --jobs 1, core.process_file handles that
queue in the main process. Otherwise, make_executor chooses a
SequentialExecutor for small queues or a ProcessExecutor for larger ones.
See executors.py
for batching and worker thresholds.
Keep these invariants when changing the pipeline:
- Only the main process accesses SQLite. Workers parse tags and return
ParseResultvalues with structured diagnostics. M3UG parsing, filtering, selection, and output also stay in the main process. - Do not inherit database connections. Worker contexts use
forkserveron POSIX andspawnelsewhere, never plainfork. - Preserve ordering. Worker results are consumed in submission order; each file's walk index remains the final tie-breaker for playlist sorting. Changing the worker count must not change deterministic playlist results.
- Release tags after use. Keep
Matchrecords, not anall_tagsmap, unless retention was explicitly requested. - Flush on exit.
db.WriteBatchcommits everycore.COMMIT_EVERYwrites and flushes at the end of parsing, including errors and interrupts. Interrupted executors cancel pending work and shut down without waiting.
Benchmarking¶
Use a test copy of a collection on a real filesystem, not tmpfs. Choose a new database path for a cold run:
/usr/bin/time -f '%e s, %M KiB' \
uv run genplis --db ./benchmark-sequential.db --jobs 1 /path/to/test-collection
Repeat the command against that database for a warm run. Compare with
--jobs 0 using a separate fresh database. Run each case three times and
take the median. Compare playlist checksums too; use fixed random seeds and
identical inputs/settings so byte-for-byte comparisons are meaningful.
Filter engine¶
The rule engine lives in m3ug.py. These are the main structures to understand before changing it:
| Structure | Role |
|---|---|
FilterNode, RuleNode |
Expression tree; plain rules form AND groups, @OR forms OrNode groups, and @NOT wraps one rule |
M3UGFile.expression |
The expression used for matching; rules is only a flattened leaf view |
Directives |
Playlist controls, separate from matching; UNSET distinguishes missing values from explicit overrides |
SourceLocation, literal tokens, IncludeInfo |
Preserve source and include boundaries for diagnostics without reparsing |
Tag lookup and comparison caching¶
EvaluationTags provides a read-only per-song view shared by filtering and
selection-key extraction. It lazily resolves names, calculated values, and
normalized rule inputs. Don't mutate the underlying tags while the view is
in use. Exact lowercase names win over mixed-case aliases; otherwise the
first matching spelling wins. The view is discarded after the song.
PredicateCache shares pure built-in comparisons across equivalent rules
for one scan. Its keys distinguish rule types, tag names, value types,
expected values, and case sensitivity. Custom rule checks aren't cached.
The cache is bounded—by default 16,384 results total, at most 1,024 per
predicate—and bypasses oversized keys and unsupported values. Admission
checks live alongside the class; don't remove them when changing caching.
Neither cache retains whole song mappings across the scan. Short-circuiting, per-rule warnings, and distinctions such as booleans versus numbers must stay unchanged. Standalone rules and traces also support ordinary tag mappings.
Includes and diagnostics¶
parse_m3ug recursively resolves includes in the main process, detects
cycles and depth errors, then merges included defaults before local
directives. include_loader lets tests supply included content without files.
@NO-PLAYLIST belongs to M3UGFile, not Directives, so it isn't inherited.
See Includes for path and precedence rules.
own_expression and ordered IncludeInfo records retain each file's
boundaries alongside the merged expression. diagnostics.py renders this
metadata and trace results. RuleNode.explain records optional TagLookup
details; normal apply avoids that trace-recording overhead.
Output and statistics¶
Use Output and Palette from output.py rather than emitting ANSI codes
in parsers or workers. Helpers default to Output.plain(); workers return
TagDiagnostic records for the main process to render. Theme choice and
color enablement are separate decisions.
Statistics renderers measure plain-text widths before coloring cells, so table alignment doesn't depend on escape sequences. Keep collection of statistics separate from rendering and avoid retaining per-file tags.
Cache internals¶
The disposable SQLite cache is implemented in db.py:
files(path TEXT PRIMARY KEY, last_modified TIMESTAMP, tags JSONB)stores one row per audio file. Tags are JSON-serializedTagsvalues.db.get_db_keynormalizes absolute paths and collapses./.., but doesn't resolve symlinks.- Freshness compares whole-second modification times. A file whose timestamp is no newer than the cached value is a hit. M3UG files aren't cached.
cache_metastorestag_version, built fromCACHE_SCHEMA_VERSION:tinytag_version:max_size:ignored-tagswith ignored names sorted. A version change makescreate_files_tablerebuildfiles.- Cleaning removes rows for missing files and runs
VACUUMonly when rows were removed.
Bump CACHE_SCHEMA_VERSION when changing tag extraction or storage in a way
that makes old rows incompatible. For location and maintenance commands,
see Cleaning the cache.
Extending the code¶
Adding an operator¶
- Subclass
RuleNode, defineOPERATORfor diagnostics, and follow the existingOPERATOR_NAMEnaming convention. SetIS_TEXT = Truefor text operators so boolean-tag warnings are preserved. Implementcheck()and overridecheck_params()when argument types need validation. RaiseGenplisM3UGExceptionfor invalid syntax or values. - Register the symbol in
RuleNode.buildand the operator sets used during parsing. Decide whether it is eligible forPredicateCache. - Test text, numbers, lists, missing values, invalid inputs, and cached versus
uncached behavior in
tests/test_m3ug.py. - Document its semantics in M3UG.
Adding a rating source¶
Extend get_rating() in m3ug.py and return a CalculatedRating with the
normalized 0–5 value, actual source key, and diagnostic note. Keep source-tag
knowledge inside this function; lookup and trace code consume its result
without knowing which rating sources are supported. Use a None value when
no usable source is found.
Add rating tests in tests/test_m3ug.py and trace tests in
tests/test_trace.py. Check explicit *rating, raw-rating precedence, zero
ratings, unusable values, and source provenance. Update Tags.
Changing ordering¶
Start with sorting.py for grammar, normalization, and comparison, then the
selection helpers in core.py. Preserve the distinction between an absent
sort (None, use the configured default) and an empty sort (walk order).
Test direction, numeric strings, case folding, missing and multi-valued keys, random seeds, dedupe, limits, and worker-count stability. See Ordering for the behavior to preserve.
Changing CLI or config behavior¶
Update core.setup_argparse() / resolve_args(), config.py's known
settings and parsing, and tests for CLI-over-config and default behavior.
Keep Usage, Configuration, and
examples/config.ini consistent with the change.
Documentation¶
User guides are Markdown files under docs/; the documentation index
lists their subjects. Keep each fact in one guide and link to it elsewhere,
rather than duplicating settings or language rules.
- Use relative Markdown links between guides, such as
[Tags](tags.md). The website build resolves them to published routes. - Use absolute GitHub links in
README.mdand for repository files outsidedocs/. Keep the README short and link to detailed guides. - Edit examples in
examples/, not copies in documentation. Keep filters andconfig.inivalid. - Check incoming links when renaming headings, then run the strict website build to validate local links and anchors.
Packaged agent skill¶
Edit agent-instructions.md, not the generated
src/genplis/skills/genplis/SKILL.md. The canonical language, tag, and usage
guides supply its offline references. After changing those documents or the
package version, regenerate the checked-in package resources:
uv run python scripts/sync_skill.py
uv run python -m pytest tests/test_skill.py
The generator records the package version, adjusts instruction links to the bundled references, and points links to unbundled guides at GitHub. A test rejects drift between documentation and packaged resources. Hatch includes the resources in the wheel and source distribution without custom build hooks. The installed-distribution smoke test also installs both skill targets into a temporary home directory.
Website development¶
The website uses Zensical. Its dependencies
live in the separate docs group and aren't needed to run genplis.
Run the website locally¶
uv run --group docs python scripts/website.py serve
Open http://localhost:8000/genplis/, or the printed preview URL. Source edits
in docs/ and examples/ regenerate the site and reload the preview. Stop
with Ctrl+C; restart after editing scripts/website.py itself.
Build and validate¶
Run the strict build before submitting documentation changes:
uv run --group docs python scripts/website.py build
It writes site/ and validates local links, assets, and anchors under the
/genplis/ prefix. To validate existing output without rebuilding, use:
uv run --group docs python scripts/website.py check
Try browser search in the preview after changing navigation or examples. Search is local, covers the guides and examples, and excludes the landing page.
Sources and deployment¶
| Source | Purpose |
|---|---|
docs/home.md |
Landing page at /genplis/ |
docs/index.md, other guides |
Documentation at /genplis/docs/ and its subpages |
examples/*.m3ug |
Generated gallery at /genplis/docs/examples/, individual downloads, and ZIP |
docs/assets/ |
Stylesheets and branding assets |
website/overrides/ |
Template overrides |
zensical.toml |
Navigation and theme configuration |
scripts/website.py |
Source preparation, preview, and link validation |
Edit original sources, not .website-src/ or site/. Both are ignored
build outputs. The preparation step rewrites documentation links and adds
source-edit links pointing back to the originals.
The gallery reads examples/*.m3ug in filename order and provides copyable
code and byte-for-byte downloads. Its ZIP preserves companion include files.
Don't create a separate docs/examples.md or maintain duplicate examples.
pages.yml builds on pull requests, main pushes, and manual dispatches;
only non-PR runs on main deploy through a Pages artifact and the
github-pages environment. Set the repository's Settings → Pages source
to GitHub Actions. No gh-pages branch or personal token is needed.
Release¶
Versions live in pyproject.toml; genplis --version reads the installed
distribution metadata. Use Semantic Versioning for the CLI, configuration,
and documented M3UG language, not internal Python APIs or the disposable
cache format.
Keep notable user-visible changes under [Unreleased] in the root
CHANGELOG.md.
Use Keep a Changelog categories (Added, Changed, Deprecated, Removed,
Fixed, Security) only when needed. Internal refactors and CI-only changes
need no entry. At release time, move entries into a dated version section,
leave an empty [Unreleased], and update the comparison links. GitHub Release
notes are extracted from this file, not generated from commit messages.
One-time publishing setup¶
- Enable 2FA on the PyPI account that will own
genplis. - Before the first upload, add a pending trusted publisher on PyPI:
project
genplis, GitHub ownerhaplo, repositorygenplis, workflow filenamerelease.yml, environmentpypi. After the first upload, this becomes the project's trusted publisher. No API token is stored in GitHub. - Create the GitHub repository environment
pypi. Restrict deployments to release tags and require approval if available for the repository/account. - Protect
v*tags against deletion or updates. Restrict who can create them.
Prepare and verify a release¶
Finish unrelated work first. Prepare release changes on a branch and merge
them into main after checks pass. The first release is 1.0.0, directly
to PyPI, without an RC or TestPyPI release.
- Run
uv version 1.0.0 --no-sync(substitute the next version for later releases). Review thepyproject.tomlanduv.lockdiff; avoid unrelated dependency upgrades. - Date the changelog entry using the intended release day (
YYYY-MM-DD), and verify installation instructions and links. - Validate the tag/version/changelog combination and run checks:
uv run --locked python scripts/release.py v1.0.0
uv run --locked pre-commit run --all-files
uv run --locked python -m pytest
- Build into a new, empty directory to avoid uploading old artifacts:
uv build --no-sources --out-dir /tmp/opencode/genplis-1.0.0-dist
uvx twine check --strict /tmp/opencode/genplis-1.0.0-dist/*
uv run --isolated --no-project --with /tmp/opencode/genplis-1.0.0-dist/*.whl python scripts/smoke_release.py 1.0.0
uv run --isolated --no-project --with /tmp/opencode/genplis-1.0.0-dist/*.tar.gz python scripts/smoke_release.py 1.0.0
uv publish --dry-run /tmp/opencode/genplis-1.0.0-dist/*
Use an appropriate empty temporary path on other machines. The smoke test
runs against installed artifacts, not src/, and checks both entry points
plus sequential, multi-process, and warm-cache playlist generation using
copies of the test media. Inspect the wheel and sdist contents too: the wheel
must include py.typed, the license, entry points, and package modules, not
local caches or benchmark outputs. The sdist must build without git metadata.
- Stage only the release changes, run
uv run pre-commit runafter staging, fix/re-stage if needed, and commit. Merge and wait for CI and website checks onmainbefore creating the annotated tag:
git switch main
git pull --ff-only github main
git tag -a v1.0.0 -m "genplis 1.0.0"
git push github v1.0.0
Automated publishing and verification¶
.github/workflows/release.yml runs on stable vX.Y.Z tag pushes. It reuses
the full CI checks (including Python 3.12–3.15 tests), validates the tag and
dated changelog entry, builds the wheel and sdist, checks metadata/README,
and smoke-tests both installed artifacts. Artifacts are built only once.
The separate publish job downloads these verified artifacts and uses PyPI
Trusted Publishing (OIDC), with id-token: write restricted to that job and
the pypi environment. Only after publishing succeeds does a separate job
create the GitHub Release with curated notes and the same wheel/sdist.
Verify the workflow, PyPI README/links, and GitHub Release notes/assets, then test an index installation outside the checkout:
uv run --isolated --no-project --refresh-package genplis --with genplis==1.0.0 genplis --version
Leave the package version at the released version until preparing the next release. If a job fails, prefer re-running failed jobs to preserve the original build artifacts. PyPI accepts retries of identical files, but does not allow replacing an uploaded distribution. Never move a published tag: fix a broken release with a new patch version (and yank the bad version on PyPI if necessary). The GitHub Release job can be retried independently.
Roadmap¶
- Parse tags of all files in music collection
- Cache tags to local DB
- Implement parser for filter grammar
- Implement filtering logic
- Filter files in collection
- Generate playlists (MVP complete!)
- Display tags when receiving a file as parameter
-
--excludeoption - Install as
genpliscommand - pre-commit
- Tests
- PR check
- Run pre-commit on whole project
- Test on every supported Python version
- Documentation
- Example cron and systemd files and instructions on how to run periodically
- Improve M3U generation
- Include original M3UG content as comment
- Support basic extended M3U tags:
#EXTM3Uand#EXTINF - Parallel parsing of files
- Config file support
- Improve M3UG grammar
- Command for DB cleaning
- Optimize memory usage
- Optimize DB space
- Display all available tags, with stats
- Debug M3UG
- Color output
- Website
- Improve Windows support (help wanted).
- Improve macOS support (help wanted).
- AI agent skill.
- Publish to PyPI.
Suggestions and contributions are welcome in GitHub issues.