A standard-library-only toolkit that gives a single-file Python script a spine: dataclass-driven config wired to both a CLI and environment variables, structured logging, and a few utilities that are tedious to rewrite for the twentieth time.
Nothing installs it. A script names a released tag in its PEP 723 header, and uv
fetches that exact tag on first run. This is the one decision the rest of the
project bends around. A script written in March keeps running against the
scriptkit it was written against, no matter what the library did in the nine
months since, and it keeps doing that without anyone maintaining it. The price is
that a published tag can never change, which is why the release process below
looks stricter than a personal library seems to warrant.
The repo is py-scriptkit and the import name is scriptkit. Current release is
v1.0.0.
| Section | What is in it |
|---|---|
| Writing a script | The pinned header, the settings dataclass, the API, the logger, and scriptkit new. This is why you are usually here. |
| Cutting a release | The eight-step checklist, and the three constraints that decide what the library is allowed to become. |
| Working on scriptkit | Dev venv setup, the lint and type and test gates, and where everything lives. |
Every command block here is Windows PowerShell, and the syntax assumes it:
statements chain with ;, not &&; paths are written .\name; and interpreters
are called as python.exe. Run them in a PowerShell terminal. VS Code's
integrated terminal is PowerShell by default on Windows, so the blocks paste in
unchanged; if its default profile has been switched to Git Bash or cmd, pick a
PowerShell profile from the terminal dropdown first, or the .\, ;, and .exe
forms will not behave the same. The uv installer is the exception: it invokes
powershell itself, so it runs from any shell.
The pin goes in the header. Everything else is an ordinary frozen dataclass:
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "scriptkit @ git+https://github.com/acalderhead/py-scriptkit.git@v1.0.0",
# ]
# ///
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal
from scriptkit import ScriptSettings, get_logger, parse_settings
logger = get_logger(__file__)
@dataclass(frozen=True)
class Settings(ScriptSettings):
name: str = "World" # -> --name / APP_NAME, default "World"
retries: int = 3 # scalars, bool, Path, Decimal, UUID, ...
mode: Literal["fast", "slow"] = "fast" # Enum / Literal -> choices
tags: list[str] = field(default_factory=list) # --tags a b c / APP_TAGS=a,b,c
since: datetime | None = None # Optional[...] + ISO date/datetimeEvery settable field turns into a --flag and also reads an APP_* environment
variable. The annotation does the work from there. Enum and Literal become
choices, so a bad value dies at the parser instead of somewhere in the middle
of the run. list[X] becomes nargs, and the env form takes the same values
comma-separated. datetime and date go through fromisoformat. Optional[...]
gets unwrapped to the type inside it before any of that happens. Precedence runs
CLI, then env, then the default in the dataclass.
That is the entire configuration story. What else a script gets:
| API | Purpose |
|---|---|
ScriptSettings |
frozen-dataclass config base; dir_output == dir_base (created on run), dir_data = dir_base/data |
parse_settings(cls) |
builds the CLI and env wiring off the dataclass; precedence is CLI > env > default |
get_logger / set_log_level |
RichLogger console output, or a stdlib fallback with the same semantic methods |
timestamp(granularity) |
compact UTC stamps, year down to second |
scriptkit.azure.get_credential |
lazy DefaultAzureCredential |
The logger takes a vocabulary aimed at what a script is actually doing rather than
at severity: stage, step, substep, config, metric, result, read,
write, metadata, alert, and check. The stdlib four are still there, so
info, warning, error, and debug work as usual.
Add the [rich] extra to the pin for decorated console output:
"scriptkit[rich] @ git+https://github.com/acalderhead/py-scriptkit.git@<tag>",
Leave the extra off and the identical calls print plain [TAG]-prefixed lines
through a shim. This matters because it means adding or dropping rich never
touches a line of script code.
scriptkit new writes a fresh copy of the bundled module template into the
current directory. A module carries no pin, since it imports scriptkit rather than
fetching it.
.\.venv313\Scripts\scriptkit.exe new my_moduleFrom git with nothing installed locally, substituting the current release from the top of this file:
uvx --from "scriptkit @ git+https://github.com/acalderhead/py-scriptkit.git@<tag>" scriptkit new my_moduleThe full form is scriptkit new NAME [--dir DIR] [--force]. The name gets
normalized into a snake_case filename, and --dir defaults to wherever you are.
Single-file scripts are not scaffolded from here. Each script repo has its own
new-script.ps1, which is what stamps the pinned PEP 723 header.
Plain semver. PATCH for fixes, MINOR for features that break nobody, MAJOR for anything that breaks a caller.
The rule that matters more than the numbering: a published tag is a promise. Scripts resolve tags at runtime, on machines you are not sitting at, so moving a tag rewrites code that nobody is looking at and nobody will suspect. Add tags. Never move one.
-
Make the change under
src/scriptkit/, with tests intests/. -
Bump
__version__insrc/scriptkit/__init__.py.Do not go looking for a version in
pyproject.toml. There isn't one. The field is declareddynamicand hatch reads__version__out of the package at build time, which is set up under[tool.hatch.version]. One number, one file, no chance of the two disagreeing. -
Leave
pyproject.tomlalone unless you added or changed a dependency or an extra, in[project.dependencies]or[project.optional-dependencies]. Nothing else in there is per-release. -
Write the
CHANGELOG.mdentry. Open a## [vX.Y.Z]heading, move the relevant lines down out of## [Unreleased], and group them underAdded,Changed, andFixed. -
Go green:
.\.venv313\Scripts\python.exe -m ruff check . .\.venv313\Scripts\python.exe -m pyright .\.venv313\Scripts\python.exe -m pytest -q
The
ruff checkandpytest (all versions)VS Code tasks do the same with fewer keystrokes. -
Commit, tag, push:
git commit -am "Release vX.Y.Z: <summary>" git tag vX.Y.Z git push origin main --tags
-
Move the defaults onto the new tag. Three places hold one:
src/scriptkit/templates/script_template.py, each script repo'snew-script.ps1and the-Tagdefault in itssetup-venvs.ps1, and the READMEs, this one included.Scripts already out there keep their old pin, which is the entire point. Bump a script's header when you want its new behavior and not before.
-
Confirm CI is green, and that one
uv runagainst a real script resolves the new tag from a cold cache. A tag that GitHub has butuvcannot resolve is a failure you want to find now.
Three constraints, and they are the reason step 1 is short.
Standard library only. Anything heavier hides behind an optional extra the way
[rich] and [azure] do, and never lands in the base install. A script that
pulls a dependency tree to print a timestamp is worse than no library.
Every public API has a test, and CI is green before a tag exists. Consumers pin tags, so a broken tag is permanent.
Anything printed stays ASCII-safe. A legacy Windows console will render the rest as garbage, and it will do it in the log file you are reading six months later to work out what went wrong.
You only need any of this to edit the library itself. Consuming scripts need
uv run and nothing else.
Install uv:
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"Then build the dev virtualenvs. There is one per supported Python minor version,
each an editable .[dev] install, so ruff, pyright, and pytest come with them:
.\setup-venvs.ps1 # builds .venv311 / .venv312 / .venv313
.\setup-venvs.ps1 -Force # delete and recreate themVS Code points at .venv313 for IntelliSense, linting, and debugging. Switch
with Python: Select Interpreter, or use the versioned debug configs. Every
command below also exists as a task under Terminal > Run Task.
Ruff is configured in pyproject.toml under [tool.ruff]:
line length, a pinned rule set, and src/scriptkit/templates/ excluded, because
the templates are not this project's code and should not be held to its lint
config. Lint results do not depend on which interpreter runs them, so .venv313
is enough.
The plain check lives in the release checklist. The two variants worth having to hand:
.\.venv313\Scripts\python.exe -m ruff check --fix . # apply the safe auto-fixes
.\.venv313\Scripts\python.exe -m ruff format . # reformat in placeTasks are ruff check and ruff format. Format-on-save is already on for Python
files, set in .vscode/settings.json, so the format
command is mostly for files you touched outside the editor.
Pyright is in the dev extra and configured under [tool.pyright]. It checks
src against the 3.11 baseline and skips the bundled templates. The package
ships a py.typed marker, which means this gate guards the types your consumers
see in their editors, not only the ones you see in yours. That makes a type
regression a visible, breaking change rather than a private annoyance.
The command is in the release checklist.
The suite is in tests/, one file per module, with testpaths set in
pyproject.toml. Single-version runs are in the release checklist. This is the
local stand-in for the CI matrix, and every public API passes on all three:
.\.venv311\Scripts\python.exe -m pytest -q; .\.venv312\Scripts\python.exe -m pytest -q; .\.venv313\Scripts\python.exe -m pytest -qCI runs {ubuntu,windows} against {3.11,3.12,3.13}, so the local matrix catches
version problems but not platform ones. Tasks: pytest (3.13) is the default test
task, with pytest (3.11/3.12) and pytest (all versions) alongside it. The VS
Code Testing beaker works too.
new-module.ps1 copies module_template.py and new-test.ps1 copies
test_template.py. Both have .bat launchers for double-clicking. These do the
same job as scriptkit new without needing the console entry point on PATH.
py-scriptkit/
├── src/scriptkit/ the package (import name = scriptkit)
│ ├── __init__.py public API + __version__ (single source of truth)
│ ├── settings.py ScriptSettings, build_parser_from_settings, parse_settings
│ ├── logging.py get_logger / set_log_level (RichLogger or stdlib shim)
│ ├── times.py timestamp()
│ ├── azure.py lazy DefaultAzureCredential helper
│ ├── cli.py `scriptkit new`, scaffolds a module (console entry point)
│ ├── py.typed PEP 561 marker, ships inline types to consumers
│ └── templates/ the source of truth all repos scaffold from
│ ├── script_template.py single-file script (PEP 723 header, pinned)
│ ├── module_template.py importable module (no pin, it imports scriptkit)
│ └── test_template.py pytest file for a module
├── tests/ pytest suite, one file per module
├── .github/workflows/ ruff + pyright + pytest on {ubuntu,windows} × {3.11,3.12,3.13}
├── .vscode/ run / debug / test config
├── new-module.ps1 / .bat local dev scaffolder, copies module_template.py
├── new-test.ps1 / .bat local dev scaffolder, copies test_template.py
├── setup-venvs.ps1 / .bat builds the per-version dev venvs
├── pyproject.toml package metadata (version is dynamic, read from __init__)
├── CHANGELOG.md per-release notes, keyed by git tag
├── ROADMAP.md phased action plan across the three repos
├── IDEAS.md post-v1.0.0 brainstorm, not committed work
└── CLAUDE.md session handoff for Claude Code
src/scriptkit/ is the ordinary src layout. The src/ directory keeps the
package off the repo root, so an import cannot accidentally resolve against the
working tree and the tests are forced to exercise the installed copy, which is
what a consumer gets. The scriptkit/ directory inside it is the package, and its
name is the import name.