Developer Guide
This guide covers everything you need to set up a development 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 debuggable.
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.
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 |