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