Skip to content

Development

Setting Up uv

This project is set up to use uv to manage Python and dependencies. First, be sure you have uv installed.

Then fork the technophile-musicfan/python-gazelle repo (having your own fork will make it easier to contribute) and clone it.

Basic Developer Workflows

The Makefile simply offers shortcuts to uv commands for developer convenience. (For clarity, GitHub Actions don’t use the Makefile and just call uv directly.)

# First, install all dependencies and set up your virtual environment.
# This simply runs `uv sync --all-extras` to install all packages,
# including dev dependencies and optional dependencies.
make install

# Run uv sync, lint, and test:
make

# Build wheel:
make build

# Linting (auto-fixes formatting and lint issues):
make lint

# Linting in check-only mode, matching CI (fails on issues, does not modify files):
make lint-check

# Run tests:
make test

# Delete all the build artifacts:
make clean

# Upgrade dependencies to compatible versions:
make upgrade

# To run tests by hand:
uv run pytest   # all tests
uv run pytest -s src/module/some_file.py  # one test, showing outputs

# Build and install current dev executables, to let you use your dev copies
# as local tools:
uv tool install --editable .

# Dependency management directly with uv:
# Add a new dependency:
uv add package_name
# Add a development dependency:
uv add --dev package_name
# Update to latest compatible versions (including dependencies on git repos):
uv sync --upgrade
# Update a specific package:
uv lock --upgrade-package package_name
# Update dependencies on a package:
uv add package_name@latest

# Run a shell within the Python environment:
uv venv
source .venv/bin/activate

See uv docs for details.

Per-Clone Setup (Fresh Clone or Second Machine)

Three things are not carried by git clone and must be done once per clone. make install does not do them, and nothing fails loudly if you skip them.

# 1. Git hooks. This sets core.hooksPath to .beads/hooks — local git config,
#    which no clone inherits. Without it the beads export/import hooks and the
#    pre-commit lint gate never run, silently.
bd hooks install --beads
bd hooks list                # expect five "installed" lines
git config core.hooksPath    # expect a path ending in .beads/hooks

# 2. Issue tracker state. There is no Dolt remote configured, so the tracked
#    .beads/issues.jsonl is the only channel between machines. Hydrate the local
#    database from it BEFORE any `bd` write — a write exports the whole file, so
#    exporting from a stale database drops issues created on the other machine.
bd import

# 3. Agent tooling. .claude/commands/ and .claude/skills/ are generated and
#    gitignored; regenerate rather than committing them.
openspec update

The hooks live in .beads/hooks/, which is tracked, so the project's own additions below each file's END BEADS INTEGRATION marker travel with the repo — bd hooks install preserves user content outside the markers. Only the core.hooksPath pointer is per-clone.

The Quality Gate

CI runs exactly these, and so should you before opening a pull request:

make lint-check   # codespell, ruff check, ruff format --check, basedpyright
make test         # pytest
make docs         # mkdocs build --strict

make lint is the same linting in fix mode (it rewrites files); make lint-check is the check-only form CI uses. A commit-time hook also runs ruff check and ruff format --check on staged Python files, so lint failures usually surface at git commit rather than in CI — that hook needs the per-clone setup above.

Style specifics: ruff with line-length 100 and the E/F/UP/B/I rule sets, 4-space indent, basedpyright clean at 0 errors and 0 warnings, codespell clean. basedpyright runs in its strict default for src; tests and devtools get relaxed rules via executionEnvironments (they hold loosely-typed fixtures and mocks and do some private-attribute introspection), while semantic checks such as reportArgumentType, reportCallIssue and reportOptionalMemberAccess stay on everywhere.

Testing

asyncio_mode = "auto", so write async def test_* directly — no @pytest.mark.asyncio decorator.

The suite has three tiers:

Tier Location Needs Behaviour when unavailable
Unit tests/test_*.py nothing (mock httpx transport) always runs
Model tests/models/ captured fixtures pytest.skip
Integration tests/integration/ tracker credentials pytest.skip

Missing credentials or fixtures make those tiers skip, not fail. A run reporting skips is healthy, not broken:

uv run pytest                              # everything available
uv run pytest --ignore=tests/integration   # unit + model only, no .env needed

Credentials for the integration tests

Copy .env.example to .env and fill in what you have. An API key alone covers most tests, and you only need the tracker(s) you actually have an account on; absent variables just skip the corresponding tests.

ORPHEUS_API_KEY, ORPHEUS_USERNAME, ORPHEUS_PASSWORD, REDACTED_API_KEY, REDACTED_USERNAME, REDACTED_PASSWORD

Be careful against live trackers

Never loop requests at a tracker after an auth error — that risks a ban. Verify with a single request and max_retries=0, and stay on read-only endpoints.

Fixtures for the model tests

uv run python devtools/capture_fixtures.py

This populates tests/fixtures/{orpheus,redacted}/ from live responses. The fixtures are gitignored on purpose — a real index.json contains your passkey and authkey — so every developer regenerates them locally and they are never committed. Without them, tests/models/ skips.

Architecture

An async-first client library for Gazelle trackers in src/pygazelle/, Python 3.11+, built on httpx for async HTTP and pydantic v2 for typed models.

Module Role
transport.py GazelleTransport — the HTTP layer: API-key or cookie/login auth, TokenBucket rate limiting, retry/backoff on 429 and 5xx, response parsing into typed errors
client.py GazelleClient — exposes resource namespaces (.torrents, .artists, .user, …); OrpheusClient and RedactedClient subclasses wire the per-tracker base URL and auth
resources/ One class per endpoint group, extending BaseResource; methods call transport.request(action, **params) and return models
models/ Pydantic response models extending GazelleModel (models/base.py)
sync.py Synchronous *Sync wrappers over the async clients (background event loop plus a proxy)
errors.py GazelleErrorGazelleAuthError, GazelleRateLimitError, GazelleNotFoundError, GazelleAPIError

The public API is re-exported from pygazelle/__init__.py — update __all__ when you add a public class. The test layout mirrors the source layout.

Conventions

Models. Extend GazelleModel, which configures pydantic v2 with alias_generator=to_camel (the API speaks camelCase, fields are snake_case), populate_by_name=True and extra="ignore". Declare new fields in snake_case; no per-field alias is needed.

Adding an endpoint. Add resources/<name>.py extending BaseResource, add its model under models/, expose it as a @property on GazelleClient, and export the public types from __init__.py.

Tracker divergences. Orpheus and RED return different schemas and use different auth. Handle this by making the base model tolerant — Optional fields, union types like bool | str | None — and not by adding per-tracker subclasses: resources always build the base model regardless of tracker, so a subclass is dead code unless the resource layer starts dispatching per tracker. Known divergences: RED uses a bare Authorization: <key> header, the redacted.sh host, and requires a User-Agent; Orpheus uses Authorization: token <key> and omits userstats.requiredRatio. Per-tracker configuration lives in GazelleTransport (api_key_prefix, user_agent) and in the client subclasses.

from __future__ import annotations in every module. This is not cosmetic. On Python 3.14 (PEP 649) annotations are evaluated lazily against the defining scope, so a method named like a builtin — inbox.list, say — shadows that builtin inside a sibling method's annotation (list[Message]) and crashes get_type_hints. The future-import keeps annotations as strings resolved against module globals, which avoids the trap entirely.

IDE setup

If you use VSCode or a fork like Cursor or Windsurf, you can install the following extensions:

  • Python

  • Based Pyright for type checking. Note that this extension works with non-Microsoft VSCode forks like Cursor.

Supply Chain Hardening

Dependencies are an attack surface. Before adding or upgrading any dependency, follow supply-chain-hardening, a concise cross-ecosystem guide on installing dependencies safely. Its key defaults:

  • Cool-off period: Don't install or upgrade to a release less than 14 days old (absent a documented exception)—most malicious publishes are caught within days. For uv, set UV_EXCLUDE_NEWER to a cutoff date a couple weeks back (uv takes a date, not a duration); this project's CI workflows set it automatically.

  • Vet before adding: Confirm the package is actually needed and its name is spelled correctly (typosquats are common), and prefer a little first-party code over a new dependency.

  • Pin, lock, and audit: Commit your uv.lock, pin GitHub Actions to a commit SHA or immutable tag, and run a vulnerability audit (e.g. pip-audit) after changes.

Documentation


This file was built with simple-modern-uv.