Ctrl K
ComunidadGuías…

pyproject.toml

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

Archivo original ↗Preguntar a KiBot
[project]
name = "server-homelab"
version = "0.0.0"
requires-python = ">=3.15"
# The renderer's whole runtime closure. promote.yml and ci.yml's validator jobs set UV_NO_DEV and
# UV_NO_GROUP=lint, so a package listed only in a group is absent there; its download cannot block them.
dependencies = [
  "pyyaml",
  "jinja2>=3",
]

[tool.uv]
package = false                      # virtual project: manage deps/lock, do not build the repo
default-groups = ["dev", "lint"]     # `uv run` installs both; UV_NO_DEV drops only `dev`
# DECIDED: no `compile-bytecode` here. uv applies it to the sync inside every `uv run`, which
# then recompiles the venv and prints `Bytecode compiled N files` on each call. The explicit
# syncs pass `--compile-bytecode` instead (checkout_sync.py, claude-clone-sync.sh, CI's setup
# action), so a root importer of daniel-box's checkout or the agent clone still finds bytecode
# the syncing user wrote (#6984). A session worktree's venv goes uncompiled.

# PyYAML ships no wheel for some interpreters, and a source build without libyaml's headers
# silently falls back to the pure-Python parser, which rejects input the C loader accepts.
# Forcing the extension makes that build fail instead; the hosts and CI install libyaml-dev.
[tool.uv.extra-build-variables]
pyyaml = { PYYAML_FORCE_LIBYAML = "1" }

[dependency-groups]
dev = [                              # `dev` is uv's default group for `uv run`/`uv sync`
  "pytest>=8",
  "ansible-core==2.22.0",           # test_toposort imports ansible.errors; also runs deploys via `uv run ansible-playbook`.
  # Pinned exactly, not `>=`: this is the one place the deploy-side ansible version is chosen.
  "requests",                       # community.docker modules
  "docker",                         # community.docker SDK-backed modules
  "flask",                          # ical-proxy ical_proxy.py imports flask at module top (test_app.py)
  "shellcheck-py==0.11.0.1",        # validate.shell_templates; pinned to match prek.toml's shellcheck-py rev
  "pytest-xdist",                   # -n auto in addopts below; pytest was 13.5s of CI's hook budget
  "jsonschema",                     # validate.k8s_manifests checks every object against the
  # schemas vendored in scripts/validate/schemas/: Kubernetes'
  # own OpenAPI files for core kinds, the catalog's for CRDs.
  "mkdocs",                         # test_mkdocs_repo_links renders with mkdocs.yml's extensions
  "pymdown-extensions",             # through the hook module, which imports mkdocs.utils.
  "pygments",
]
# Tools only prek hooks and hand-run commands call, never a test. The CI jobs that run no lint
# hook set UV_NO_GROUP=lint, so a lint tool's install (actionlint-py downloads its binary from
# GitHub releases) cannot fail them. ansible/tests/repo/test_ci_lint_group.py holds both halves.
lint = [
  "ruff>=0.16",                     # first-party Python lint gate (prek hook); pinned via uv.lock.
  # The floor is the version the [tool.ruff] `*.md` exclusion was written against.
  "actionlint-py",                  # workflow lint gate (prek hook); its build fetches the actionlint binary
  "mkdocs-material>=9.5",           # the docs site; `uv run mkdocs build` renders docs/ into site/
  "zizmor>=1.0",
  "ty==0.0.99",                     # first-party Python type gate (prek hook). Pinned exactly, not
  # `>=`, because ty is pre-1.0: a 0.0.x bump can add a rule or
  # change an inference and turn master red on an unrelated PR.
  # Renovate raises the bump as its own PR, which is where the
  # new diagnostics get read.
]

[tool.pytest.ini_options]
testpaths = [                        # single source of truth for WHAT runs; excludes vendored
  "ansible/tests",                   # ansible/collections/** third-party tests
  "ansible/roles/*/*/tests",         # every role's own logic tests, k8s and setup alike
  ".claude/hooks",
  "scripts",                         # closes the smoke_extract CI gap
  "evals",                                         # homelab eval-case schema validation
  "hosts/lib/tests",                               # the scripts the host packages ship (H3)
  "hosts/components/*/tests",                      # a host component's own scripts, as for a role
]
# `-n auto` (pytest-xdist) fans the suite across the machine's cores: 5.6s -> 3.0s locally, and
# pytest was 13.5s of the ~24s of hook work in CI's `rest` shard. Set here rather than in the prek
# hook so a bare `uv run pytest` gets it too. The suite is filesystem-read-only and order-
# independent, so it parallelises cleanly — if you add a test that writes shared state, fix the
# test rather than dropping this. Pass `-n0` to disable it for a debugging run (--pdb needs that).
#
# `--dist loadscope` keeps a module's tests on ONE worker. xdist's default `load` hands tests
# out one at a time regardless of the module they came from, so a module-scoped fixture is
# rebuilt on every worker that draws any of its tests — and this suite has expensive ones,
# chiefly `real_tree` in scripts/validate/tests/test_validate_k8s_manifests.py, which renders
# every k8s role. A single `--durations` run showed that one fixture three times over, at 22s,
# 24s and 28s of setup.
#
# Measured at `-n 4` (the GitHub runner's width), the two modes run interleaved on the
# same box so a busy neighbour could not favour either — an earlier non-interleaved pair read
# 148s vs 47s and was contention, not signal:
#   --dist load:      56.5s / 56.9s wall, ~160s CPU
#   --dist loadscope: 45.3s / 44.8s wall, ~155s CPU
# So it is ~20% of wall and almost no CPU: the saving is worker idle time, not work removed.
# At `-n auto` on a 16-core workstation the two are within noise (35s vs 38s) — the narrower
# the run, the more loadscope is worth, which is why it pays on CI and looks pointless locally.
#
# It is worth the flag because BOTH CI legs of a landing are pytest-bound: the `pytest` job's
# own test step measured 267s against the `hooks` job's 29-46s, and a landing waits on CI twice
# — once on the PR before auto-merge fires, once on the master merge commit.
#
# loadscope groups by class where a module has them; drop to `loadfile` only if a module's
# classes ever need splitting across workers.
# --strict-markers turns a misspelled marker into a collection error instead of a silently
# unregistered one; --strict-config does the same for an unknown key in this table.
# -p leakguard fails any test that reaches the network, the cluster or the host. It is
# registered here rather than from a conftest so it covers every `testpaths` entry at once;
# ansible/tests is on `pythonpath` below, which is what makes the bare module name resolve.
# -p xdist_width narrows `-n auto` to the run's selection: its recorded seconds over its heaviest
# module's, from scripts/dev/pytest_shard_weights.json, capped at the core count, so one file
# runs on one worker rather than sixteen and five heavy ones get three. The whole suite and a CI
# shard keep every core. Its module docstring has the measurements.
#
# -p render_share lets the workers of one run share a single placeholder render of the fleet
# instead of one per module (6 modules, 10-16s each). The cache lives only as long as the run;
# its module docstring has the design.
#
# -p phase_timing prints, on a run given `--durations`, what `--durations` cannot see: each
# worker's collection, prebuild and finish times, and the slowest module collections (#7710).
#
# No `-q` here, deliberately. `-q` is what a reader adds on the command line to shorten a scoped
# run, and pytest COUNTS the flag: with `-q` already in addopts, a hand-typed `-q` becomes `-qq`,
# which suppresses the summary line entirely. Measured on `scripts/lib/tests`: with
# `-q` in addopts, `uv run pytest scripts/lib/tests -q | tail -3` printed progress dots and no
# count for a 152-test pass, and `-k nosuchtestname -q` printed only `bringing up nodes...` — a
# run that collected nothing read exactly like one that passed. Reviewers had to override
# addopts to get a count back. The default verbosity
# costs ~2 header lines per xdist worker and prints the count every time.
addopts = "-n auto --dist loadscope -m 'not ui and not image' --strict-markers --strict-config -p leakguard -p xdist_width -p render_share -p phase_timing"
# Every warning is a failure. Measured before enabling: the only classes that
# fired were ResourceWarnings, each a real leak — HTTPError test doubles built on a tempfile
# nothing closed, an sqlite connection the Valheim store never closed, and the deployer's
# subprocess pipes left open on the timeout path. A DeprecationWarning from a dependency
# bump surfaces here too, on the PR that bumps it rather than on the day the API goes away.
filterwarnings = [
  "error",
]
# `ui` is registered here so it stays a marker rather than becoming a typo nobody notices,
# and so `--markers` documents it. The deselection lives in addopts above: these tests drive
# a real browser against live LAN routes and read SOPS, none of which a GitHub runner has.
# Run them with `uv run pytest -m ui`, which overrides the default -m.
markers = [
  "image: runs a script inside its pinned container image with `docker run`; deselected from the pytest shards (no Docker daemon) and run by .github/workflows/image-leg.yml with `-m image`",
  "ui: live checks against the homelab's own routes — browser smoke (scripts/diagnostics/tests/test_ui_smoke.py) and the Uptime Robot keyword rules (ansible/tests/repo/test_uptime_robot_keywords.py)",
]
# filter_plugins: lets test_toposort import toposort. hosts/lib: host_lib is a cross-role
# shared module (deployed INTO each setup script's /opt dir as a sibling) — on pythonpath so the
# import-tested renovate_notify.py + test_host_lib.py resolve it in CI. scripts: the k8s manifest
# tests import the render guards from there — _k8s_render.py did it with a sys.path insert, which
# only worked for modules importing _k8s_render first. gitops_deploy/files: lets a test import
# the deployer's modules by bare name, as the installed copy does.
# monitor-bridge/files: bridge/common.py is the same cross-role pattern as host_lib.py, staged
# into autofix-bridge's ConfigMap rather than duplicated — without this, `uv run pytest
# ansible/roles/k8s/autofix-bridge/files` alone can't resolve `from bridge.common import ...`
# (it only worked when monitor-bridge/files happened to already be on sys.path from the same run).
# game-stats/files: stats_lib.py is the same shape again, staged into both valheim-stats' and
# terraria-stats' ConfigMaps — without this, `import stats_lib` in either game's own script
# resolves only when a test that inserts game-stats/files happened to run first in the session.
pythonpath = [
  # `ansible/tests` so the guards in its subdirectories reach the shared `_helpers.py` and
  # `_k8s_render.py` at its root: pytest prepends only a test's OWN directory.
  "ansible/tests",
  "ansible/filter_plugins",
  "hosts/lib",
  "ansible/roles/k8s/game-stats/files",
  "ansible/roles/setup/gitops_deploy/files",
  "hosts/components/deploy-ui/files",
  "ansible/roles/k8s/monitor-bridge/files",
  # crowdsec/files: jobs_metrics.py is the same cross-role pattern, staged into autofix-bridge's
  # ConfigMap rather than duplicated (M6 batch B7). Without this, `from jobs_metrics import ...`
  # in autofix.py resolves only when a crowdsec test collected first.
  "ansible/roles/k8s/crowdsec/files",
  # flux_job_connect.py is the one transport of every Job script Flux runs, and
  # arr-notification's seed imports it from here on the controller path too.
  "scripts/renderer/job_files",
  # The claude-code host component's scripts, which its tests import by bare name the way a
  # role's tests import its `files/` through ansible/roles/conftest.py.
  "hosts/components/claude-code/files",
  # The same for the renovate-agent, renovate-notify, fake-remux and hypervisor components.
  "hosts/components/renovate-agent/files",
  "hosts/components/renovate-notify/files",
  "hosts/components/fake-remux/files",
  "hosts/components/hypervisor/files",
  # `scripts` resolves the cross-directory `from lib.docs_provenance import ...` form (a PEP 420
  # namespace package — the subdirectories deliberately have no __init__.py, which would change
  # how pytest names the test modules under them). Each remaining subdirectory is listed as well
  # because the modules under it still import their siblings by bare name, which resolves only
  # when that directory is itself on sys.path.
  #
  # `scripts/validate` and `scripts/diagnostics` are deliberately ABSENT. Their modules are
  # packages (`validate.k8s_manifests`, `diagnostics.probe_lib.core`) reached through `scripts`
  # alone, and leaving the directory listed would let a bare sibling import resolve here while
  # failing for the directly-invoked script that is how both actually run in production.
  "scripts",
  "scripts/backup",
  # its tests' `_reap_entrypoint_harness.py` holds the Longhorn CR builders that
  # ansible/tests/longhorn/test_longhorn_reap_guard.py imports too
  "scripts/backup/tests",
  "scripts/deploy_tools",
  # its tests share _land_fakes.py the way ansible/tests share _helpers.py
  "scripts/deploy_tools/tests",
  "scripts/dev",
  "scripts/docs",
  # its tests share _catalog_fixtures.py, as scripts/deploy_tools/tests share _land_fakes.py
  "scripts/docs/tests",
  "scripts/grafana",
  "scripts/home_assistant",
  "scripts/infra_map",
  "scripts/lib",
  "scripts/secrets_mgmt",
]

[tool.ruff]
# One minor behind `requires-python`: ruff supports py315 only in preview and warns on every
# run otherwise. 3.14 syntax is valid 3.15, so the rules lose nothing the code can use yet.
target-version = "py314"
# DECIDED: Markdown stays out of ruff's scope. ruff 0.16 began formatting Python code
# blocks inside .md, which pulled every prose file in the repo into a gate whose stated
# job is first-party Python — and its output mangles the plan docs, exploding readable
# two-element dict keys across four lines. Excluding *.md holds the gate to the same
# Python files ruff 0.15 covered; `uv run ruff format --check` prints the count, so
# don't restate one here. `ansible/collections` is the vendored third-party tree, never
# linted or formatted.
extend-exclude = [
  "ansible/collections",
  "*.md",
]
# The hosts run reconcile under Ubuntu 24.04's python3, which is 3.12. Formatting it for the
# repo's newer target writes 3.14-only syntax, `except A, B:` among it.
# hosts/lib/tests/test_reconcile_component.py parses the file with the 3.12 grammar. The
# repository signer runs under daniel-box's /usr/bin/python3 for the same reason, and
# ansible/roles/setup/homelab_builder/tests/test_repo_signer.py holds it to the same grammar.
# The signer imports host_lib.py, so that is py312 too, and
# hosts/lib/tests/test_host_lib.py parses it with the 3.12 grammar (#6202).
# rootq's root script runs under /usr/bin/python3 too, because uv's interpreter sits under a
# user's home, which root must not execute from; hosts/components/claude-code/tests/test_rootq.py
# parses it with the 3.12 grammar.
per-file-target-version = { "hosts/components/claude-code/files/homelab_rootq.py" = "py312", "hosts/components/claude-code/files/homelab_rootq_socket.py" = "py312", "hosts/components/claude-code/files/rootq_socket.py" = "py312", "hosts/lib/homelab-reconcile" = "py312", "hosts/lib/reconcile_lib/*.py" = "py312", "hosts/lib/ci_verdict.py" = "py312", "hosts/lib/k3s_gates.py" = "py312", "hosts/lib/homelab-k3s-restart" = "py312", "hosts/lib/homelab_repo_sign.py" = "py312", "hosts/lib/host_lib.py" = "py312" }

[tool.ruff.lint]
# Bug-catching rules only — pyflakes F (unused imports, undefined names, f-string bugs),
# syntax errors E9, bare-except E722, ==None/==True comparisons E711/E712, and flake8-bugbear
# B. Stylistic checks (E7 one-liners, ambiguous names) stay OFF — formatting is owned by
# `ruff format`.
#
# B earns its place on the same "real bugs only" test as the rest: it is the ruleset for
# constructs that are legal, silent and wrong. The 16 findings it opened on enabling were led
# by B023 (a closure over a loop variable in await_ci's CI poller) and B905 (six `zip()` calls,
# four of which had an equal-length invariant nothing asserted). Both classes fail by producing
# a plausible wrong answer rather than an error, which is the block-footguns criterion.
#
# The rest pass the same test, and each was measured against the tree before it was enabled:
# - E402 + RUF100 together. 131 `# noqa: E402` comments sat on the sys.path bootstrap imports
#   with E402 never selected, so the comments enforced nothing and nobody could tell; RUF100
#   found 156 such dead directives (BLE001, S310 and S506 among them). E402 makes the bootstrap
#   convention a lint, and RUF100 removes a noqa that names a rule this table does not run.
# - DTZ: 30 naive datetime calls. The staleness, grace-period and rotation code compares
#   timestamps across hosts and cron zones, where a naive value is the bug class the
#   grace-periods-must-be-derived-from-the-schedule incident belongs to.
# - RUF059 / RUF013 / RUF012 / PT017 / S110 / S112 / PLW0129 / PLW1508 / PGH003: 26 hits
#   between them. An unpacked variable nothing reads, an implicit Optional, a mutable class
#   default, an assert inside `except`, a swallowed exception, an assert on a string literal,
#   a non-string envvar default and a blanket `type: ignore` are each legal, silent and wrong.
# - PT011 / PT012 / RUF043: 60 hits on 2026-10-08, every one in a test. Each lets a
#   `pytest.raises` pass on the wrong failure: a bare `ValueError` with no `match=`, a block whose
#   earlier statement can raise first, or a `match=` pattern whose unescaped `.` or `(` matches
#   more than the message it names. That is a test staying green while checking nothing.
# - PLE / T10 / PIE794 / PIE796 / PIE800 / PIE804 / RUF006 / RUF009 / RUF018 / RUF024 / RUF034:
#   0 hits on 2026-10-08. Each is a bug class (a pylint error, a leftover breakpoint, a duplicate
#   class field or enum value, an un-awaited asyncio task, a call evaluated once as a dataclass
#   default, an assignment inside `assert`, a shared mutable `dict.fromkeys` value, identical
#   if/else arms), selected so the pattern cannot arrive rather than to clear a backlog.
# - ANN401: 14 hits on 2026-10-08, all in signatures. `json.loads` and `yaml.safe_load` return
#   `Any`, and a helper that annotates its return `Any` hands that to every caller: `ty` then
#   checks nothing the caller reads from the parsed JSON, so a response of the wrong shape
#   indexes, iterates and compares without a diagnostic. That is legal, silent and wrong.
#   The fix is a return type that is true (`lib.json_types.JsonValue`) and a narrowing step at
#   the caller, never an ignore.
#
# DOCSTRING LAYOUT (D205/D415/D212/D209/D210) IS THE ONE EXCEPTION to "bug-catching rules only",
# and it is a narrow one. Those five check the SHAPE of a docstring that already exists: a
# one-line summary ending in `.`, `?` or `!`, a blank line, the body, and the closing quotes on
# their own line. That shape is a written convention with a rule file behind it
# (~/.claude/rules/python.md, from Google's Python style guide section 3.8) that holds across
# 226 files and 900-odd docstrings. `ruff format` does not touch docstring bodies, so
# unlike the E7 checks this table turns off, no formatter can hold the convention — it either
# gets linted or it drifts back.
#
# The undocumented-symbol rules stay OFF, deliberately, and the numbers are here so nobody has to
# re-measure to reopen it: D103 118, D102 28, D107 9 outside tests. Ruff's versions have no size
# threshold, where the rule we actually adopted asks for a docstring when a function is public
# API, non-trivial in size, or non-obvious in logic. Selecting them means writing 155 docstrings
# on mostly-trivial functions — a wider rule than the docstring-layout rules above, so it
# needs its own argument.
select = [
  "F", "E9", "E402", "E711", "E712", "E722", "B",
  "DTZ",
  "RUF100", "RUF059", "RUF013", "RUF012",
  "PT017", "S110", "S112", "PLW0129", "PLW1508", "PGH003",
  "PT011", "PT012", "RUF043",
  "PLE", "T10", "PIE794", "PIE796", "PIE800", "PIE804",
  "RUF006", "RUF009", "RUF018", "RUF024", "RUF034",
  "ANN401",
  "D205", "D415", "D212", "D209", "D210",
]
# Explicit codes above, never the bare `D` prefix: D203/D211 and D212/D213 are mutually
# incompatible pairs, and selecting the prefix makes ruff warn and then silently drop one of
# each. `[tool.ruff.lint.pydocstyle] convention` is deliberately absent — measured on
# this repo's pinned ruff, it does not filter an explicitly-selected code, so beside these codes
# it would be redundant rather than load-bearing.

[tool.ruff.lint.per-file-ignores]
# Vendored upstream script, byte-for-byte: ansible/tests/services/test_karakeep_time_tagger_script.py
# asserts its sha256 against the commit the archived Docker role pins, so a lint fix here is a
# drift that test exists to catch.
#
# The docstring-layout rules are here for the same reason, and they are the load-bearing half:
# all 5 of the tree's D212 hits are in this one file, so without this entry `ruff check --fix`
# rewrites it and that sha256 test fails. A gate that forces an edit to a byte-pinned file is a
# gate nobody can turn on.
"ansible/roles/k8s/karakeep/files/karakeep-time-tagger.py" = [
  "RUF012",
  "D205",
  "D415",
  "D212",
  "D209",
  "D210",
]

# Test modules are exempt from TWO of the five layout rules, and the exemption is the reason
# this gate could land at all — it is not an oversight to tidy away later. Google makes a test
# module's docstring optional, and says a test docstring exists only to carry something beyond
# "Tests for foo": a run instruction, a setup pattern, an external dependency. The layout rules
# leave tests alone on that basis, so behind this ignore sit 253 D205 and 202 D209. Removing
# these two entries turns a 4-fix gate into a 455-error wall, which is how a later session
# "cleaning up" the ignore ends up reverting the whole thing instead.
#
# D415, D212 and D210 are NOT exempt, and that is deliberate. With D415 exempt here, nothing
# would stop 111 test docstrings from silently regressing at a colon — a fix with no gate
# behind it lasts until the next mechanical pass.
# (Counts measured with `--isolated --exclude '*.md' --exclude ansible/collections`, which is what
# bypasses these very entries; a plain `--select` run cannot see behind them.)
"**/tests/**" = ["D205", "D209"]
"**/test_*.py" = ["D205", "D209"]
"**/conftest.py" = ["D205", "D209"]

[tool.ty.environment]
python-version = "3.15"
# This repo has no `__init__.py` files and no installed package: a script reaches a sibling
# directory through the `sys.path` bootstrap `.claude/rules/python-layout.md` describes, and a
# test reaches its role's `files/` the same way. ty resolves imports from search paths alone, so without this list
# it reports 189 import-not-found diagnostics for first-party modules that resolve fine at
# runtime. The list is the union of pytest's `pythonpath` above, `.claude/hooks`, and the `files/`
# sibling of every `testpaths` entry — the same three sources the bootstrap draws on.
# ENFORCED: ansible/tests/repo/test_ty_config_covers_the_repo.py fails if a pythonpath entry
# is added here without a matching search path.
extra-paths = [
  "ansible/tests",
  "ansible/filter_plugins",
  "ansible/roles/setup/initial_setup/files",
  "hosts/lib",
  "ansible/roles/setup/gitops_deploy/files",
  "hosts/components/deploy-ui/files",
  "ansible/roles/k8s/monitor-bridge/files",
  "ansible/roles/k8s/game-stats/files",
  "ansible/roles/k8s/gpu-exporter/files",
  "ansible/roles/k8s/karakeep/files",
  "ansible/roles/k8s/longhorn-reconcile/files",
  "scripts",
  "scripts/backup",
  "scripts/deploy_tools",
  "scripts/dev",
  "scripts/docs",
  "scripts/grafana",
  "scripts/home_assistant",
  "scripts/infra_map",
  "scripts/lib",
  "scripts/diagnostics",
  "scripts/secrets_mgmt",
  ".claude/hooks",
  "ansible/roles/k8s/autofix-bridge/files",
  "ansible/roles/k8s/configarr/files",
  "ansible/roles/k8s/janitorr/files",
  "ansible/roles/k8s/qbittorrent/files",
  "ansible/roles/k8s/pihole/files",
  "ansible/roles/k8s/arr-notification/files",
  "scripts/renderer/job_files",
  "hosts/components/fake-remux/files",
  "ansible/roles/k8s/ical-proxy/files",
  "ansible/roles/k8s/homelab-mcp/files",
  "ansible/roles/k8s/crowdsec/files",
  "ansible/roles/k8s/traefik/files",
  "ansible/roles/k8s/artifacts/files",
  "hosts/components/k3s-host-checks/files",
  "hosts/components/hypervisor/files",
  "hosts/components/renovate-notify/files",
  "hosts/components/renovate-agent/files",
  "hosts/components/claude-code/files",
  "ansible/roles/k8s/home-assistant/files",
  "ansible/roles/k8s/tdarr/files",
  "ansible/roles/k8s/sonarr/files",
  "ansible/roles/k8s/dri-device-plugin/files",
  "ansible/roles/k8s/n8n/files",
  "ansible/roles/k8s/prowlarr/files",
  "ansible/roles/k8s/headlamp/files",
  "ansible/roles/k8s/observability/files",
  "ansible/roles/k8s/media-volume/files",
  "ansible/roles/k8s/jellyfin/files",
  "ansible/roles/k8s/registry/files",
  "ansible/roles/k8s/netpol-baseline/files",
  "ansible/roles/k8s/system-upgrade/files",
  "scripts/backup/tests",
  "scripts/deploy_tools/tests",
  "scripts/docs/tests",
]

[tool.ty.src]
# `ansible/collections` is the vendored third-party tree, excluded for the same reason ruff's
# `extend-exclude` skips it. `site` is the mkdocs build output, and `.claude/worktrees` holds
# other live sessions' whole checkouts of this same repo.
#
# The `include` list is required, not tidiness: dropping it does NOT fall back to the whole
# project. Measured 2026-09-02 — with `include` absent, a deliberately broken file at the repo
# root was not checked at all, while one under `scripts/` still was. A hand-kept list is the
# enumeration failure `testpaths` has a guard for, so this one has a guard too.
# ENFORCED: ansible/tests/repo/test_ty_config_covers_the_repo.py fails on a tracked .py
# file outside these directories.
include = [
  "scripts",
  ".claude",
  "ansible/roles",
  "ansible/tests",
  "ansible/filter_plugins",
  "evals",
  "hosts",
]
exclude = ["ansible/collections", "site", ".claude/worktrees"]

[tool.ty.rules]
# unresolved-attribute, invalid-assignment and unsupported-operator are errors since
# 2026-10-08. They were off from 2026-09-02, counted at 32, 10 and 14. While off they grew to 87,
# 18 and 76, because a rule that is off does not stop new code adding to it. Each of the 181 was
# fixed at its cause, not exempted. The causes, most common first:
# - An Optional used unnarrowed: an `re.match`, a `str | None` verdict or a `Path | None`. A
#   test now asserts the value is present first, so a None fails on the assert and its message.
# - Module-global cache dicts that inferred a value union; they now carry TypedDicts.
# - Test doubles bolted onto functions as attributes; they are now small classes.
# - Mixins calling methods only their host class defines; a TYPE_CHECKING stub declares them.
#
# Four line suppressions remain, each a `# ty: ignore[<rule>]`. Two are tests that omit a
# required argument to prove the call raises a TypeError. One is a test that writes to a frozen
# dataclass to prove it raises. One reads an attribute ansible's `constants` module sets at
# import time. The vendored karakeep script is exempted per file in [[tool.ty.overrides]] below.
#
# A per-file `ignore` beats a CLI `--error`, so a per-file exemption from any rule here hides it
# from a re-measure too. There is no per-file exemption for invalid-argument-type — every file in
# the source roots is gated on it.
#
# invalid-argument-type is at zero. Its diagnostics cluster on three causes: signatures taking
# `set`/`dict`/`Callable[[Sized], str]` when they only READ the argument, so wanting
# AbstractSet / Mapping / a type variable; the loader dance above; and `render_or_error`
# returning `tuple[str | None, str | None]`, whose correlation a destructuring loses, so
# `if err:` narrows the wrong name. A test's monkeypatch idiom is not among them, so tests get
# no per-file exemption.
#
# Widening an annotation to `Any` where the value really is heterogeneous — `_state` in the two
# mkdocs plugins, `hosts` in infra_map, three dict literals in tests — makes ty stop asking
# without making anything safer; each is a place a real type would be better if the shape were
# ever pinned down.
invalid-argument-type = "error"
unresolved-attribute = "error"
invalid-assignment = "error"
unsupported-operator = "error"
# Warning by default; an error here. `import urllib` alone then reaching `urllib.error` resolves
# only while something else in the process has imported the submodule, which is a dependency on
# an import in a file you are not reading.
#
# The sites this rule finds are LATENT, not live. checks/b2.py
# reaches `urllib.error.HTTPError` and imports only `urllib.parse`, but it also imports
# `bridge.net`, which imports `urllib.error` — so in the real monitor-bridge process it always
# resolved. validate/k8s_manifests.py gets `yaml.constructor` the same way, via `yaml.loader`.
# The rule is on because that is a guarantee held by a sibling module's import list rather than
# by anything local, and it breaks silently when the sibling's imports are tidied.
possibly-missing-submodule = "error"

[[tool.ty.overrides]]
# Two scripts run inside container images built elsewhere, so their third-party imports are not
# and should not be in this repo's dev group. The rule stays on everywhere else, where an
# unresolved import means a typo or a missing search path above.
#
# THE COLLATERAL, stated because it is not obvious: this is per-file, not per-module, so these
# two files lose the check on their FIRST-party imports too. homelab-mcp/files/app.py imports
# `k8s_reads` from its own directory, which is on extra-paths above and would otherwise be
# checked — a typo there now reports nothing here and surfaces as a ModuleNotFoundError when the
# pod starts. ty has no per-module suppression, so the alternatives are this or losing the rule
# tree-wide. The role's own tests are what cover that file's imports.
include = [
  "ansible/roles/k8s/homelab-mcp/files/app.py",
  "ansible/roles/k8s/karakeep/files/karakeep-time-tagger.py",
]
[tool.ty.overrides.rules]
import-not-found = "ignore"

[[tool.ty.overrides]]
# The vendored karakeep script is byte-pinned (ruff's per-file-ignores above has why), so a fix
# for its four `unresolved-attribute` diagnostics cannot land here. `__init__` sets
# `self.karakeep = None` and a later method assigns the client before any caller reads it, an
# ordering ty cannot follow. The
# override covers that one rule in that one file; ty checks every other rule there.
include = ["ansible/roles/k8s/karakeep/files/karakeep-time-tagger.py"]
[tool.ty.overrides.rules]
unresolved-attribute = "ignore"

[[tool.ty.overrides]]
# `claude_guard` is a different kind of external import from the block above: not a container
# image, but a sibling homelab package the dotfiles repo deploys to `~/.local/share/claude-guard`
# outside this checkout entirely — no `extra-paths` entry can reach it, since that path does not
# exist in CI or in a fresh clone. `.claude/hooks/_claude_guard.py` bootstraps it onto `sys.path`
# at runtime; `test_claude_guard_import.py::test_deployed_import_reaches_both_trusted_hosts` is
# what actually proves the import resolves, on a host where the package is deployed.
#
# Same collateral as above: `test_claude_guard_import.py` also loses
# the check on `import _claude_guard`, a first-party, `extra-paths`-covered import — a typo there
# would surface as a hook crash instead. `_claude_guard.py` imports nothing else first-party.
# `_hook_common.py` imports only
# `_claude_guard` first-party, and every hook test module imports it, so a typo there fails
# those suites at collection instead.
include = [
  ".claude/hooks/_claude_guard.py",
  ".claude/hooks/_hook_common.py",
  ".claude/hooks/tests/test_claude_guard_import.py",
]
[tool.ty.overrides.rules]
import-not-found = "ignore"

[[tool.ty.overrides]]
# `claude_worktree` is the same kind of import as `claude_guard` above: the dotfiles repo
# deploys it to `~/.local/share/claude-worktree`, and `scripts/lib/_claude_worktree.py`
# bootstraps it at runtime. ty cannot read its signatures, because CI checks out the dotfiles
# repo rather than carrying an in-tree copy. `lib.worktrees`' reader calls lose their type
# check with it, and the import test loses the check on `import claude_worktree`.
# `test_claude_worktree_import.py` and `test_worktrees.py` run against the real module in CI
# and on every host, and that is what catches a renamed reader.
include = [
  "scripts/lib/_claude_worktree.py",
  "scripts/lib/worktrees.py",
  "scripts/lib/tests/test_claude_worktree_import.py",
]
[tool.ty.overrides.rules]
import-not-found = "ignore"