# Python

> Write and edit Python for this repo — type hints pathlib and cross-platform subprocess and shell-out. Use when creating or changing .py files wiring up a subprocess call chasing a test that passes on POSIX but fails only on Windows CI (a WinError 2 or a mangled backslash path) or second-guessing syntax that looks wrong for an older Python (an unparenthesized multi-exception except clause).

- Skill: `niksavis/python` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add niksavis/python`
- Raw SKILL.md: https://api.skillmd.com/api/skills/niksavis/python/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: niksavis (https://skillmd.com/u/niksavis)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/niksavis/python

---

<!-- Generated by `basicly skills-build` from skill.yaml. Do not edit; edit the source. -->

# Python

Guidance for writing and editing Python in this ecosystem. Formatting, import
order, lint, and typing are enforced by the deterministic gates (`ruff format`,
`ruff check`, `pyright`) in the pre-commit hook — never restate a linter's rules
here or send an agent to do a formatter's job. This skill carries the judgment
those gates cannot.

## Style

- Type-hint public functions, methods, and dataclass fields; let inference cover
  obvious locals. A public signature is a contract — annotate it.
- Prefer `pathlib.Path` over `os.path` string munging for filesystem work.
- Do not hand-format to match the formatter. Run `ruff format` / `ruff check`
  (they run in pre-commit) and let them own whitespace, quotes, and import order.

## `except A, B:` is valid here — leave it alone

This repo's floor is Python 3.14 (`requires-python = ">=3.14"`), where PEP 758
makes an unparenthesized multi-exception clause legal:
`except ValueError, OSError:` catches either type. It is **not** the Python 2
`except Error, name:` capture form and not a syntax error — `src/basicly/decompose.py`
uses it and imports fine. Do not add parentheses to "fix" it; that is a no-op
diff, and stopping to re-verify that the module parses costs a whole detour.

Parentheses are still required when the clause binds the exception —
`except (ValueError, OSError) as err:`. Dropping them there raises
`SyntaxError: multiple exception types must be parenthesized when using 'as'`.

## Type test doubles and helpers precisely

The pre-commit gate is much more than `ruff`: it runs every check declared for
mode `fast` in `basicly.toml` — `pyright`, `bandit`, `lint-imports`, `vulture`,
`docs-claims` and the projection gates among them. So a worktree with `ruff` and
`pytest` both green can still be rejected at commit time, and a mistyped test
helper blocks the commit itself rather than CI. Commit early and let the hook
name the mismatch instead of inferring it from a clean `ruff check`.

Be precise about the type a helper *produces*, and structural about the slot a
double *fills*:

- Return the concrete type, not `object` — `def _fake_args() ->
  argparse.Namespace:`, not `-> object`. An `object` returned where a concrete
  type is expected trips `reportArgumentType` at the first call site that
  passes it on.
- Type a captured container to its real value type — `captured: dict[str,
  list[str]]`, not `dict[str, object]`. A `dict[str, object]` value trips
  `reportOperatorIssue` the moment the test indexes, iterates, or compares it.
- Annotate a parameter that *receives* a double with a structural type —
  `Callable[..., _Proc]` or a `Protocol` — never a sibling fake class. A helper
  typed `def _install(monkeypatch, fake: _FakeBr)` checks clean until a second
  fake or a `lambda` reaches it, and then pyright rejects those call sites with
  `"_FakeBrShow" is not assignable to "_FakeBr" (reportArgumentType)`. The same
  helper typed `Callable[..., _Proc]` accepts every one of them — `_install`
  in `tests/test_decompose.py`, which is fed `_FakeBr`, `_FakeBrShow`, and a
  bare `lambda`.

## Measure headroom before you place code

Two ratchets gate every module and pull opposite ways: `module-size` refuses growth on
a module at its frozen token baseline, and `comment-density` refuses a prose share
over its cap, so trimming a docstring to pay one raises the other. Paying them after
the code is written is the slowest order (four successive trims on one file, measured
2026-08-16). Before writing a line, measure every file the change will touch:

```sh
uv run python .scripts/headroom.py <file> [<file> ...]   # tokens left, prose points left
```

A file with under ~200 tokens left wants the extraction decided now, not after the
gate refuses. A new module is never free: it needs `tests/test_<module>.py` for the
naming gate, a layer in `.importlinter`, and a `git add` before pytest can import it.

## Cross-platform shell-out (fails only on Windows CI)

Two subprocess mistakes pass every local POSIX run and surface *only* on Windows
CI, so they read as "flaky" when they are deterministic. Both, with reproductions
and fixes, are in [references/cross-platform.md](references/cross-platform.md):

- **Building the child env** — inherit `os.environ` (keep `PATH`). A bare env dict
  drops `PATH`, which still finds `git` and friends on POSIX but raises
  `[WinError 2]` on Windows.
- **Embedding an OS path in a shell string** — POSIX `shlex.split` treats `\` as an
  escape, so a Windows path like `sys.executable` is mangled. Pass an argv list, or
  normalize with `Path(...).as_posix()`; never concatenate a path into a shell
  string.

Read the reference before touching any code that spawns a process or builds a
command line.

