Complete Language-agnostic project setup first. This recipe assumes a modern src/-layout project managed with uv; merge it into an existing project rather than replacing working packaging or tool configuration blindly.
Replace every YOUR_* placeholder before running the copied configuration. The versions below are a reproducible snapshot based on pydocformatter; research and pin current compatible releases when adopting the recipe.
Write down these values before editing configuration (choose the appropriate Python version(s) to support):
Distribution name: YOUR-DISTRIBUTION-NAME
Import package: YOUR_IMPORT_PACKAGE
Minimum Python: 3.12
Supported Python versions: 3.12 through current
Supported operating systems: YOUR_SUPPORTED_SYSTEMS
CLI command, if any: YOUR-CLI
Version source: src/YOUR_IMPORT_PACKAGE/_version.py
Use the distribution name in package metadata and PyPI URLs; use the import package in Python imports. Do not advertise an interpreter or platform as supported unless the code and direct dependencies are compatible and the claim has proportionate CI or documented best-effort status.
Create the basic layout:
mkdir -p src/YOUR_IMPORT_PACKAGE tests tools
touch src/YOUR_IMPORT_PACKAGE/__init__.py
printf '3.12\n' > .python-versionPut importable runtime code under src/YOUR_IMPORT_PACKAGE/, tests under tests/, and repository-only maintenance code under tools/. Keep generated output out of all three.
Append the outputs the project uses:
.coverage
.pytest_cache/
.ruff_cache/
.cache/
.pydocfmt_cache/
.venv/
__pycache__/
*.py[cod]
build/
dist/
htmlcov/
*.egg-info/
/.generated/
/site/
/zensical.generated.tomlThe current .cache/ entry is for ty's project-local cache. Once ty provides a supported cache-directory setting, configure and git-ignore a tool-specific location instead. Remove coverage, pydocformatter, ty, or documentation entries when those tools are not enabled. Keep a deliberately committed lock file and authored documentation configuration out of the git-ignore list.
Replace the agnostic guide's illustrative .codex/config.toml with this complete Python-project version:
approval_policy = "on-request"
model_reasoning_effort = "medium"
plan_mode_reasoning_effort = "high"
sandbox_mode = "workspace-write"
web_search = "live"
[sandbox_workspace_write]
network_access = true
writable_roots = [
"~/.cache/gh",
"~/.cache/pip",
"~/.cache/uv",
"~/.local/share/uv",
]This repeats the GitHub CLI, pip, and uv paths deliberately because a repository-level writable_roots list replaces the user- or system-level list instead of extending it. Add every other external path this repository needs to the same list.
Create or merge these sections in pyproject.toml (example template):
[project]
name = "YOUR-DISTRIBUTION-NAME"
dynamic = ["version"]
authors = [{name = "YOUR NAME"}]
maintainers = [{name = "YOUR NAME"}]
description = "ONE-SENTENCE DESCRIPTION"
readme = "README.md"
license = "YOUR-SPDX-LICENSE"
license-files = ["LICENSE.md"]
requires-python = ">=3.12"
keywords = ["your", "search", "terms"]
classifiers = [
"Development Status :: 3 - Alpha",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = []
[project.urls]
homepage = "https://github.com/YOUR-ORG/YOUR-REPOSITORY"
source = "https://github.com/YOUR-ORG/YOUR-REPOSITORY"
changelog = "https://github.com/YOUR-ORG/YOUR-REPOSITORY/blob/main/CHANGELOG.md"
documentation = "https://YOUR-DOCUMENTATION-URL/"
issues = "https://github.com/YOUR-ORG/YOUR-REPOSITORY/issues"
# Delete this table when the project installs no command.
[project.scripts]
YOUR-CLI = "YOUR_IMPORT_PACKAGE.cli:main"
[dependency-groups]
docs = [
"la-dev-codex-plugins==0.5.5",
"zensical==0.0.54",
]
test = [
{include-group = "docs"},
"la-dev-codex-plugins[pytest]==0.5.5",
"pytest==9.1.1",
"pytest-cov==7.1.0",
"pytest-mock==3.15.1",
"pytest-xdist==3.8.0",
]
dev = [
{include-group = "test"},
"pre-commit==4.6.2",
"pydocformatter==1.2.1",
"ruff==0.16.3",
"twine==7.0.0",
"ty==0.0.71",
]
[build-system]
requires = ["hatchling>=1.31.0"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/YOUR_IMPORT_PACKAGE"]
[tool.hatch.build.targets.sdist]
exclude = [
".github/",
".codex/",
".agents/",
"AGENTS.md",
]
[tool.hatch.version]
path = "src/YOUR_IMPORT_PACKAGE/_version.py"Remove URLs, scripts, license fields, classifiers, and dependency groups that genuinely do not apply. The metadata specification does not require a keyword case; prefer lowercase search terms such as python, docstrings, and static-analysis, retaining canonical capitalization only when it materially identifies a proper name or acronym. Browse the complete current PyPI classifier list and copy only classifiers that apply, with their exact spelling. Add a classifier for every supported Python version, but use requires-python, not classifiers, to constrain installation compatibility. Runtime dependencies should use the lowest compatible bounds needed by downstream resolvers; direct documentation, test, and development tools should use exact pins for reproducible repository work.
Create the version source:
"""Package version."""
__version__ = "0.1.0"Export it from src/YOUR_IMPORT_PACKAGE/__init__.py if version access is part of the public API. Keep this file as the single version source and test that built metadata and CLI output agree with it.
Resolve and commit the environment:
uv lock
uv sync --locked --no-default-groups --group dev
uv lock --check
uv treeUse uv run for project commands. After every dependency edit, run uv lock to regenerate uv.lock; never edit the lock file manually. Review and commit the resulting lock-file diff, resync with uv sync --locked --no-default-groups --group dev, and run the full checks. Add a repository test such as tests/test_dependency_pins.py that parses pyproject.toml and fails when a direct docs, test, or dev string is not an exact name==version pin; allow only {include-group = "..."} table entries.
Start with the uncomplicated pytest configuration:
[tool.pytest.ini_options]
addopts = ["-n", "auto"]
filterwarnings = ["error"]
testpaths = ["tests"]Use module-level test functions, plain assert, fixtures, pytest.raises, @pytest.mark.parametrize, and pytest-mock. Use uv run pytest -n 0 PATH for focused serial debugging and uv run pytest for the configured parallel suite.
Enable shared guarded-CWD mode when tests should not depend on the repository root but could accidentally do so. It is especially useful when code discovers pyproject.toml or other configuration by walking from the current directory, uses relative input/output paths, launches subprocesses that inherit the CWD, or writes through tempfile APIs. The guard makes accidental repository reads and writes fail early while redirecting ordinary temporary files to a safe writable location.
You can skip shared guarding when the suite intentionally tests against the checkout as its working directory, a framework requires repository-root execution, every relevant test already chooses an explicit temporary CWD, or a small pure-unit suite performs no relative filesystem access or configuration discovery. Skipping it means omitting the plugin settings and root conftest.py below; ordinary pytest remains fully supported. You may also load the plugin only for selected tests and use its private fixtures without guarding every unmarked test.
To enable shared guarding, extend the pytest table:
[tool.pytest.ini_options]
addopts = ["-n", "auto"]
filterwarnings = ["error"]
testpaths = ["tests"]
la_dev_cwd_isolation_unmarked = "shared_guarded"Create root conftest.py:
"""Repository-wide pytest configuration."""
pytest_plugins = ("la_dev_codex_plugins.pytest_isolation.plugin",)The default eager cleanup is the simplest choice. Use la_dev_cwd_isolation_cleanup = "pytest_retained" only when a cooperative resource keeps interpreter-owned files open until shutdown; that mode also needs pytest temporary-path retention configuration. See Pytest working-directory isolation before enabling it.
For a test that deliberately needs a private writable CWD:
def test_generates_file(isolated_cwd):
output = isolated_cwd / "output.txt"
output.write_text("result", encoding="utf-8")Use guarded_cwd instead when a particular test must prove that it does not read from or write to its CWD.
The following is pydocformatter's complete self-configuration and is a strong, intentionally strict starting point:
[tool.pydocfmt]
line-length = 120
indent-style = "space"
indent-width = 4
line-ending = "lf"
respect-gitignore = true
force-exclude = true
extend-select = [
"no-blank-line-after-function-docstring",
"summary-trailing-period",
"non-imperative-summary",
"summary-starts-with-this",
"missing-public-module-attribute-documentation",
"class-attribute-documentation-order",
"module-attribute-documentation-order",
"parameter-type-required",
"return-type-required",
"yield-type-required",
"class-attribute-type-required",
"module-attribute-type-required",
"rule-codes-in-suppression-comments",
]
ignore = [
"public-class-attribute-docstring-must-be-attached",
"public-module-attribute-docstring-must-be-attached",
"private-class-attribute-docstring-must-be-owner",
"private-module-attribute-docstring-must-be-owner",
]
require-explicit = [
"rule-codes-in-suppression-comments",
"rule-names-in-suppression-comments",
]
[tool.pydocfmt.docstring]
convention = "google"
missing-documentation = "all-docstrings"
require-init-attribute-documentation = true
[tool.pydocfmt.comment]
detect-code = true
detect-expressions = true
join-standalone-lines = true
task-marker-mode = "hanging"
[tool.pydocfmt.per-file-ignores]
"tests/**/test_*.py" = ["PDF6"]
[tool.pydocfmt.per-file-settings]
"tests/**/test_*.py" = { docstring-missing-documentation = "has-section" }Do not add custom extensions to the baseline without a project need. If the project genuinely uses .rpy Python files and .mdx Markdown files, merge this optional example into the tables above:
[tool.pydocfmt]
extend-include = ["*.rpy", "*.mdx"]
[tool.pydocfmt.extension]
rpy = "python"
mdx = "markdown"Before adopting it:
- Set
conventionin[tool.pydocfmt.docstring]to the project's Google, NumPy, reStructuredText, or generic docstring convention. - Choose
missing-documentationin[tool.pydocfmt.docstring]. The example's"all-docstrings"value is a good fit for AI-oriented development, where consistently generating documentation for every eligible definition is inexpensive and gives later agents more local context, but it may impose too much writing and maintenance overhead on a primarily manual workflow. Separately decide whetherrequire-init-attribute-documentation = truein the same table is appropriate for the project's attributes. - Review
respect-gitignoreandforce-excludeunder[tool.pydocfmt]. Withforce-exclude = true, pydocformatter applies configured exclusions to explicitly passed paths, so do not repeat--force-excludein commands that load this configuration. - Review the type-requirement rules in
extend-selectunder[tool.pydocfmt], includingparameter-type-required,return-type-required,yield-type-required,class-attribute-type-required, andmodule-attribute-type-required. - Select one suppression-comment identity policy through the applicable rules in
extend-selectandrequire-explicitunder[tool.pydocfmt]. - Set
indent-styleandindent-widthunder[tool.pydocfmt], and keep them consistent with Ruff and the existing source. The example uses four spaces. - Decide whether
join-standalone-linesin[tool.pydocfmt.comment]should combine adjacent ordinary prose comments into paragraphs before wrapping. Keep it enabled when manually wrapped comment lines should be reflowed as prose; disable it when adjacent comment lines must remain separate semantic lines. - Sources assigned to Markdown automatically use
source-context = "fragment"anddocstring-missing-documentation = "has-section". These language-aware defaults disable rules that assume a complete importable module or require docstrings for illustrative definitions while retaining completeness checks for documentation sections an example actually contains. Use a per-file entry withsource-context = "module"and the desired missing-documentation policy only for documentation paths whose Python fences intentionally represent complete modules, and add the standalonepydocfmt-skipfence token only for intentionally malformed or deliberately nonconforming examples. - Keep the default source-language assignments unless the project genuinely uses custom filename extensions:
.py,.pyi, and.pyware Python, while.mdis Markdown. For a custom extension, add a separate[tool.pydocfmt.extension]table such asrpy = "python"ormdx = "markdown". The mapping does not change directory discovery, so add matchingextend-includepatterns when direct directory checks should find those files. Every custom extension assigned to Markdown receives the same automatic language-aware defaults without a matching per-file pattern. - Confirm that
[tool.pydocfmt.per-file-ignores]and[tool.pydocfmt.per-file-settings]fit the project's test style and paths. - Remove a rule from
ignoreunder[tool.pydocfmt]only after resolving its incompatibility with the chosen attachment policy.
Run:
uv run pydocfmt check --fix
uv run pydocfmt checkCopy and adapt this broad, opinionated Ruff starting point:
[tool.ruff]
force-exclude = true
indent-width = 4
line-length = 200
output-format = "grouped"
preview = true
respect-gitignore = true
src = ["src", "tools"]
[tool.ruff.analyze]
preview = true
[tool.ruff.format]
docstring-code-format = true
indent-style = "space"
line-ending = "lf"
preview = true
quote-style = "double"
skip-magic-trailing-comma = true
[tool.ruff.lint]
select = [
"A", "ANN", "ARG", "ASYNC",
"B",
"C4", "C90", "COM",
"DTZ",
"E", "EXE",
"F", "FA", "FLY", "FURB",
"G",
"I", "ICN", "INP", "INT", "ISC",
"LOG",
"N",
"PERF", "PGH", "PIE", "PLC", "PLE", "PLR", "PLW", "PT", "PYI",
"RET", "RSE", "RUF",
"S", "SIM", "SLOT",
"T10", "TC", "TID", "TRY",
"UP",
"W",
"YTT",
]
extend-select = [
"boolean-positional-value-in-call",
]
ignore = [
"D",
"DOC",
"any-type",
"compare-to-empty-string",
"doc-line-too-long",
"indentation-with-invalid-multiple",
"indentation-with-invalid-multiple-comment",
"invalid-module-name",
"line-too-long",
"missing-trailing-comma",
"multiple-leading-hashes-for-block-comment",
"no-space-after-block-comment",
"no-space-after-inline-comment",
"over-indented",
"prohibited-trailing-comma",
"raise-vanilla-args",
"read-whole-file",
"start-process-with-partial-path",
"suspicious-subprocess-import",
"tab-indentation",
"too-few-spaces-before-inline-comment",
"unnecessary-dunder-call",
"write-whole-file",
]
extend-unsafe-fixes = []
unfixable = []
dummy-variable-rgx = "^_$"
future-annotations = true
preview = true
task-tags = ["TODO", "FIXME", "XXX", "HACK", "BUG", "DEBUG", "NOTE", "OPTIMIZE", "REVIEW"]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = [
"assert",
]
"tests/**/test_*.py" = [
"private-member-access",
]
[tool.ruff.lint.flake8-implicit-str-concat]
allow-multiline = true
[tool.ruff.lint.flake8-quotes]
docstring-quotes = "double"
inline-quotes = "double"
multiline-quotes = "double"
[tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "parents"
[tool.ruff.lint.flake8-type-checking]
exempt-modules = ["typing", "typing_extensions", "collections.abc", "types", "pathlib"]
runtime-evaluated-decorators = ["dataclasses.dataclass"]
[tool.ruff.lint.isort]
combine-as-imports = false
force-sort-within-sections = false
known-first-party = ["YOUR_IMPORT_PACKAGE", "tests"]
length-sort = false
length-sort-straight = true
lines-after-imports = 2
no-sections = false
order-by-type = true
split-on-trailing-comma = false
[tool.ruff.lint.isort.import-heading]
future = "Future imports"
standard-library = "Standard library imports"
third-party = "Third-party imports"
first-party = "First-party imports"
local-folder = "Local imports"
[tool.ruff.lint.mccabe]
max-complexity = 50
[tool.ruff.lint.pep8-naming]
extend-ignore-names = ["[A-Z]", "[A-Z][A-Z]"]
[tool.ruff.lint.pycodestyle]
ignore-overlong-task-comments = true
[tool.ruff.lint.pylint]
allow-magic-value-types = ["str", "bytes", "int"]
max-args = 20
max-bool-expr = 20
max-branches = 30
max-locals = 50
max-nested-blocks = 15
max-positional-args = 6
max-public-methods = 80
max-returns = 30
max-statements = 300
max-statements-in-try = 25Before adopting it:
- Set
srcunder[tool.ruff]to every first-party source root, and replaceYOUR_IMPORT_PACKAGEin[tool.ruff.lint.isort].known-first-partywith the import package. Add other first-party packages only when they exist. - Choose
line-length,indent-width, and the[tool.ruff.format]values for indentation, line endings, and quotes, keeping shared settings consistent with pydocformatter. This example uses Ruff'sline-length = 200for formatting but ignoresline-too-long, so it does not enforce a universal 200-character ceiling. - Decide whether
preview = trueunder[tool.ruff],[tool.ruff.analyze],[tool.ruff.format], and[tool.ruff.lint]is acceptable. Preview rules and formatter behavior can change between pinned Ruff releases; disable preview consistently if the project wants only stable behavior. - Decide whether
boolean-positional-value-in-callinextend-selectmatches the project's API style, and whetherfuture-annotations = truematches its annotation and runtime-compatibility policy. - Confirm the paths and rule-name exceptions in
[tool.ruff.lint.per-file-ignores]. Allowingassertthroughout tests is conventional for pytest; allowingprivate-member-accessis a separate choice about white-box tests. - Decide whether
[tool.ruff.format].docstring-code-formatshould reformat Python examples in docstrings and whetherskip-magic-trailing-comma = truematches the desired call and collection layout. Review the related trailing-comma ignores and[tool.ruff.lint.isort].split-on-trailing-commatogether. - Decide whether multiline implicit string concatenation and parent-relative imports are project conventions; adjust
[tool.ruff.lint.flake8-implicit-str-concat],[tool.ruff.lint.flake8-tidy-imports]accordingly. - Review
[tool.ruff.lint.flake8-type-checking].exempt-modulesandruntime-evaluated-decorators. Add custom dataclass-like or framework decorators only when annotations beneath them are actually evaluated at runtime; remove exemptions the project does not need. - Decide whether the
[tool.ruff.lint.isort]sorting choices and[tool.ruff.lint.isort.import-heading]comments are desired. Import headings create persistent source comments and are not required for correct import grouping. - Review
[tool.ruff.lint.pep8-naming].extend-ignore-names; the example permits one- and two-letter uppercase names, which may be useful for type variables or mathematical notation but too broad for some projects. Add special naming patterns only for real conventions. - Review
[tool.ruff.lint.pylint].allow-magic-value-types; permitting all strings, bytes, and integers suppresses many magic-value findings and may be broader than the project wants. - Treat
[tool.ruff.lint.mccabe].max-complexityand the[tool.ruff.lint.pylint]limits as permissive migration values, not universal recommendations. Tighten them where they would flag genuinely hard-to-maintain code without forcing artificial decomposition.
Run:
uv run ruff check --fix
uv run ruff format
uv run ruff check
uv run ruff format --checkCopy and possibly adapt the complete configuration:
[tool.ty.src]
respect-ignore-files = trueRun:
uv run ty check --force-excludeThe project requires-python declaration supplies the baseline Python environment; verify that source roots, generated files, optional imports, and framework-specific behavior are analyzed as intended. Add configuration only for real project requirements rather than preemptive ignores.
Ty currently provides --force-exclude only on the CLI, with no equivalent TOML setting. Ordinary discovery already honors exclude and ignore files, but explicitly passed targets override them; retaining the flag makes the command safe if targets are appended later. The recommended pre-commit hook sets pass_filenames: false, so its entry does not need the flag. When ty supports the setting in TOML, configure it there and remove the redundant CLI flag.
Add these sections to the root AGENTS.md. If a same-named section already exists from the agnostic setup, merge the listed lines into it instead of repeating the heading:
## Commands
- Use uv for venv management and ALL Python execution.
- Never run uv with a custom/temporary cache dir (e.g. UV_CACHE_DIR or --cache-dir); if cache-related uv failures occur then abort and notify the user.
- The venv has no pip; use `uv pip`, `uv tree`, or similar.
- Use pytest for running tests. Pytest uses pytest-xdist multiprocessing by default; pass `-n 0` for serial debugging or focused runs where worker startup is slower.
- Use `uv run ty check` for type checking, `uv run ruff ...` for code formatting/linting, and `uv run pydocfmt check --fix` to format docstrings/comments in Python and supported fenced Python blocks in Markdown.
- Use `uv run la-dev-markdown-tables` to fix Markdown table formatting and `uv run la-dev-markdown-tables --check` to verify it.
## Code style
- Do not import functions directly into the local namespace; import the containing module and call functions through it (for example, `from X import Y` followed by `Y.func()`, or `import X.Y` followed by `X.Y.func()`, or `import X.Y as Z` followed by `Z.func()`). Classes, exceptions, types, and constants may be imported directly. Direct function imports are allowed in `__init__.py` files (or other clearly sole-purpose public API files) solely to re-export functions as part of the package's public API.
- Write concise, meaningful docstrings. Module docstrings should identify what the file/package is, not say that it "provides support" or "implements" something. Attribute documentation must explain the role, semantics, units, source, or downstream use of the attribute; never restate the identifier with filler like "The foo value" or "The FOO enum member."
## Tests
- Write tests as module-level pytest functions. Use plain `assert`, `pytest.raises`, fixtures, `@pytest.mark.parametrize`, and `pytest-mock`; do not add `unittest.TestCase` test classes.
## Workflows
- When changing function signatures or class attributes, update all affected docstrings in the same change.
## Packaging
- Keep wheel contents limited to installed runtime code, runtime data, licenses, and required metadata. Include in the source distribution the source, build metadata, license and user-documentation files, and tests and configuration needed to build, document, and validate the unpacked archive. Exclude CI and agent configuration, repository-only helpers, and local state unless a non-sensitive file is required by those checks; never package credentials or secrets.Append this local repository to the repos list created by the agnostic guide:
- repo: local
hooks:
- id: ruff-check-fix
name: ruff check (fix)
entry: uv run ruff check --fix
language: system
types_or: [python, pyi]
stages: [pre-commit]
- id: pydocfmt-fix
name: pydocfmt (fix)
entry: uv run pydocfmt check --fix
language: system
types_or: [python, pyi, markdown]
files: (?i)\.(?:py|pyi|pyw|md)$
stages: [pre-commit]
- id: ruff-format-fix
name: ruff format (fix)
entry: uv run ruff format
language: system
types_or: [python, pyi]
stages: [pre-commit]
- id: ruff-check
name: ruff check
entry: uv run ruff check
language: system
types_or: [python, pyi]
stages: [pre-push, manual]
- id: pydocfmt-check
name: pydocfmt (check)
entry: uv run pydocfmt check
language: system
types_or: [python, pyi, markdown]
files: (?i)\.(?:py|pyi|pyw|md)$
stages: [pre-push, manual]
- id: ruff-format-check
name: ruff format (check)
entry: uv run ruff format --check
language: system
types_or: [python, pyi]
stages: [pre-push, manual]
- id: ty
name: ty
entry: uv run ty check
language: system
pass_filenames: false
stages: [pre-commit, pre-push, manual]
- id: pytest
name: pytest
entry: uv run pytest
language: system
pass_filenames: false
stages: [pre-commit, pre-push, manual]The hooks are declared in nominal execution order. Commit-time hooks apply Ruff lint fixes, pydocformatter fixes, and then final Ruff formatting before running ty and pytest. Pre-push/manual runs the corresponding non-mutating checks in the same order before ty and pytest. The pydocformatter filters select the built-in .py, .pyi, .pyw, and .md filename forms case-insensitively; types_or requires pre-commit 2.9.0 or newer.
If a project configures extra pydocformatter extensions, override the hook's files regex to include them. When pre-commit's identify library already recognizes every added extension as Python or Markdown, changing files is sufficient. Otherwise, also use an appropriate broader type filter such as types_or: [file], and let files be the actual extension filter. These hook overrides control the explicit paths passed by pre-commit; configure [tool.pydocfmt.extension] separately to assign each custom suffix to python or markdown. Add extend-include only when direct directory discovery must find the same suffixes.
For example, use only the widened files line when identify recognizes both extensions; otherwise override both fields as follows:
- id: pydocfmt-check
types_or: [file]
files: (?i)\.(?:py|pyi|pyw|md|rpy|mdx)$Keep the ty hook as bare uv run ty check: pass_filenames: false prevents pre-commit from appending explicit paths, so normal ty discovery honors configured exclusions and ignore files without --force-exclude. Add the flag only if the hook later names or receives explicit targets that must still be excluded.
The pytest hook also sets pass_filenames: false because pytest should select the suite through its configuration rather than receive changed source paths. With the recommended pytest configuration, uv run pytest runs the configured pytest-xdist multiprocessing suite; use uv run pytest -n 0 for serial debugging, focused runs where worker startup dominates, or tests that are not yet safe to distribute. Omit pytest-xdist and the addopts entry when the suite is too small to benefit or relies on process-global or external resources that cannot be isolated across workers.
The agnostic setup already supplies Markdown-table fix/check hooks. Commit-time hooks may mutate files; the manual stage used by CI is non-mutating. Run the complete suite until it passes without changing files:
uv run pre-commit run --all-files
uv run pre-commit run --all-files --hook-stage manualFor any CI provider, create the locked environment and run the manual pre-commit stage:
uv sync --locked --no-default-groups --group dev
uv run --no-sync pre-commit run --all-files --hook-stage manual --show-diff-on-failureTest the minimum and newest supported Python versions. Add operating-system, architecture, alternative-interpreter, or libc jobs only when the project claims that support or has a realistic compatibility risk. Do not infer support merely because one job passes on ubuntu-latest.
Replace the generic workflow's installation and check steps with:
- name: Install uv
uses: astral-sh/setup-uv@v8.3.2
with:
enable-cache: true
- name: Sync locked development environment
run: uv sync --locked --no-default-groups --group dev
- name: Run non-mutating checks
run: uv run --no-sync pre-commit run --all-files --hook-stage manual --show-diff-on-failureFor a package published to PyPI, prefer a dedicated PYPI.md when the GitHub README contains repository-only installation paths, relative links, plugin instructions, contributor material, or other content that renders poorly on a package index. Use README.md directly when the same file is correct in both locations.
A concise PYPI.md should contain:
# Distribution name
One package-oriented description.
## Install
The exact `pip`, `uv`, or tool installation command.
## Quick start
One installed-package example.
## Documentation
Absolute links to full documentation, source, changelog, and issues.
## Compatibility
Supported Python versions and platforms.When using it, change project metadata to:
[project]
readme = "PYPI.md"Build and inspect both distributions before release:
uv build --clear
uv run twine check dist/*
uv run python -m zipfile -l dist/*.whl
uv run python -m tarfile -l dist/*.tar.gzSmoke-test the wheel and sdist independently in isolated environments. Verify package version, imports, installed data, entry points, and one representative operation; tests running from the checkout do not prove the distributions are complete.
Skip this section when the project does not publish to PyPI or does not use a GitHub remote. Otherwise configure a PyPI trusted publisher for the repository and workflow, then create a GitHub environment named pypi with a required reviewer. That reviewer is the visible human approval between a validated release and irreversible package publication.
Create .github/workflows/python-package-release.yml and replace the placeholders:
name: Python package release
on:
workflow_dispatch:
inputs:
ref:
description: Git ref to build and validate without publishing
required: true
default: main
type: string
release:
types: [published]
permissions:
contents: read
jobs:
build:
if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease
runs-on: ubuntu-latest
steps:
- name: Check out release source
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.ref }}
- name: Install uv
uses: astral-sh/setup-uv@v8.3.2
with:
enable-cache: true
- name: Sync locked development environment
run: uv sync --locked --no-default-groups --group dev
- name: Validate lock and project checks
run: |
uv lock --check
uv run --no-sync pre-commit run --all-files --hook-stage manual --show-diff-on-failure
- name: Validate release identity
if: github.event_name == 'release'
shell: bash
run: |
tag="${{ github.event.release.tag_name }}"
test "$(git cat-file -t "$tag")" = tag
test "$(git rev-list -n 1 "$tag")" = "$(git rev-parse HEAD)"
version="$(uv run --no-sync python -c 'from YOUR_IMPORT_PACKAGE import __version__; print(__version__)')"
test "v$version" = "$tag"
- name: Build and check distributions
run: |
uv build --clear
uv run --no-sync twine check dist/*
- name: Smoke-test wheel
run: uvx --isolated --from ./dist/*.whl YOUR-CLI --version
- name: Smoke-test source distribution
run: uvx --isolated --from ./dist/*.tar.gz YOUR-CLI --version
- name: Generate release checksums
run: |
mkdir -p release/packages
mv dist/*.whl dist/*.tar.gz release/packages/
uv run --no-sync la-dev-release-checksums --output release/SHA256SUMS release/packages/*.whl release/packages/*.tar.gz
- name: Upload validated artifacts
uses: actions/upload-artifact@v6
with:
name: python-package-distributions
path: release/
if-no-files-found: error
retention-days: 7
publish:
if: github.event_name == 'release' && !github.event.release.prerelease
needs: build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/YOUR-DISTRIBUTION-NAME
permissions:
id-token: write
steps:
- name: Download validated artifacts
uses: actions/download-artifact@v8
with:
name: python-package-distributions
path: release/
- name: Publish distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: release/packages/
release-assets:
if: github.event_name == 'release' && !github.event.release.prerelease
needs: publish
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download validated artifacts
uses: actions/download-artifact@v8
with:
name: python-package-distributions
path: release/
- name: Upload distributions and checksums to the GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "${{ github.event.release.tag_name }}" release/packages/*.whl release/packages/*.tar.gz release/SHA256SUMS --repo "${{ github.repository }}"Replace CLI smoke checks with installed-import checks when the package has no CLI. Preserve artifact order when generating checksums. The manual workflow_dispatch path builds and tests but cannot enter either publishing job. For stronger supply-chain control, pin third-party actions to reviewed commit SHAs and let Dependabot propose updates.
The checksum manifest is recommended for released Python artifacts. Generate it only after metadata, installation, and smoke validation, publish it beside the exact wheel and source archive, and never restore an older manifest after a failed generation. See Release checksum manifests.
In the generic runbook from the agnostic setup, make these Python steps exact:
- Name the package version file,
pyproject.toml,uv.lock,PYPI.mdorREADME.md, wheel name, source-archive name, tag form, PyPI project, and release workflow as sources of truth. - Have Codex inspect the complete diff since the previous tag and propose the next semantic version. Stop and obtain explicit user approval of that exact version before editing it.
- Update the version source, changelog comparison links/date, classifiers, Python support text, package-index description, entry-point examples, and lock file when affected.
- Run
uv lock --check, the full manual pre-commit stage, documentation checks,uv build --clear,twine check, archive-content inspection, and independent wheel/sdist smoke tests. - Commit and push the release preparation, require CI success for that commit, create an annotated tag on it, and run the workflow's nonpublishing
workflow_dispatchpreflight against the tag. - Summarize the exact version, tag, commit, artifacts, GitHub Release, and PyPI trusted-publishing pipeline. Stop for a second explicit user approval before publishing the GitHub Release.
- After the release event starts the workflow, show the exact pending
pypienvironment deployment, ask the user to approve it on GitHub, and end the turn. Do not poll while waiting for that manual action. - Resume only after new user input. Require the workflow to succeed, then verify GitHub assets,
SHA256SUMS, PyPI files/metadata/provenance, and a fresh installation of the exact published version. - Never move a pushed tag or replace a PyPI filename. Retain exact validated artifacts after partial publication; a defective published artifact requires a new version.
For a non-GitHub project or an explicitly chosen direct-release process, keep the same two user confirmations and artifact validation. Publish the exact checked files with uv publish dist/*.whl dist/*.tar.gz or twine upload, retain credentials outside shell history, upload the artifacts and generated SHA256SUMS to the release destination, and perform the same public verification.
Run the checks in increasing scope:
uv lock --check
uv run ruff check
uv run pydocfmt check
uv run ruff format --check
uv run ty check --force-exclude
uv run pytest -n 0
uv run pre-commit run --all-files --hook-stage manual
git diff --check
git status --shortFor a published package, also build, inspect, and smoke-test both distributions. Format Markdown tables with uv run la-dev-markdown-tables and verify with uv run la-dev-markdown-tables --check.
Run the Python audit; it checks both this recipe and the prerequisite agnostic recipe:
$toolkit:perform audit-project-setup[python]
Continue with AI-supported repository development.