Developer Guide
This guide covers how to set up your dev environment, run tests, and contribute to VinylKit.
Prerequisites
- Python 3.12+
- uv — fast Python package and project manager
Install uv if you haven't already:
Bash (macOS / Linux):
curl -LsSf https://astral.sh/uv/install.sh | sh
PowerShell (Windows):
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
Development Setup
Clone and Install Dependencies
# Bash / PowerShell
git clone https://github.com/alaninspace/vinyl-kit.git
cd vinyl-kit
uv sync # installs all dependencies, including the dev group
Running the CLI in Dev Mode
Use uv run to execute the CLI directly from source without installing:
# Bash / PowerShell
uv run vinylkit [COMMAND]
Installing as a Global Tool
# Bash / PowerShell
uv tool install . --force
After installing globally, the vinylkit command is available everywhere in your terminal.
Rebuilding After Changes
# Bash / PowerShell
uv tool install . --force --no-cache
[!NOTE]
Your configuration file persists across reinstalls. It lives in a platform-specific location managed byplatformdirs(e.g.%LOCALAPPDATA%\vinylkit\vinylkit\config.tomlon Windows,~/Library/Application Support/vinylkit/config.tomlon macOS,~/.config/vinylkit/config.tomlon Linux), not inside the repo.
Project Structure
src/
└── vinylkit/
├── __init__.py # Package marker
├── __main__.py # Entry point for `python -m vinylkit`
├── cli.py # Root Click group, logging setup, main() entry point
├── commands/
│ ├── __init__.py # Package marker
│ ├── _helpers.py # Shared helpers, constants, and re-exported deps
│ ├── tag.py # scan, tag, rename commands
│ ├── migrate.py # migrate command
│ ├── auth.py # auth group: login, identity
│ ├── collection.py # collection group: download
│ ├── config_cmd.py # config group: show, set + _CONFIG_CONVERTERS
│ └── cache.py # cache group: list, clear
├── config.py # TOML config loading and saving (platformdirs)
├── discogs.py # Discogs API client, OAuth, and response caching
├── exceptions.py # Custom exception hierarchy
├── models.py # Frozen dataclasses (slots) and enums
├── naming.py # Path generation, template rendering, file moves
├── tagging.py # Mutagen-based MP3 (ID3v2) and FLAC tagging
└── utils.py # Backup helpers and filename sanitization
tests/
├── conftest.py # Shared fixtures (runner, mock_discogs, mp3_file, flac_file)
├── test_auth_logic.py # Authentication priority chain tests
├── test_cache.py # Cache list and clear command tests
├── test_cli.py # Core CLI interaction tests (CliRunner)
├── test_cli_commands.py # rename, scan, auth, config command tests
├── test_collisions.py # File collision detection and overwrite tests
├── test_config_roundtrip.py # Config set → show round-trip verification
├── test_discogs.py # Discogs API client tests (respx)
├── test_edge_cases.py # Unicode, empty tracklist, missing fields
├── test_examples_coverage.py # Ensures every doc example has a test
├── test_expanded_metadata.py # Expanded metadata field tests
├── test_help.py # Help output and rich-click formatting tests
├── test_logging.py # Loguru initialisation and config round-trip tests
├── test_migrate.py # Library migration command tests
├── test_naming.py # Naming and path generation tests
├── test_tagging.py # Tagging logic and scan tests
├── test_tagging_integration.py # Real MP3/FLAC tag round-trip tests
├── test_tagging_modes.py # Tag mode (replace/merge) behavior tests
└── test_utils.py # backup_file, sanitize_filename, ensure_absolute
docs/
├── quickstart.md # Setup and basic workflow
├── user-guide.md # In-depth command and feature reference
├── examples.md # Real-world command combinations
├── configuration.md # Full list of all settings
├── tag-mapping.md # Authoritative tag mapping reference (canonical names, MP3/FLAC keys, sources)
├── auth.md # Discogs authentication guide
├── data-model.md # Data model reference
├── spec.md # Feature specification
└── developer-guide.md # This file
Architecture Overview
Synchronous CLI
VinylKit is a synchronous CLI built on Click (via rich-click for enhanced help output) with httpx Client for API calls. There is no async code — this keeps the CLI simple and easy to debug.
rich-click Setup
All modules use import rich_click as click instead of import click. This is a drop-in replacement that adds colored panels, option grouping, and epilog rendering to --help output. The configuration lives in cli.py:
COMMAND_GROUPS— Splits root-level commands into "Core Commands" and "Administration" sections.OPTION_GROUPS— Groups options fortagandmigrateinto logical sections (e.g. "Release Identification", "Output Control").- Epilogs — Every command defines an epilog string (e.g.
_TAG_EPILOG) with real-world examples. Use@click.command(epilog=_MY_EPILOG)to attach it. - Dynamic epilogs —
config setbuilds its epilog from_CONFIG_CONVERTERSkeys so the valid-key list stays in sync automatically. - Context settings — The root group sets
help_option_names=["-h", "--help"]andmax_content_width=100.
Module Responsibilities
| Module | Responsibility |
|---|---|
cli.py |
Root Click group, initialise_logging(), main() entry point. Registers commands from commands/ subpackage |
commands/_helpers.py |
Shared helpers (collect_audio_files, extract_id, display_relative, plan_supplementary_moves, check_collisions, download_artwork, save_release_files), re-exported deps for single-point mocking |
commands/tag.py |
scan, tag (incl. batch folder iteration), rename commands |
commands/migrate.py |
migrate command |
commands/auth.py |
auth group with login, identity |
commands/collection.py |
collection group with download |
commands/config_cmd.py |
config group with show, set, _CONFIG_CONVERTERS dict |
commands/cache.py |
cache group with list, clear, _format_age helper |
config.py |
TOML config loading/saving via tomllib / tomli-w, path resolution via platformdirs |
discogs.py |
Discogs API client, OAuth flow, response caching, rate limit header tracking and dynamic throttling |
models.py |
Frozen dataclasses with slots=True (e.g. DiscogsRelease, AppConfig, AudioFile) and enums (TagMode, AuthMode, etc.). Exception: RateLimitInfo is intentionally mutable |
naming.py |
Filename template rendering, path generation, and safe file moves (cross-drive via shutil.move) |
tagging.py |
Mutagen-based tagging for MP3 (ID3v2) and FLAC (Vorbis comments), artwork embedding/saving, folder scanning (scan_folder), tag clearing (clear_audio_tags), track/disc calculation (calculate_track_and_disc), release info writing (write_release_info) |
utils.py |
Backup file creation, filename sanitization, and path resolution (ensure_absolute) |
exceptions.py |
Custom exception hierarchy rooted at VinylkitError |
Data Flow
CLI command (click, ctx.obj=AppConfig)
→ DiscogsClient (httpx)
→ DiscogsRelease model (frozen dataclass)
→ tagging.py (write tags to audio files)
→ naming.py (generate paths, move/rename files)
Key Conventions
from __future__ import annotationsin every file- Frozen dataclasses with
slots=Truefor most models — immutable and memory-efficient. Exception:RateLimitInfois intentionally mutable (updated in-place on every API response) - Custom exceptions for user-facing errors — never leak raw library exceptions to the CLI
VinylkitErrorhierarchy:ConfigError,AuthError,DiscogsAPIError,TaggingError,FileOperationError,ValidationError- Loguru logging: Use
from loguru import logger— nologging.getLogger(__name__). The globalloggerinstance routes to both console and file sinks configured ininitialise_logging(). Stdlib loggers (httpx, authlib) are bridged through an_InterceptHandler - Two-phase rename: The
tagcommand's post-tagging rename uses two phases — (1) rename files in-place within the source folder so they always have correct names, then (2) move tolibrary_root. If phase 2 fails (e.g. permissions), files are still tagged and properly named in the source folder - Cross-drive moves:
move_fileusesshutil.move(notPath.replace) so that moves between different drives/filesystems work correctly (copy + delete fallback) - Logging convention: Per-file operations in modules (
tagging.py,naming.py) uselogger.debug()so they only appear in the log file. Command-level summaries and release separators incli.pyuselogger.info(). HTTP request tracing indiscogs.pyuseslogger.debug(). Third-party HTTP loggers (httpx, httpcore) are suppressed to WARNING
Running Tests
VinylKit uses pytest as its test runner.
# Bash / PowerShell
# Run all tests
uv run pytest
# Verbose output
uv run pytest -v
# With coverage
uv run pytest --cov
# Run a single test file
uv run pytest tests/test_cli.py
# Run a specific test by name
uv run pytest -k "test_name"
Testing Stack
| Tool | Purpose |
|---|---|
pytest |
Test runner |
pytest-mock |
Mocking via mocker fixture |
pytest-cov |
Coverage reporting |
click.testing.CliRunner |
CLI invocation testing |
respx |
HTTP request mocking for Discogs API tests |
Test Conventions
- Shared
conftest.py— common fixtures live intests/conftest.py: runner—CliRunnerwith config isolated viaVINYLKIT_CONFIGenv var (prevents reading/writing real user config).mock_discogs— patchesget_client,tag_audio_file,clear_audio_tags,write_release_info, andsave_artwork. Does not mockmove_file/move_directory— tests that need file movement suppressed should patch those locally.mp3_file/flac_file— minimal valid audio files for real tagging round-trip tests.create_mock_release()— helper function (not a fixture) for buildingDiscogsReleaseobjects with sensible defaults.- Autouse fixtures —
_suppress_loguru_file_sink(session-scoped) prevents loguru file sinks during tests,_isolate_cache_dirredirects the cache directory totmp_pathfor every test, and acaplogbridge fixture routes loguru output to pytest's log capture. - Docs/examples parity — every example in
docs/examples.mdmust have a corresponding test intests/test_examples_coverage.py. If you add an example, add a test.
Linting, Formatting & Type Checking
Ruff (Lint + Format)
# Bash / PowerShell
# Check for linting errors
uv run ruff check .
# Auto-fix what can be fixed
uv run ruff check . --fix
# Format code (88-char line length)
uv run ruff format .
Enabled rule sets: E, F, W (pycodestyle + pyflakes), I (isort), N (pep8-naming), UP (pyupgrade), B (bugbear), A (builtins), SIM (simplify), TCH (type-checking), RUF (ruff-specific), PT (pytest-style), RET (return), ARG (unused-arguments), PTH (use-pathlib), PERF (perflint), FURB (refurb).
mypy (Type Checking)
# Bash / PowerShell
uv run mypy src/
mypy runs in strict mode. All new code must be fully type-hinted.
Type stubs:
| Library | Source |
|---|---|
mutagen |
Local stubs in stubs/mutagen/ (no PyPI package exists) |
authlib |
types-authlib dev dependency |
Documentation Web Application
VinylKit includes a built-in documentation server that serves this guide, the User Guide, and other references locally. It is styled with the "Vintage Stripe Doc System" and features real-time full-text search.
Running the Docs Server Locally
To start the documentation web server, run:
# Bash / PowerShell
uv run vinylkit-docs
By default, the server runs on http://localhost:8080. You can override the port by setting the PORT environment variable:
# Bash
PORT=9090 uv run vinylkit-docs
Architecture & Mechanics
- FastAPI Backend (
src/docs_web/main.py): A lightweight FastAPI application that dynamically parses local markdown files using Python'smarkdownlibrary and serves them through Jinja2 templates. - Jinja2 Templates (
src/docs_web/templates/): Defines the layout (base.htmlandpage.html), including navigation links, table of contents sidebars, and the search overlay. - Search System (
/docs/search): Scans all local markdown files, matches the query phrase case-insensitively, and returns highlighted match snippets to the frontend search palette. - On-the-fly Link Rewriting: The backend includes a helper
rewrite_md_links()that intercepts and rewrites relative.mdreferences (e.g.[User Guide](user-guide.md)) into clean absolute paths (e.g./docs/user-guide), guaranteeing robust page routing.
Developer Workflow for Doc Changes
- Editable Install: Because the package is installed in editable mode in the development virtualenv, any changes to code or documentation markdown files are picked up immediately.
- Hot-Reload: The FastAPI backend runs via Uvicorn with hot-reload enabled. Changes to Python files will trigger an automatic server restart.
- Browser Refresh: Changes to HTML templates, static CSS stylesheet (
src/docs_web/static/css/style.css), or JavaScript (src/docs_web/static/js/main.js) will reflect on the next browser refresh.
Deploying the Docs Web App
The documentation server is self-hosted on CogitoVM via Docker and proxied by Caddy gateway with automated Let's Encrypt TLS for vinylkit.app.
1. Local Validation
Run the validation script to execute tests, linter, and type checks:
.\.deploy\deploy.ps1
2. Automated Release via GitHub Actions
Deploying a documentation release is triggered by pushing a tag matching docs-v* (e.g. docs-v1.0.2):
git add .
git commit -m "docs: update documentation webapp (v1.0.2)"
git push origin main
git tag docs-v1.0.2
git push origin docs-v1.0.2
The GitHub Action (main_vinylkit-webapp.yml) will automatically deploy the updated container to CogitoVM and reload Caddy gateway.
Adding a New CLI Command
- Create a command in the appropriate
commands/module (or a new module). Use@click.command()(not@cli.command()) and register it incli.pyviacli.add_command(my_command):
# commands/my_module.py
@click.command()
@click.argument("path", type=click.Path(exists=True))
@click.pass_obj
def my_command(config: AppConfig, path: str) -> None:
"""Short description for --help."""
...
# cli.py
from vinylkit.commands.my_module import my_command
cli.add_command(my_command)
- Use
@click.pass_objto access theAppConfiginstance. - Access mockable deps through
_helpers(e.g._helpers.tag_audio_file) so tests can patchvinylkit.commands._helpers.Xin one place. - Raise custom exceptions from
vinylkit.exceptionsfor user-facing errors — themain()wrapper catchesVinylkitErrorand prints it cleanly. - Add tests using the shared
runnerfixture fromconftest.py:
from vinylkit.cli import cli
def test_my_command(runner, tmp_path) -> None:
result = runner.invoke(cli, ["my-command", str(tmp_path)])
assert result.exit_code == 0
Adding a New Config Option
- Add a field to
AppConfiginmodels.py:
@dataclass(slots=True, frozen=True)
class AppConfig:
...
my_option: str = "default_value"
- Update
load_config()inconfig.pyto read the new field from TOML. - Update
save_config()inconfig.pyto write it back. - Add a converter entry in
_CONFIG_CONVERTERSincommands/config_cmd.pysoconfig setworks:
_CONFIG_CONVERTERS: dict[str, Callable[[str], Any]] = {
...
"my_option": str,
}
- Add to
config showoutput incommands/config_cmd.py. - Update
docs/configuration.mdwith the new option.
Documentation Rules
- Any new feature must include updates to the relevant docs (README, user-guide, etc.).
- Any new example added to
docs/examples.mdmust have a corresponding test case intests/test_examples_coverage.py.
Code Conventions
| Convention | Detail |
|---|---|
| Future annotations | from __future__ import annotations in every file |
| Type hints | All functions fully type-hinted; must pass mypy --strict |
| Error handling | Custom exceptions from vinylkit.exceptions; never leak raw library exceptions |
| Safety | Destructive operations (rename, move) default to dry-run or require confirmation |
| Line length | 88 characters |
| Style | PEP 8 via ruff |