pyproject.toml
Rama dev · réplica de lectura del repositorio original.
[build-system]
requires = ["hatchling>=1.32.4"]
build-backend = "hatchling.build"
[project]
name = "reaper"
version = "0.1.0"
description = "Grave decisions, clearly explained. Media library pruning for Plex, Sonarr, Radarr, Seerr and Tautulli"
readme = "README.md"
license = "AGPL-3.0-or-later"
requires-python = ">=3.13"
authors = [{ name = "Scythe Labs" }]
# Every floor is an exact release that exists on PyPI. Dependabot raises them.
dependencies = [
# Web
"fastapi>=0.146.0", # >=0.138 for app.frontend(), >=0.135 for fastapi.sse
"starlette>=1.9.0,<2.0.0", # PIN: 1.0 removed on_startup/on_shutdown; FastAPI declares no upper bound
"uvicorn[standard]>=0.54.0",
"pydantic>=2.14.1",
"pydantic-settings>=2.16.0",
"python-dotenv>=1.2.4", # imported directly (config.load_raw_env), not only transitively
# Persistence
"sqlalchemy>=2.1.5", # plain SQLAlchemy, NOT SQLModel (see docs/STATUS.md)
"alembic>=1.20.0",
"aiosqlite>=0.22.1",
"greenlet>=3.5.6",
# Integrations
"httpx2>=2.14.0",
"plexapi>=4.18.3",
"tenacity>=9.2.1",
# Scheduling — 3.x, NOT 4.x (still alpha: 4.0.0a6, 14 months stale)
"apscheduler>=3.11.3,<4.0.0",
"tzlocal>=5.0", # imported directly (app_settings host-zone detection), not only via apscheduler
# Secrets & auth
"cryptography>=50.0.2", # Fernet, for API keys at rest; 50.0.0 fixes CVE-2026-69247
"pwdlib[argon2]>=0.3.1", # NOT passlib (dead since 2020-10-08)
# Observability
"structlog>=26.1.0",
"requests>=2.34.2",
]
[project.optional-dependencies]
# Only the binary builds (.github/workflows/binaries.yml) install this group; it rides
# uv.lock so the bundler is as pinned as everything it bundles (rule 15).
package = [
"pyinstaller>=6.22.3",
"pillow>=13.0.0", # lets PyInstaller cut .ico/.icns from the one committed PNG
"pystray>=0.19.5", # the menu-bar/tray icon the desktop builds keep while serving
]
dev = [
"ruff>=0.17.1",
"mypy>=2.4.0",
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
"pytest-cov>=7.1.0",
"httpx>=0.28.1", # dev-only: no production import remains; respx speaks httpx only, not httpx2
"respx>=0.23.1", # httpx mocking (respx speaks httpx only, not httpx2)
"hypothesis>=6.170.2", # property tests for the scoring invariants
"pytest-xdist>=3.8.0",
"pytest-httpx2>=1.0.0", # httpx2 mocking; respx cannot (see docs/history/PLAN-narrative.md httpx2 scope)
"pyyaml>=6.0.3", # imported directly (test_repo_hygiene reads the CI config), not only via uvicorn[standard]
"types-pyyaml>=6.0.12.20260906", # its stubs, so the hygiene test type-checks with everything else in tests/
]
[project.scripts]
# The out-of-band escape hatch. Run inside the container:
# docker exec -it reaper reaper-admin reset-password --username admin
reaper-admin = "reaper.cli:main"
# What a packaged install runs: data dir, preflight, migrations, then the server.
# The PyInstaller binaries and the snap both enter here; see src/reaper/launcher.py.
reaper-server = "reaper.launcher:main"
[tool.hatch.build.targets.wheel]
packages = ["src/reaper"]
# ----------------------------------------------------------------------------
# Ruff
# ----------------------------------------------------------------------------
[tool.ruff]
line-length = 100
target-version = "py313"
src = ["src", "tests"]
# Migration bodies are machine-generated by `alembic revision --autogenerate`.
# Linting them means hand-editing every generated file for no benefit.
extend-exclude = ["alembic/versions"]
[tool.ruff.lint]
select = [
"E", "W", # pycodestyle
"F", # pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
"B", # bugbear
"A", # builtins shadowing
"C4", # comprehensions
"DTZ", # flake8-datetimez — naive datetimes are a real bug class here
"T20", # no print()
"SIM", # simplify
"PTH", # use pathlib
"RUF",
"ASYNC", # async footguns (blocking calls in async defs)
"S", # bandit
]
ignore = [
"S101", # assert — we use it in tests and for invariants
# ASYNC109: prefers asyncio.timeout() over a `timeout` parameter. Our timeouts
# are passed through to httpx, which handles them at the transport layer where
# they belong — wrapping in asyncio.timeout would cancel mid-request instead.
"ASYNC109",
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S105", "S106", "S107", "S311"] # fake secrets in fixtures; seeded rng in generators
"scripts/*" = ["S311", "T201"] # dev tools: seeded rng, console output
# A console entry point talks to its operator via print, and its bind-all default is
# the same one the container CMD ships.
"src/reaper/launcher.py" = ["T201", "S104"]
[tool.ruff.lint.isort]
known-first-party = ["reaper"]
# ----------------------------------------------------------------------------
# mypy — strict. The scoring engine's safety depends on exhaustive matching
# over Observation[T] (Known | Unknown | Absent), which only strict mode enforces.
# ----------------------------------------------------------------------------
[tool.mypy]
python_version = "3.13"
strict = true
warn_unreachable = true
disallow_any_explicit = false
plugins = ["pydantic.mypy"]
[[tool.mypy.overrides]]
# pystray, PIL, and AppKit ship only in the `package` extra (the desktop builds), so
# the dev environment mypy runs in never has them; the launcher imports all three
# behind guards.
module = ["plexapi.*", "apscheduler.*", "pystray.*", "PIL.*", "AppKit"]
ignore_missing_imports = true
# ----------------------------------------------------------------------------
# pytest
# ----------------------------------------------------------------------------
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
# `--dist worksteal` applies only under `-n`. Without `-n`, xdist forces `dist=no`, so a single
# worker debug run is unchanged. The default `load` hands out tests in chunks and never reclaims
# one, so the worker holding the 5-7s property tests finishes alone while the rest idle. Measured
# 53s -> 43-46s for the same 562s of CPU (docs/LEARNINGS.md, 2026-08-20).
addopts = "--strict-markers --strict-config -m 'not live' --dist worksteal"
# A test still running after this many seconds prints every thread's stack, so a hung test
# names itself in the CI log. It only prints. The job's own timeout still ends the run.
faulthandler_timeout = 120
markers = [
"live: talks to a real instance; requires .env.local. Deselected by default.",
# pytest-httpx2 registers this in its own pytest_configure, but exposes only the
# fixture (not the hook) from its package __init__, so under --strict-markers we must
# declare it here. Configures the httpx2_mock fixture (assert_all_called, base_url, ...).
"httpx2: configure the httpx2_mock fixture (see pytest-httpx2).",
]
[tool.coverage.run]
source = ["src/reaper"]
branch = true
# SQLAlchemy's async bridge runs the body of every session-using coroutine inside a
# greenlet, and coverage does not trace one unless it is named here. Without it the
# report was blind to most route and service bodies -- it recorded a line carrying an
# `await` and dropped its neighbors, so `deps._record_password_failure` read as unreached
# in all four password gates that in fact have tests asserting the exact 403 each one
# raises. It understated the suite by 2.8 points and, worse, made the codecov patch
# gate unusable: a genuinely untested line was indistinguishable from an artifact.
concurrency = ["thread", "greenlet"]