Ctrl K
ComunidadGuías…

pyproject.toml

Rama dev · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
[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"]