pyproject.toml
Rama main · réplica de lectura del repositorio original.
[project]
name = "stocktake"
dynamic = ["version"] # the release's tag; see [tool.hatch.version]
description = "Share portfolio tracker — performance, dividends, AU tax"
readme = "README.md"
requires-python = ">=3.12"
license = "AGPL-3.0-or-later"
dependencies = [
# `appcore` brings its own requirements; these are what this application
# uses directly. Declaring them rather than relying on a dependency's
# dependencies means an appcore that stops needing one does not silently
# break this.
"fastapi>=0.111",
"uvicorn[standard]>=0.30",
"pydantic>=2.7",
"pydantic-settings>=2.3",
"pyyaml>=6.0",
"sqlalchemy>=2.0",
"alembic>=1.13",
"psycopg[binary]>=3.1",
"jinja2>=3.1",
"python-multipart>=0.0.9",
"argon2-cffi>=23.1", # argon2id password hashing (multi-user auth)
"openpyxl>=3.1", # .xlsx export
"yfinance>=0.2.50", # price/FX feed (only ticker symbols ever leave)
"pdfplumber>=0.11", # dividend-statement parsing (local, no cloud OCR)
"tzdata>=2024.1", # zoneinfo on slim images
"ruamel.yaml>=0.18", # comment-preserving config.yaml edits (admin settings)
"pyotp>=2.9", # TOTP second factor
"qrcode>=7.4", # TOTP enrolment QR, rendered locally as inline SVG
"webauthn>=3.0.0", # passkeys: both ceremonies, and the signature checks
"appcore", # the suite's shared scaffold — pinned below
"pyjwt>=2.13.0",
]
[project.urls]
Homepage = "https://github.com/jarrodruggiero/stocktake"
Documentation = "https://jarrodruggiero.github.io/stocktake"
Source = "https://github.com/jarrodruggiero/stocktake"
Issues = "https://github.com/jarrodruggiero/stocktake/issues"
[tool.uv]
# Rebuild the project when the tag or commit moves, not only when this file
# does, or an install made before a release goes on naming the version before.
cache-keys = [{ file = "pyproject.toml" }, { git = { commit = true, tags = true } }]
# What builds the project, pinned. uv.lock holds what the project installs, not
# what builds it, so these resolved to whatever was newest in every job that
# installs the project, the one that tests a release among them. Dependabot
# does not read this list: move it by hand, then `uv lock`.
build-constraint-dependencies = [
"hatchling==1.32.4",
"hatch-vcs==0.5.0",
# …and what those two need.
"packaging==26.3",
"pathspec==1.1.1",
"pluggy==1.6.0",
"setuptools==84.0.0",
"setuptools-scm==10.3.4",
"tomlkit==0.15.1",
"trove-classifiers==2026.9.21.13",
"vcs-versioning==2.6.0",
]
[tool.uv.sources]
# Pinned to a revision rather than tracking a branch: nothing here changes
# because somebody pushed to appcore. Moving the pin is a deliberate act, and
# appcore's own CI runs THIS suite against its main, so a break shows up there
# before it ever reaches a pin.
appcore = { git = "https://github.com/jarrodruggiero/appcore", rev = "3638b61ef2d2abe345d46156da646d484bf3d1f8" }
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
# The version is read from the git tag, so the tag is the only place a release
# number is written: one kept here by hand said 0.64.0 for five releases
# (decisions.md #137). A tree with no git history, such as a source zip,
# installs as 0.0.0 rather than failing. Nothing in the app reads it, and the
# image never builds the project (`--no-install-project`).
[tool.hatch.version]
source = "vcs"
fallback-version = "0.0.0"
[tool.hatch.build.targets.wheel]
packages = ["app"]
# Test-only dependencies. NOT installed into the image (the Dockerfile installs
# the runtime list directly), so this group is free to grow.
[dependency-groups]
dev = [
"pytest>=8.0",
"httpx>=0.27", # fastapi.testclient
"freezegun>=1.5", # deterministic dt.date.today() — the app has 18 call sites
"ruff>=0.6",
"pytest-cov>=5.0",
"soft-webauthn>=0.1.3", # a signing authenticator, so passkey tests are real
"hypothesis>=6.100", # many generated cases per test, shrunk to the smallest that fails
]
# The documentation site, which docs.yml builds from this lock like everything
# else. `uv sync` takes only the dev group, so nothing else installs it.
docs = [
"mkdocs-material>=9.7.7",
]
[tool.ruff]
target-version = "py312"
# The codebase already sits at ~90 characters (p99); 100 gives headroom without
# inviting the long lines that make a diff unreadable.
line-length = 100
[tool.ruff.lint]
# Ruff's defaults (the pycodestyle errors worth failing a build over, plus
# pyflakes) and import ordering. Deliberately NOT the whole of `E`: E501
# line-length would flag 21 existing lines that are perfectly readable, and a
# formatting argument is not what CI is for.
#
# `B` (bugbear) and `UP` (pyupgrade) were tried and deferred: between them they
# want ~30 edits to app code — several of them behaviour-adjacent, like adding
# `strict=` to every zip() — which does not belong in the change that turns CI
# on. Worth enabling as its own pass, with the suite as the safety net.
select = ["E4", "E7", "E9", "F", "I"]
[tool.ruff.lint.isort]
# Say which packages are ours rather than letting ruff infer it: `appcore` is
# an installed dependency, and inference that depends on layout sorts the same
# file differently depending on where it is checked out.
# `factories`/`fixture_portfolio` are the test suite's own top-level helpers.
known-first-party = ["app", "appcore", "factories", "fixture_portfolio", "histories",
"test_routes"]
[tool.coverage.run]
# `app` only: appcore is a dependency now and measures itself, in its own
# repository, against its own gate.
source = ["app"]
branch = true
[tool.coverage.report]
# The gate. Raising this is the only way it should ever move — if a change
# drops coverage, write the test rather than lowering the number.
#
# TARGET IS 100. See tests/test_coverage_policy.py for where it stands, what is
# excluded and why. Every exclusion below, and every `# pragma: no cover` in
# the source, must carry a written justification — enforced by that test, not
# by good intentions.
fail_under = 92
show_missing = true
skip_covered = true
exclude_also = [
# Justification: a module's CLI entry point runs a process, not a function.
# Exercising it would mean spawning one; the work it does is covered by
# calling the functions it calls.
'if __name__ == .__main__.:',
# Justification: `if TYPE_CHECKING` blocks contain imports only, and exist
# for the type checker rather than the interpreter.
'if TYPE_CHECKING:',
# Justification: an abstract or protocol body has nothing to execute.
'^\s*\.\.\.$',
# Justification: a deliberate "not supported" branch exists in order to
# raise. Its body IS the raise, so covering it tests the language rather
# than anything this application decides.
'raise NotImplementedError',
]
omit = [
# Justification: Alembic's downgrade() paths. The app never downgrades —
# the migration chain is append-only by policy,
# and upgrade() is covered on both backends because every test builds its
# schema by running the chain. Testing downgrades would be testing a path
# the app is documented never to take.
"app/migrations/versions/*.py",
# Justification: the Alembic environment is configuration, executed by
# Alembic rather than by the app, and it is exercised on both backends
# every time a test builds its schema — but as a script, so the lines do
# not attribute to this package's coverage.
"app/migrations/env.py",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
# Assertion rewriting inside the helpers, so factories/fixture failures explain
# themselves the same way test-file asserts do.
python_files = ["test_*.py"]
markers = [
# Tests that start a fresh interpreter (the lazy-import probes in
# test_memory.py). A few seconds each, and they cannot be done in-process:
# by then something else has already imported the module under test.
"slow: spawns a subprocess; deselect with -m 'not slow'",
# Layout checks that drive a real headless Chrome over every page twice.
# Minutes, not seconds, so they are deselected by default below.
"visual: drives a browser; select with -m visual",
# Every route sent values a form cannot send (test_hostile_input.py): about
# 8,000 requests, a minute and a half. Deselected by default for the same
# reason as the visual tests.
"hostile: walks every route with hostile values; select with -m hostile",
]
# Visual and hostile-input tests are opt-in: `pytest tests` should stay a
# short loop. CI runs each as its own job with `-m visual` / `-m hostile`,
# which overrides this because addopts are prepended and the last -m on the
# command line wins.
addopts = ["-m", "not visual and not hostile"]
filterwarnings = [
# yfinance/pandas emit deprecation noise we neither cause nor can fix here.
"ignore::DeprecationWarning:yfinance.*",
"ignore::DeprecationWarning:pandas.*",
]