[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.*", ]