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
- vaayne/agent-kittheir own version, first seen Oct 16, 2026
- haztecaso/skillstheir own version, first seen Oct 15, 2026
Other versions2 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 2
- vaayne/agent-kit★ 54same name
- 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.
- go-scriptSkill
- kotlin-scriptSkill
- rust-scriptSkill
- scripteratopsSkill
- shell-scriptSkill
- swift-scriptSkill
- typescript-scriptSkill
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
- Very specific: line 19
Every script starts with this block.
- Very specific: line 30
Keep dependencies empty by default.
- Very specific: line 31
Add a dependency only for a real gain: httpx (serious HTTP — the default network library, §2.3), rich (tables, progress bars, syntax …
- Very specific: line 71
Never
shell=Truewith interpolated input: Always a list of args (["git", "-C", str(path), "status"]). - Distinctive: line 75
If a script draws settings from more than one place, resolve them with an explicit, documented precedence.
- Distinctive: line 107
Follow the cross-tool convention so scripts behave predictably in pipes and CI:
- Distinctive: line 185
Every script exposes the same baseline interface.
- Very specific: line 305
Start from assets/template_script.py + assets/test_template_script.py, or assets/template_http.py + assets/test_template_http.py for …
≈ “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.
Files
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:
- Distribution → uv / PEP 723 header.
- Interface →
argparse(stdlib, zero dependencies). - 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
dependenciesempty by default.argparse,pathlib,subprocess,json,os,sys,shutil,tempfile,tomllibcover 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"], nothttpxorhttpx>=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 viauv 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()takesargvand returns anint, somain(["--dry-run", "/tmp"])is directly callable in tests without interceptingSystemExit.sys.exit()appears in exactly two places: the finalif __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 raisingSystemExitis the only way to unwindfinally/withblocks on the way out.parse_args(argv)separate frommain(), so parsing tests in isolation.main()opens withparse_args→setup_logging→install_signal_handlers, all outside thetry; 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 anintcount. - Typed errors (one class per failure category, subclassing
RuntimeError), never catch-allexcept Exception. Catch atmain(), loglog.error("%s", exc), convert to an exit code. Chain conversions withraise ... from exc. - Fail fast, clear message on
stderr, non-zero exit code. The Python equivalent ofset -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.py2.1 Subprocess boundary
def run(cmd: list[str], *, cwd: Path | None = None, timeout: float | None = DEFAULT_TIMEOUT) -> str: ... # sketch — see assets/template_script.pyRoute 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'sTimeout.DEFAULT_TIMEOUT = 30.0(seconds, a float); the httpx template reuses the name for anhttpx.Timeout— same name, different type. TimeoutExpired→CommandTimeout, a subclass ofCommandError, whichmain()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 catchCommandTimeoutbeforeCommandError:exceptmatches in source order, so the parent would otherwise shadow the subclass and swallow 124 into a generic 1.- Never
shell=Truewith 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 defaultsThis 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:
- Each source is read by one boundary function —
read_config_file(path)(dispatches.toml→tomllib,.json→json, both stdlib; other suffixes raiseConfigError),read_dotenv(path),read_env(). These are the mock hooks. Env earns a real function, not an inlinedict(os.environ): an expression has no name to patch, so tests must reach past it toos.environ— the stdlib beneath the boundary, which §4 forbids.should_colorreads env through that sameread_env(), so colour and precedence can never disagree about the environment. - Merging is a single pure function —
resolve_config(*, cli, env, dotenv, file_cfg) -> Configtakes 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:
- 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. - Wrap HTTP in ONE boundary function returning plain parsed JSON (typed
-> object, never leakingResponseobjects) and raising a typedHttpError(wrap thehttpx.HTTPErrorbase class,from exc). Callresp.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 optionalclientso tests can inject anhttpx.MockTransportand run with zero network; forward it through orchestration (do_work(url, *, client=None, ...)), and close it infinallyonly 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.pyThe 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 breakgrep). Logs go to stderr, so the decision keys onsys.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 counterpartFORCE_COLORforces it on (for CI, where there's no TTY). TreatTERM=dumbas no-color too. - A
--color {auto,always,never}flag (defaultauto). - 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.pysetup_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 | headcloses the pipe early; unhandled, Python raisesBrokenPipeErrorand prints an uglyException ignored in:traceback at shutdown. Catch it inmain(), point stdout atos.devnull, return 141 — wrapping theos.dup2(..., sys.stdout.fileno())call intry/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, andtimeout(1)send SIGTERM, not SIGINT. Install a handler raisingSystemExit(143)so the process unwinds cleanly (context managers /finallyrun). Leave SIGINT on Python's default — it already raisesKeyboardInterrupt, whichmain()maps to 130. - Cross-platform: this handling is portable.
SIGTERMregisters on both Unix and Windows;BrokenPipeErroris a plainOSErroron both;os.devnullresolves to/dev/nullornulautomatically. 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.whichup front — the first statement inmain()'s try — raisingConfigErrorlisting every missing tool. A clear "tool not found" beats a crypticFileNotFoundErrormid-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; onelog.info("config: %s", config)at-vleaks it to stderr and CI logs. Redact before logging: route config through aredacted()helper masking a declared_SECRET_FIELDSset. 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 —
--jsonmakesdo_workemit one JSON object ({"count": n, "items": [...]}) on stdout instead of plain lines. Give it an explicitdest="as_json"and threadas_jsoninto 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 taketype=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.1Pass 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.1Then 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 onlyOS 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.pyis standalone tooling and carries its own condensed copies ofread_env,should_color, andColorFormatter(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, updateinstall.pyby hand too.
3. CLI contract
Every script exposes the same baseline interface. These are not optional:
| Flag | Behavior | How |
-h / --help | Show usage and exit | Automatic from argparse — give every argument a help= and the parser description=__doc__ |
-V / --version | Print version and exit 0 | action="version", version=f"%(prog)s {__version__}" — plus a help= of its own, like every other argument |
-v / --verbose | Raise log verbosity | action="count", default=0; -v→INFO, -vv→DEBUG, wired to logging |
-q / --quiet | Lower log verbosity | action="count", default=0; -q→ERROR only, -qq→CRITICAL (silent) |
--color | Control colorized logs | choices=("auto","always","never"), default auto; §2.4 |
--json | Machine-readable output | action="store_true", dest="as_json" (scripts producing structured results) |
--dry-run | Simulate without modifying | action="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-vfor version. - Verbose means
logging, not scatteredprints.setup_logging(args.verbose - args.quiet, color=args.color)at the top ofmain()maps the net count to a level and sends all logs to stderr. Business code callslog.debug/info/warning/error, never bare prints for diagnostics.-qmirrors-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-qqqyields level 60: total silence, where the contract says CRITICAL. - Configure it with an explicit
StreamHandler(sys.stderr)passed tobasicConfig(handlers=[...]), format"%(levelname)s: %(message)s". NotbasicConfig(stream=...): that form cannot accept a formatter, which makes--colorimpossible to wire. --dry-runshort-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
--helpoutput once flags pile up. Past ~6–8 options a flatoptions:list buries the signal, mixing universal boilerplate with the script's own flags. Split them withparser.add_argument_group(title): put the baseline (-h,-V,-v,-q,--color) in ageneralgroup and the script's own flags in named domain groups (selection,target,behaviour…). Groups are display-only — parsing and theNamespaceare unchanged, so no test moves. Create the domain groups first andgenerallast, so boilerplate sits at the bottom; build the parser withadd_help=Falseand add-h/--help(action="help") intogeneralyourself, since argparse otherwise pins-hto the default section. A small script with a handful of flags skips all this and keeps argparse's automatic-h.template_script.pyshows the grouped form.
def setup_logging(verbosity: int, *, color: str = "auto") -> None: ... # sketch — see assets/template_script.pyExit codes
Catch typed errors at main() and convert them to a code. Keep the convention:
| Code | Meaning |
0 | Success |
1 | Runtime error (typed exception caught in main) |
2 | Usage error (argparse emits this automatically on bad args) |
124 | A run() subprocess exceeded its timeout (CommandTimeout, caught before CommandError) |
130 | Interrupted — SIGINT / Ctrl-C (KeyboardInterrupt) |
141 | Broken pipe — downstream reader closed (128 + SIGPIPE) |
143 | Terminated — 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 noread_env()forces every env test down tomod.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, monkeypatchmod.fetch_jsonformain()tests. - Never import
unittest.mock— monkeypatch, local fakes, injection. Fakes ignore their signature (lambda *a, **k: …); fake subprocess results with a local class exposingreturncode/stdout/stderr, notCompletedProcess, notMock.@pytest.mark.parametrizefor table-driven cases. - Keep tests hermetic — the templates' autouse fixtures stub
shutil.which(somain()'s preflight never reads the real PATH),delenvambient config/color vars, and booby-traphttpx.Clientso a forgotten mock raises instead of dialing out.
What the structure above makes easy:
| Target | How to test it | Tool |
Pure logic (transform) | Direct call, assert the return value | no mock |
Precedence (resolve_config) | Overlapping layer dicts, assert who wins | no mock |
parse_args | Assert on the Namespace | no mock |
--version | Raises SystemExit(0), version on stdout | capsys |
-v/-q → log level | setup_logging(n), assert root level — trap 1 | capsys |
should_color | A local _FakeStream exposing isatty(); pass env={...} — pure, no hook | no mock |
ColorFormatter | Build a LogRecord, assert the ANSI wrapper | no mock |
run boundary | Patch mod.subprocess.run, local fake result | monkeypatch |
read_config_file | .toml / .json / bad suffix → ConfigError | tmp_path |
read_dotenv | Comments, quotes, blanks, malformed, = in values | tmp_path |
Orchestration (do_work) | Patch mod.run by name, assert behavior | monkeypatch |
| Subprocess timeout | TimeoutExpired → CommandTimeout and main([...]) == 124 | monkeypatch |
require_tools | Patch mod.shutil.which; raise lists every missing tool | monkeypatch |
write_atomic | Content correct, no leftover temps | tmp_path |
redacted, redacted_url | Masked; the real value appears nowhere | no mock |
| SIGTERM handler | signal.getsignal(SIGTERM), call it → SystemExit(143) | pytest.raises |
| Broken pipe | BrokenPipeError in main() returns 141 | monkeypatch |
--json output | do_work(..., as_json=True) prints valid JSON | capsys |
main() exit codes | End-to-end: the code and the logged message | capsys |
| Typed errors | Assert attributes and message substrings, not just the class | pytest.raises |
| Stream discipline | Data on .out and logs on .err — trap 2 | capsys |
| Precedence e2e | Via main(): real tmp files, stubbed mod.read_env, stubbed do_work capturing the Config | tmp_path |
Two traps — the naive test passes and lies:
setup_loggingtests 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.pyhas this as_isolated_root_logger(). Clearing the root drops caplog's handler too, so those tests assert oncapsys.- Stream discipline needs both halves of
capsys.readouterr().caplogcaptures records through a root handler and proves nothing about which stream they landed on — a log line wrongly sent to stdout still shows up incaplog.text. Assert data on.outand absent from.err, logs on.errand 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:
- 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.pyrightly 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. - A
test_<script>.pycovering the pure logic, config precedence, orchestration, and whichever robustness helpers you shipped. - 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.)
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
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.

