[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"]