agentleFS
buildosaurusbuildosaurus/scripteratops/skills/python-script/SKILL.md

python-script

Skill0 starsChanged 3 months agoMITView on GitHub

Write self-contained, portable, testable Python command-line tools — a single-file uv/PEP 723 script with an argparse CLI and pure-logic/side-effect separation that pytest can test. Use when the user wants a Python script, CLI utility, automation tool, or "glue" code (orchestrating git, curl, file manipulation, parsing) in Python — they name Python, or are working in a Python codebase. Also triggers on adding tests to an existing Python script, making a Python script portable/testable, or porting a shell script to Python.

  • Reads credentials

2 other projects use a version of this.

Adopted by

Most recently taken up by

  • Other versions2 projects use a version of this.

    Word for word · 0

    None found.

    Edited · 0

    None found.

    Wrote their own version · 2

    1. vaayne/agent-kit★ 54same name
    2. haztecaso/skills★ 0same name

    How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.

  • In this repository7 other files it gives its agents.

The file

We split this skill into the rules it gives an agent

8 rules. About 0 are common, 3 are distinctive, and 5 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 5

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Every script starts with this block.

    line 19
  2. Very specific:

    Keep dependencies empty by default.

    line 30
  3. Very specific:

    Add a dependency only for a real gain: httpx (serious HTTP — the default network library, §2.3), rich (tables, progress bars, syntax …

    line 31
  4. Very specific:

    Never shell=True with interpolated input: Always a list of args (["git", "-C", str(path), "status"]).

    line 71
  5. Distinctive:

    If a script draws settings from more than one place, resolve them with an explicit, documented precedence.

    line 75
  6. Distinctive:

    Follow the cross-tool convention so scripts behave predictably in pipes and CI:

    line 107
  7. Distinctive:

    Every script exposes the same baseline interface.

    line 185
  8. Very specific:

    Start from assets/template_script.py + assets/test_template_script.py, or assets/template_http.py + assets/test_template_http.py for …

    line 305

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Python script

Standard for all Python scripts: a single self-contained file, runnable via uv, with an argparse CLI and logic testable with pytest. The goal is the distribution lightness of a Bash script (a single file, no prior install) without paying its fragility.

Three decisions are already settled. Don't second-guess them, apply them:

  1. Distribution → uv / PEP 723 header.
  2. Interface → argparse (stdlib, zero dependencies).
  3. Tests → pytest, made easy by an architecture that isolates side effects.

This file carries the decisions; the code implementing them lives in four gated templates. Copy and adapt one — don't retype it from here: assets/template_script.py + assets/test_template_script.py (base), assets/template_http.py + assets/test_template_http.py (network). The remaining files in assets/ are tooling, not templates: check_skill.py + test_check_skill.py are this skill's own drift guard (see Quality gates) — never copy them into a user's script — while install.py (+ test_install.py) and README.template.md are the shared installer and README starter you reach for when shipping an installed tool (below).

1. uv header (PEP 723)

Every script starts with this block. It makes the file self-contained: uv run script.py reads the metadata, builds an ephemeral environment (cached on subsequent runs), and executes. No venv to activate, no requirements.txt to drag around.

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = []
# ///
"""A one-liner saying what the script does."""
  • Keep dependencies empty by default. argparse, pathlib, subprocess, json, os, sys, shutil, tempfile, tomllib cover the overwhelming majority of needs.
  • Add a dependency only for a real gain: httpx (serious HTTP — the default network library, §2.3), rich (tables, progress bars, syntax highlighting), pydantic (complex config validation). Otherwise, stdlib.
  • Pin every dependency to an exact version (dependencies = ["httpx==0.28.1"], not httpx or httpx>=0.27). A single-file script has no lockfile, so an unpinned dep silently upgrades on the next run and a breaking release changes behavior.
  • Pin requires-python = ">=3.12": advanced f-strings, pattern matching, better error messages, clean typing.
  • The shebang makes the file directly executable (chmod +x script.py && ./script.py) while remaining launchable via uv run script.py.
  • YAML is out of scope; standardize config on TOML (recommended, supports comments) or JSON.

2. Script structure

The structure isn't cosmetic: it's designed to make tests trivial. The guiding principle is that pure logic must never touch the outside world directly. Everything that "leaves" the process (subprocess, network, filesystem, env, print, sys.exit) is isolated in thin boundary functions, easy to mock. The rest is pure logic: input → output, testable without any mock.

Canonical order in the file — template_script.py is exactly this:

shebang → uv header → docstring → from __future__ import annotations + imports → __version__ → constants → logger → Boundary → Pure logic → Config → Orchestration → Typed errors → Logging + color → Signals → CLI → __main__ guard

Divide those regions with # --- Section name --- banners; typed errors sit below the boundaries that raise them (names resolve at call time). Each file gets a one-line module docstring (argparse consumes it as the CLI description, so it is user-facing), __version__ = "0.1.0", and one logger named after the script's own module stem — getLogger("template_script"), not getLogger("script"), which collides the moment two of these run in one process.

Non-negotiable points:

  • main() takes argv and returns an int, so main(["--dry-run", "/tmp"]) is directly callable in tests without intercepting SystemExit.
  • sys.exit() appears in exactly two places: the final if __name__ guard and the SIGTERM handler. Every other path returns an int. The handler is a real carve-out — it has no return value to propagate, so raising SystemExit is the only way to unwind finally/with blocks on the way out.
  • parse_args(argv) separate from main(), so parsing tests in isolation. main() opens with parse_args → setup_logging → install_signal_handlers, all outside the try; the try's first statement is the tool preflight.
  • A single entry point to each type of side effect (run(), fetch_json(), read_config_file()/read_dotenv()/read_env()). That's the single mock hook — and "single" is literal: a second read point elsewhere in the file silently reopens what the boundary was meant to close.
  • An orchestration layer (do_work) assembles boundary calls plus pure logic, separate from both: data positional, behavior flags keyword-only (bare *), returns an int count.
  • Typed errors (one class per failure category, subclassing RuntimeError), never catch-all except Exception. Catch at main(), log log.error("%s", exc), convert to an exit code. Chain conversions with raise ... from exc.
  • Fail fast, clear message on stderr, non-zero exit code. The Python equivalent of set -euo pipefail.
def do_work(target: Path, config: Config, *, dry_run: bool = False, as_json: bool = False) -> int: ...  # sketch — see assets/template_script.py
def main(argv: list[str] | None = None) -> int: ...  # sketch — see assets/template_script.py

2.1 Subprocess boundary

def run(cmd: list[str], *, cwd: Path | None = None, timeout: float | None = DEFAULT_TIMEOUT) -> str: ...  # sketch — see assets/template_script.py

Route all subprocess calls through it: check=False + capture, raising a typed CommandError (exposing .cmd, .code, .stderr) on non-zero, returning stripped stdout.

  • Bound every subprocess with timeout= — an unbounded child is the classic way a script hangs forever, the same rule as httpx's Timeout. DEFAULT_TIMEOUT = 30.0 (seconds, a float); the httpx template reuses the name for an httpx.Timeout — same name, different type.
  • TimeoutExpired → CommandTimeout, a subclass of CommandError, which main() maps to exit 124. The exit code is a property of the failure category, so it belongs in the type, not an attribute nobody dispatches on. main() must catch CommandTimeout before CommandError: except matches in source order, so the parent would otherwise shadow the subclass and swallow 124 into a generic 1.
  • Never shell=True with interpolated input. Always a list of args (["git", "-C", str(path), "status"]). No shell means no quoting bugs and no command injection from a filename, config value, or argument.

2.2 Config and precedence

If a script draws settings from more than one place, resolve them with an explicit, documented precedence. Higher wins:

CLI flags  >  environment variables  >  .env file  >  config file  >  built-in defaults

This is the prevailing standard (twelve-factor; python-dotenv's default, where a real exported env var is NOT overridden by .env). The .env file is a dev convenience that populates env vars locally, so an actual env var — set in CI or a container — must win over it. Config files sit one rung lower for the same "deployment overrides file" logic. Do not invert this (a common footgun is letting .env override real env vars).

Two rules make it testable:

  1. Each source is read by one boundary function — read_config_file(path) (dispatches .toml→tomllib, .json→json, both stdlib; other suffixes raise ConfigError), read_dotenv(path), read_env(). These are the mock hooks. Env earns a real function, not an inline dict(os.environ): an expression has no name to patch, so tests must reach past it to os.environ — the stdlib beneath the boundary, which §4 forbids. should_color reads env through that same read_env(), so colour and precedence can never disagree about the environment.
  2. Merging is a single pure function — resolve_config(*, cli, env, dotenv, file_cfg) -> Config takes the already-loaded dicts and layers them; no I/O, so it's tested with plain asserts and no mocks.

Model config as a @dataclass(frozen=True), layered via replace(), skipping absent/None so an unset higher layer never clobbers a lower one. Note the key mapping: the env var is API_URL but the field is api_url — env and .env read through an UPPER_SNAKE keymap (CLI and files use field names) so real env vars actually apply; a silent name mismatch is easy to ship otherwise. Coerce picked values to str: a TOML port = 8080 arrives as an int and would land in a str | None field — only an annotated helper lets the type checker catch it.

Each file source is reached by an explicit flag, never auto-loaded from the CWD — an implicit pickup is a silent footgun (run the script elsewhere, get different config). Wire --env-file and --config (type=Path, default=None, each ranking itself in its help=). And wire the CLI layer in: main() passes cli={"api_url": args.api_url} — "CLI flags win" only exists if something passes it. Secrets get no CLI flag: argv is world-readable via ps, so a token comes from env / .env / config-file only.

2.3 HTTP requests

The default network library is httpx — modern (sync + async, HTTP/2), requests-compatible API. Pin it in the uv header: dependencies = ["httpx==0.28.1"]. For a single trivial no-dep GET, stdlib urllib.request is an acceptable fallback — anything non-trivial uses httpx.

Two rules, mirroring the run() boundary:

  1. Always set an explicit timeout — an unbounded HTTP call is the most common way a script hangs forever. DEFAULT_TIMEOUT = httpx.Timeout(10.0), on every constructed client.
  2. Wrap HTTP in ONE boundary function returning plain parsed JSON (typed -> object, never leaking Response objects) and raising a typed HttpError (wrap the httpx.HTTPError base class, from exc). Call resp.raise_for_status() inside it — httpx does not raise on 4xx/5xx, so without that line a 404's error page sails through as a successfully parsed payload. That's the single mock hook. Accept an optional client so tests can inject an httpx.MockTransport and run with zero network; forward it through orchestration (do_work(url, *, client=None, ...)), and close it in finally only when the boundary created it (owns_client = client is None).
def fetch_json(url: str, *, method: str = "GET", client: httpx.Client | None = None, **kwargs: Any) -> object: ...  # sketch — see assets/template_http.py

The URL is a secret-bearing surface — for the JSON-API scripts this template exists to seed, the API key rides in the query string (?api_key=…). Route everything you log or raise through a redacted_url() helper: the DEBUG line, the INFO line, and the HttpError message — that last is the leakiest, since main() logs it at ERROR (visible with no -v at all) and httpx embeds the full URL inside HTTPStatusError. The request uses the raw URL; only the reporting is masked. Pin httpx's own logger to WARNING in setup_logging: it logs every request's full URL at INFO, so a single -v would leak the raw key straight past your redaction.

2.4 Colorized output

Follow the cross-tool convention so scripts behave predictably in pipes and CI:

  • Auto-detect by default — emit ANSI color only when the target stream is an interactive terminal (stream.isatty()). Piped or redirected output gets no color (otherwise escape codes corrupt logs and break grep). Logs go to stderr, so the decision keys on sys.stderr.isatty(), independent of stdout. Probe it defensively: getattr(stream, "isatty", lambda: False)().
  • Respect NO_COLOR — if present (any value, even empty), disable color (no-color.org). Its counterpart FORCE_COLOR forces it on (for CI, where there's no TTY). Treat TERM=dumb as no-color too.
  • A --color {auto,always,never} flag (default auto).
  • Precedence: --color=always/never > NO_COLOR/FORCE_COLOR > TTY auto-detect.

Pure stdlib — colorize the log level name via a logging.Formatter, gated on the decision. Keep stdout data uncolored by default (auto-detect separately only if the script prints human-facing tables).

def should_color(stream: object, mode: str = "auto") -> bool: ...  # sketch — see assets/template_script.py

setup_logging(verbosity, color=...) picks ColorFormatter vs the plain one via should_color(sys.stderr, color). When you need more than colored levels — tables, progress bars, syntax highlighting — reach for rich; it handles this detection for you. The stdlib helper is for the common "readable colored logs, no dependency" case.

2.5 Robustness & safety

A world-class script survives the messy edges: hung children, closed pipes, kill, missing tools, half-written files, and secrets in logs. Bake these in — template_script.py has them wired.

  • Bound every subprocess with a timeout, and never shell=True → §2.1.
  • Handle SIGPIPE. yourscript | head closes the pipe early; unhandled, Python raises BrokenPipeError and prints an ugly Exception ignored in: traceback at shutdown. Catch it in main(), point stdout at os.devnull, return 141 — wrapping the os.dup2(..., sys.stdout.fileno()) call in try/except (OSError, ValueError), since where stdout lacks a real descriptor (pytest's capture) fileno() raises: that guard is the difference between exit 141 and a crash in your error handler.
  • Handle SIGTERM gracefully. kill, container stops, and timeout(1) send SIGTERM, not SIGINT. Install a handler raising SystemExit(143) so the process unwinds cleanly (context managers / finally run). Leave SIGINT on Python's default — it already raises KeyboardInterrupt, which main() maps to 130.
  • Cross-platform: this handling is portable. SIGTERM registers on both Unix and Windows; BrokenPipeError is a plain OSError on both; os.devnull resolves to /dev/null or nul automatically. Windows delivers SIGTERM differently at the OS level, but registration never errors, so the code is safe everywhere.
  • Preflight external tools. For glue scripts that shell out, check with shutil.which up front — the first statement in main()'s try — raising ConfigError listing every missing tool. A clear "tool not found" beats a cryptic FileNotFoundError mid-run.
  • Write files atomically (same-dir temp, fsync, os.replace()). Never leave a half-written output if the run dies mid-write.
  • Never log secrets. Config can carry a token; one log.info("config: %s", config) at -v leaks it to stderr and CI logs. Redact before logging: route config through a redacted() helper masking a declared _SECRET_FIELDS set. URLs → §2.3.
  • Retry with backoff for flaky operations (wrap the boundary, exponential sleep). For HTTP, prefer httpx's HTTPTransport(retries=n).

2.6 Subcommands (if the script needs them)

For a tool with multiple verbs (tool build, tool clean), use add_subparsers(dest="command", required=True), with a help= on each subparser. Each subcommand gets its own cmd_xxx(args) -> int wired via set_defaults(func=cmd_xxx), and main() dispatches return args.func(args).

2.7 More patterns (situational, opt-in)

  • Machine-readable output — --json makes do_work emit one JSON object ({"count": n, "items": [...]}) on stdout instead of plain lines. Give it an explicit dest="as_json" and thread as_json into orchestration.
  • Concurrency for I/O fan-out — many independent HTTP/subprocess calls run far faster in parallel. Keep the boundary unchanged and drive it from a ThreadPoolExecutor.
  • stdin / - convention — treat - (or an absent positional) as "read stdin", so the script composes in pipes. Path-valued arguments take type=Path.

2.8 Man page (optional)

To ship a man script page, keep parser construction in build_parser() -> ArgumentParser (the templates split it from parse_args, which just calls build_parser().parse_args(argv)) and generate the page with argparse-manpage — straight from uv, no system install:

uv run --with argparse-manpage argparse-manpage \
  --pyfile script.py --function build_parser \
  --project-name script --version 0.1.0 --output script.1

Pass the script's own third-party deps too (--with httpx) or the import fails. Install into a directory on the MANPATH — on macOS /usr/local/share/man/man1/ is there by default:

sudo install -m 644 script.1 /usr/local/share/man/man1/script.1

Then man script works; man ./script.1 previews it without installing. A generated page is a build artifact — don't commit it.

2.9 Install & README (optional, for tools you distribute)

The README carries the environment set-up, not just the gates: a Prerequisites section with per-platform install of uv (macOS/Linux/Windows, noting uv is optional for stdlib-only scripts) and the dependency list, pointing to the PEP 723 header as the pinned source. For a distributed tool, an optional --dependencies flag that prints the pinned deps is a nice touch — skip it otherwise (the header sits right in the file).

For a script meant to be installed (not a throwaway), copy assets/install.py next to it and ship a README.md (adapt assets/README.template.md). install.py copies the script to <prefix>/bin (default ~/.local) and, on Unix, generates + installs a man page. It auto-detects Python vs Swift, so the same installer serves both skills:

uv run install.py script.py --name script            # binary + man page
uv run install.py script.py --name script --no-man    # binary only

OS limits (spelled out in the README template): macOS/Linux get the man page and a direct script command (via the uv shebang); Windows gets neither — run uv run script.py. Pass --python-dep httpx for a script whose man page needs its third-party imports.

Drift note: install.py is standalone tooling and carries its own condensed copies of read_env, should_color, and ColorFormatter (so it needs no imports from your script). The drift guard (check_skill.py) does not cross-check them against the template versions — if you change those helpers' behaviour in a template, update install.py by hand too.

3. CLI contract

Every script exposes the same baseline interface. These are not optional:

FlagBehaviorHow
-h / --helpShow usage and exitAutomatic from argparse — give every argument a help= and the parser description=__doc__
-V / --versionPrint version and exit 0action="version", version=f"%(prog)s {__version__}" — plus a help= of its own, like every other argument
-v / --verboseRaise log verbosityaction="count", default=0; -v→INFO, -vv→DEBUG, wired to logging
-q / --quietLower log verbosityaction="count", default=0; -q→ERROR only, -qq→CRITICAL (silent)
--colorControl colorized logschoices=("auto","always","never"), default auto; §2.4
--jsonMachine-readable outputaction="store_true", dest="as_json" (scripts producing structured results)
--dry-runSimulate without modifyingaction="store_true" (scripts that mutate state)
  • Every argument gets a help=, a capitalized fragment with no trailing period.
  • Case split is deliberate: lowercase -v = verbose, uppercase -V = version. They must never collide — don't reuse -v for version.
  • Verbose means logging, not scattered prints. setup_logging(args.verbose - args.quiet, color=args.color) at the top of main() maps the net count to a level and sends all logs to stderr. Business code calls log.debug/info/warning/error, never bare prints for diagnostics. -q mirrors -v, raising the threshold instead of lowering it.
  • Level mapping: max(DEBUG, min(CRITICAL, WARNING - 10 * n)) — clamped at both ends. Clamp only the top and -qqq yields level 60: total silence, where the contract says CRITICAL.
  • Configure it with an explicit StreamHandler(sys.stderr) passed to basicConfig(handlers=[...]), format "%(levelname)s: %(message)s". Not basicConfig(stream=...): that form cannot accept a formatter, which makes --color impossible to wire.
  • --dry-run short-circuits before producing output and announces the no-op at WARNING with a [dry-run] prefix, so it shows at default verbosity.
  • Stream discipline: program data (the thing a caller would pipe) goes to stdout via print; everything else — logs, progress, diagnostics, errors — goes to stderr. This keeps scripts composable in pipelines.
  • Group the --help output once flags pile up. Past ~6–8 options a flat options: list buries the signal, mixing universal boilerplate with the script's own flags. Split them with parser.add_argument_group(title): put the baseline (-h, -V, -v, -q, --color) in a general group and the script's own flags in named domain groups (selection, target, behaviour…). Groups are display-only — parsing and the Namespace are unchanged, so no test moves. Create the domain groups first and general last, so boilerplate sits at the bottom; build the parser with add_help=False and add -h/--help (action="help") into general yourself, since argparse otherwise pins -h to the default section. A small script with a handful of flags skips all this and keeps argparse's automatic -h. template_script.py shows the grouped form.
def setup_logging(verbosity: int, *, color: str = "auto") -> None: ...  # sketch — see assets/template_script.py

Exit codes

Catch typed errors at main() and convert them to a code. Keep the convention:

CodeMeaning
0Success
1Runtime error (typed exception caught in main)
2Usage error (argparse emits this automatically on bad args)
124A run() subprocess exceeded its timeout (CommandTimeout, caught before CommandError)
130Interrupted — SIGINT / Ctrl-C (KeyboardInterrupt)
141Broken pipe — downstream reader closed (128 + SIGPIPE)
143Terminated — SIGTERM / kill / container stop (128 + SIGTERM)

4. Tests with pytest

Every script ships a test file named test_<script>.py — the mirrored <script>_test.py collects zero tests, silently. A PEP 723 script isn't importable, so load it by path via importlib, bound to mod: copy that boilerplate verbatim from assets/test_template_script.py, which handles its two subtleties (sys.modules registration before exec_module, or dataclasses fail to resolve under from __future__ import annotations; and the assert on _spec/_spec.loader that keeps it past the mypy gate). Open with a one-line summary plus a working Run: line naming the file it lives in; group tests under # --- <target>: <descriptor> --- banners.

Strategy:

  • Never test subprocess or network for real — mock the boundary. That's what it's for.
  • Patch the boundary's name in the module (monkeypatch.setattr(mod, "run", ...)), never the stdlib beneath it — that's what the boundary is for, and a script with no read_env() forces every env test down to mod.os.environ. Where you must reach stdlib (mod.subprocess, mod.shutil), go through the module, so you patch what the script resolved at import. For HTTP: inject a MockTransport client for boundary/orchestration tests, monkeypatch mod.fetch_json for main() tests.
  • Never import unittest.mock — monkeypatch, local fakes, injection. Fakes ignore their signature (lambda *a, **k: …); fake subprocess results with a local class exposing returncode/stdout/stderr, not CompletedProcess, not Mock. @pytest.mark.parametrize for table-driven cases.
  • Keep tests hermetic — the templates' autouse fixtures stub shutil.which (so main()'s preflight never reads the real PATH), delenv ambient config/color vars, and booby-trap httpx.Client so a forgotten mock raises instead of dialing out.

What the structure above makes easy:

TargetHow to test itTool
Pure logic (transform)Direct call, assert the return valueno mock
Precedence (resolve_config)Overlapping layer dicts, assert who winsno mock
parse_argsAssert on the Namespaceno mock
--versionRaises SystemExit(0), version on stdoutcapsys
-v/-q → log levelsetup_logging(n), assert root level — trap 1capsys
should_colorA local _FakeStream exposing isatty(); pass env={...} — pure, no hookno mock
ColorFormatterBuild a LogRecord, assert the ANSI wrapperno mock
run boundaryPatch mod.subprocess.run, local fake resultmonkeypatch
read_config_file.toml / .json / bad suffix → ConfigErrortmp_path
read_dotenvComments, quotes, blanks, malformed, = in valuestmp_path
Orchestration (do_work)Patch mod.run by name, assert behaviormonkeypatch
Subprocess timeoutTimeoutExpired → CommandTimeout and main([...]) == 124monkeypatch
require_toolsPatch mod.shutil.which; raise lists every missing toolmonkeypatch
write_atomicContent correct, no leftover tempstmp_path
redacted, redacted_urlMasked; the real value appears nowhereno mock
SIGTERM handlersignal.getsignal(SIGTERM), call it → SystemExit(143)pytest.raises
Broken pipeBrokenPipeError in main() returns 141monkeypatch
--json outputdo_work(..., as_json=True) prints valid JSONcapsys
main() exit codesEnd-to-end: the code and the logged messagecapsys
Typed errorsAssert attributes and message substrings, not just the classpytest.raises
Stream disciplineData on .out and logs on .err — trap 2capsys
Precedence e2eVia main(): real tmp files, stubbed mod.read_env, stubbed do_work capturing the Configtmp_path

Two traps — the naive test passes and lies:

  1. setup_logging tests must save, clear and restore the root logger's handlers and level (try/finally, or a context manager). logging.basicConfig() silently no-ops once the root logger owns a handler — and pytest's logging plugin always installs one. Without the clear, the first case passes and every case after reads a stale level. test_template_script.py has this as _isolated_root_logger(). Clearing the root drops caplog's handler too, so those tests assert on capsys.
  2. Stream discipline needs both halves of capsys.readouterr(). caplog captures records through a root handler and proves nothing about which stream they landed on — a log line wrongly sent to stdout still shows up in caplog.text. Assert data on .out and absent from .err, logs on .err and absent from .out.

For HTTP: drive the boundary with httpx's native MockTransport — hermetic, no monkeypatching, no sockets — via a _client(handler) helper, defining handlers as nested def handler(request) functions, never assigned lambdas. Cover three paths: 2xx → payload, non-2xx → typed error, transport error (ConnectError) → typed error. And assert DEFAULT_TIMEOUT is actually applied to the constructed client.

Fixtures: the Tool column above says which to reach for. Only two need judgement — monkeypatch is THE one that matters (mock the boundary and the whole orchestration is testable with no process and no socket), and caplog captures records, so it can prove a message and a level but never a stream (trap 2).

Coverage priority: pure logic and orchestration first (that's where the bugs are), arg parsing next.

Quality gates

A script isn't "done" at green tests — it must also lint clean and type-check clean. uv runs each tool in an ephemeral environment, nothing to install globally:

uv run --with pytest pytest test_script.py                  # tests
uv run --with ruff ruff check --select E,F,I,E501 script.py # lint
uv run --with mypy mypy --check-untyped-defs script.py      # type check (or pyright, or ty)

Both flag sets are load-bearing — the bare commands are green-but-blind, and this skill shipped both defects. ruff check alone omits E501, so line length goes unchecked (7 real violations hid there). mypy alone skips the bodies of untyped functions — and every pytest function is untyped, so the entire test file is waved through (12 real errors hid there). A gate that cannot fail is worse than no gate: it launders the code as verified.

A fourth gate guards this skill, not the scripts it produces — run it after editing SKILL.md or any template:

uv run assets/check_skill.py SKILL.md                       # doc/template drift (exit 1 on violation)

It enforces what review cannot: every fenced python block is byte-identical to an asset or a signature-only # sketch (a marker over a real body is itself a violation — else a comment smuggles ungated code back in); read_env/should_color/ColorFormatter stay identical across the files copying them; no test file is misnamed into invisibility. Each is a defect this skill has actually shipped. Prose is unguarded, so: decisions live here, code lives in assets/.

Pass every third-party dep to the tooling too, not just the script: the runner needs them because exec_module runs the script's real imports, and the type checker fails on the import without them (uv run --with mypy --with httpx mypy script.py). Type-checking the test file also needs --with pytest. mypy/pyright resolve --with deps reliably; ty (Astral, preview) is excellent for stdlib-only scripts but its third-party resolution is still maturing — prefer mypy when deps are involved.

The type checker earns its place: heavy hints + from __future__ import annotations mean it catches mistakes tests miss — a wrong dict[str, str]/dict[str, object] variance, an unsound replace(**kwargs) (both were caught in these very templates). Treat its findings as blocking, and keep the templates passing all three gates.

Style the gates enforce: annotate every parameter and return (incl. -> None); container types from collections.abc, never typing; lazy %-style log args, never f-strings; 88 columns, trailing commas; _-prefix non-surface helpers. The gate is ruff check, not ruff format — which would add a blank line after every # --- Section --- banner, fighting the mandated layout. Expect that diff; it isn't a defect.

Summary of what to produce

Faced with a script request, produce:

  1. The script: uv header, the §2 structure, main(argv) -> int, the §3 CLI contract, the standard exit codes, and the robustness rules its job actually triggers. Those are conditional by design — shelling out earns the timeout and preflight; writing files earns atomic writes; holding config or URLs earns precedence and redaction. template_http.py rightly has no config layer, preflight or atomic writes: it shells out to nothing, writes no files, holds no config. Ship the rules the script earns, not all of them; signals, stream discipline and exit codes are universal.
  2. A test_<script>.py covering the pure logic, config precedence, orchestration, and whichever robustness helpers you shipped.
  3. In one line: how to run it (uv run script.py ...), and that the three gates — tests + ruff check + a type checker — are green.

Start from assets/template_script.py + assets/test_template_script.py, or assets/template_http.py + assets/test_template_http.py for network scripts. Copy, then delete what the script doesn't earn. (assets/check_skill.py guards this skill; it is not part of what you ship.)

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.