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 by platformdirs (e.g. %LOCALAPPDATA%\vinylkit\vinylkit\config.toml on Windows, ~/Library/Application Support/vinylkit/config.toml on macOS, ~/.config/vinylkit/config.toml on 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 for tag and migrate into 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 epilogsconfig set builds its epilog from _CONFIG_CONVERTERS keys so the valid-key list stays in sync automatically.
  • Context settings — The root group sets help_option_names=["-h", "--help"] and max_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 annotations in every file
  • Frozen dataclasses with slots=True for most models — immutable and memory-efficient. Exception: RateLimitInfo is intentionally mutable (updated in-place on every API response)
  • Custom exceptions for user-facing errors — never leak raw library exceptions to the CLI
  • VinylkitError hierarchy: ConfigError, AuthError, DiscogsAPIError, TaggingError, FileOperationError, ValidationError
  • Loguru logging: Use from loguru import logger — no logging.getLogger(__name__). The global logger instance routes to both console and file sinks configured in initialise_logging(). Stdlib loggers (httpx, authlib) are bridged through an _InterceptHandler
  • Two-phase rename: The tag command'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 to library_root. If phase 2 fails (e.g. permissions), files are still tagged and properly named in the source folder
  • Cross-drive moves: move_file uses shutil.move (not Path.replace) so that moves between different drives/filesystems work correctly (copy + delete fallback)
  • Logging convention: Per-file operations in modules (tagging.py, naming.py) use logger.debug() so they only appear in the log file. Command-level summaries and release separators in cli.py use logger.info(). HTTP request tracing in discogs.py uses logger.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 in tests/conftest.py:
  • runnerCliRunner with config isolated via VINYLKIT_CONFIG env var (prevents reading/writing real user config).
  • mock_discogs — patches get_client, tag_audio_file, clear_audio_tags, write_release_info, and save_artwork. Does not mock move_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 building DiscogsRelease objects with sensible defaults.
  • Autouse fixtures_suppress_loguru_file_sink (session-scoped) prevents loguru file sinks during tests, _isolate_cache_dir redirects the cache directory to tmp_path for every test, and a caplog bridge fixture routes loguru output to pytest's log capture.
  • Docs/examples parity — every example in docs/examples.md must have a corresponding test in tests/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's markdown library and serves them through Jinja2 templates.
  • Jinja2 Templates (src/docs_web/templates/): Defines the layout (base.html and page.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 .md references (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

  1. Create a command in the appropriate commands/ module (or a new module). Use @click.command() (not @cli.command()) and register it in cli.py via cli.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)
  1. Use @click.pass_obj to access the AppConfig instance.
  2. Access mockable deps through _helpers (e.g. _helpers.tag_audio_file) so tests can patch vinylkit.commands._helpers.X in one place.
  3. Raise custom exceptions from vinylkit.exceptions for user-facing errors — the main() wrapper catches VinylkitError and prints it cleanly.
  4. Add tests using the shared runner fixture from conftest.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

  1. Add a field to AppConfig in models.py:
@dataclass(slots=True, frozen=True)
class AppConfig:
    ...
    my_option: str = "default_value"
  1. Update load_config() in config.py to read the new field from TOML.
  2. Update save_config() in config.py to write it back.
  3. Add a converter entry in _CONFIG_CONVERTERS in commands/config_cmd.py so config set works:
_CONFIG_CONVERTERS: dict[str, Callable[[str], Any]] = {
    ...
    "my_option": str,
}
  1. Add to config show output in commands/config_cmd.py.
  2. Update docs/configuration.md with 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.md must have a corresponding test case in tests/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