Ctrl K
ComunidadGuías…

pyproject.toml

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

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