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¶
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 |
GazelleError → GazelleAuthError, 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:
-
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_NEWERto 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.