Development workflow toolkit for Python projects
Every Python project accumulates the same development workflow boilerplate: formatter settings in pyproject.toml, supported Python versions repeated across classifiers, requires-python, black's target-version, and the testing matrix, plus a dodo.py and noxfile.py that look nearly identical from project to project. Keeping all of it consistent — and keeping it up to date as tools and Python versions evolve — is tedious and error-prone.
PyProjKit centralizes all of this. Each project declares its configuration exactly once in a pyprojconf.py at the project root; PyProjKit then:
- Syncs
pyproject.toml: writes the managed parts (version classifiers,requires-python, individual[tool.*]fields marked with# pyprojkit-managedcomments) while preserving everything else, with a--checkmode for CI. - Provides
doittask factories: format, test, badges, publish, and more, pre-wired to sensible conventions. - Provides
noxsession factories: a test session across all supported Python versions, pinned to exact patch releases.
Supported Python versions are declared once and drive everything: classifiers, requires-python, black's target-version, mypy's python_version, and the nox interpreter matrix.
Install from PyPI:
pip install pyprojkitOr add it to your project's dev dependencies (formatter, test, and analysis tools are available as extras):
[dependency-groups]
dev = [
"pyprojkit[all]"
]Then drop three small files into your project root.
pyprojconf.py:
from pyprojkit import ProjectConfig, PythonVersions, RequiresPython
config = ProjectConfig(
packages="my_package",
requires_python=RequiresPython((3, 12)),
pinned_python=PythonVersions(3, (12, 14)),
)dodo.py:
from pyprojkit import TaskFactory
globals().update(TaskFactory().create_all_tasks())noxfile.py:
from pyprojkit import NoxFactory
test = NoxFactory().create_test_session()Then sync your pyproject.toml and run your workflow:
doit sync # or: pyprojkit sync
doit format
doit test
doit badges
noxor run everything at once with doit preflight.
pyprojconf.py must define a module-level config: ProjectConfig. TaskFactory, NoxFactory, and the pyprojkit CLI load it automatically from the current directory.
Two separate concepts are configured:
requires_python(required) is the minimum and optional exclusive maximum of interpreters the package can be installed on. It's independent of what you test: if the maximum is omitted it's derived from the minimum as the next major version, so downstream dependency resolution stays future-proof.pinned_python(optional) is the specific versions you test and target.PythonVersionstakes the major version and an inclusive range of minor versions; pass a sequence for discontiguous ranges, e.g.[PythonVersions(3, (8, 9)), PythonVersions(3, (11, 12))]. Every pinned version must satisfyrequires_python.
To exclude known-broken minor versions, use RequiresPython((3, 12), exclude=[(3, 13)]), which adds !=3.13.*.
requires_python = RequiresPython((3, 12)) # requires-python = ">=3.12,<4.0"
pinned_python = PythonVersions(3, (12, 14))requires_python drives requires-python. pinned_python drives:
- Trove classifiers (
Programming Language :: Python :: 3.12, ...) - black's
target-version(["py312", "py313", "py314"]) - mypy's
python_version(3.12, the lowest pinned version) - nox interpreter pins (
3.12.13,3.13.13,3.14.6) - with
python_version_file=True, a.python-versionfile at the project root pinning the newest pinned version's exact patch release (3.14.6, the same as its nox pin), picked up by uv and pyenv
If pinned_python is omitted, classifiers, black's target-version and mypy's python_version are left unmanaged, and creating the nox test session (or setting python_version_file=True) raises a ConfigError.
The exact patch releases used for nox come from a mapping bundled with PyProjKit, updated with each release — so updating PyProjKit refreshes the pins across all your projects. Individual projects can override:
pinned_python = PythonVersions(3, (12, 14), patch_overrides={(3, 14): "3.14.7"})Tool configurations are grouped by category under ToolsConfig. Every category defaults to None, so the default configuration is minimal: no tools are managed and only the sync task exists. Set a category to enable it:
workflow_tool:"uv"or"poetry", the tool managing the project environment; commands in the agent skill are run through it, e.g.uv run doit testformatting: ordered chain of formatters, e.g. autoflake → isort → docfmt → black → toml-sorttest: pytest + coverage settingsdoit,nox: cache locations and backendsdoc: documentation tools — mkinit and sphinx (each opt-in within)analysis: mypy + pyrightpublish: build/publish output directoryskill: apyprojkit-pythonagent skill generated from the configuration
create_recommended_tools() returns a ToolsConfig with everything enabled (except sphinx, which needs project-specific settings). Customize it with dataclasses.replace:
from dataclasses import replace
from pyprojkit import (
AnalysisConfig,
DocConfig,
ProjectConfig,
PythonVersions,
RequiresPython,
SphinxConfig,
create_recommended_tools,
)
config = ProjectConfig(
packages="my_package",
requires_python=RequiresPython((3, 12)),
pinned_python=PythonVersions(3, (12, 14)),
tools=replace(
create_recommended_tools(),
doc=DocConfig(sphinx=SphinxConfig(copy_env_var="MY_PACKAGE_DOCS_DIR")),
analysis=None,
),
)To tweak a managed value, replace it in the tool's config:
tools=replace(
create_recommended_tools(),
test=TestConfig(pytest=PytestConfig(addopts="-x")),
)Anything PyProjKit doesn't manage — extra keys in managed tables, whole unmanaged tables — is simply edited directly in pyproject.toml and left untouched by the sync.
Formatters and other tools read their settings from pyproject.toml, so PyProjKit writes them there — pyproject.toml remains the single source of truth for the tools themselves, while pyprojconf.py is the single source of truth for you.
The sync engine owns:
- A header comment at the top of
pyproject.tomlnoting the file is managed (in part) by PyProjKit project.requires-python- Python version classifiers (other classifiers are untouched)
- Individual key/value pairs within tool tables, each marked with an inline
# pyprojkit-managedcomment - With
uv_build_backend=True,build-system.build-backendandbuild-system.requires, each marked with an inline# pyprojkit-trackedcomment: when the installed uv is newer than the required range (or the project isn't onuv_buildyet), they're updated tobuild-backend = "uv_build"anduv_build>=X.Y.Z,<X.Y+1.0for the installed version, the same formuv initgenerates. A requirement whose range already covers the installed uv is left alone, and an older uv never downgrades it. Unlike managed fields, tracked fields can be edited by hand, and disabling the option strips the marker but keeps the fields
The marker comment doubles as bookkeeping: a marked field dropped from your configuration is deleted on the next sync, and a table emptied that way is pruned. (All # pyprojkit-* comments are reserved — don't put them on your own fields.)
Everything else — dependencies, [tool.uv.sources], build system (unless uv_build_backend is enabled), unmanaged fields inside managed tables, whole unmanaged tables, comments — is preserved. Output is normalized with toml-sort using the same settings as the managed [tool.tomlsort] table, so syncing and formatting never fight.
Files synced by PyProjKit < 0.4 (whole-table ownership with a [tool.pyprojkit] bookkeeping table) are migrated automatically in a single sync.
PyProjKit generates a pyprojkit-python skill in the Agent Skills format, usable by any agent which supports it (Claude Code, Codex, Cursor, etc). Its content is derived from your configuration: the Python versions to target, conventions such as placing internal (_-prefixed) symbols after public ones of the same kind, and, when doit is enabled, a checklist of the workflow tasks you've enabled to run before finishing a task — doit init with mkinit, doit format with formatting, doit analysis with analysis, doit test with testing Commands are prefixed with <workflow_tool> run when a workflow tool is configured.
tools=replace(ToolsConfig(), skill=SkillConfig())The skill is written to .claude/skills/pyprojkit-python/SKILL.md by default; set SkillConfig.skills_dir to write it elsewhere, e.g. skills_dir=".agents/skills" for agents reading the cross-agent location.
The generated skill file is overwritten on every sync (so it tracks both your configuration and PyProjKit updates) and removed when dropped from the configuration; disable entirely with skill=None. The pyprojkit- prefix namespaces the skill, so it never collides with your own skills.
Verify a project is in sync without writing (e.g. in CI):
pyprojkit sync --checkPrints a diff and exits nonzero if pyproject.toml or the generated skill is out of date.
TaskFactory creates doit tasks pre-wired to the configuration. Create them selectively:
from pyprojkit import TaskFactory
factory = TaskFactory()
task_sync = factory.create_sync_task()
task_format = factory.create_format_task()
task_test = factory.create_test_task()
task_badges = factory.create_badges_task()
task_publish = factory.create_publish_task()or all at once: globals().update(factory.create_all_tasks()), which creates every task enabled by the configuration.
| Task | Description |
|---|---|
sync |
Update pyproject.toml from pyprojconf.py |
format |
Run the configured formatter chain over the project |
test |
Run pytest with coverage (HTML + XML) and JUnit output |
badges |
Generate test/coverage badges via genbadge |
publish |
Build and publish via uv (output dir is cleaned first, so stale artifacts are never published) |
init |
Generate __init__.py files via mkinit |
doc |
Build documentation via sphinx; --copy deploys to a directory given by an env var (opt-in) |
analysis |
Run mypy (with HTML/cobertura reports) and pyright (opt-in) on the project's code paths |
preflight |
Run every enabled step (see below) |
The preflight task runs every enabled step in order — sync, init, format, analysis, test, badges, nox — continuing past failures, then fails once listing the failed steps. It's meant to be used by developers and CI alike:
doit preflight # edit files in place (sync, init, format, badges)
doit preflight --no-test # skip test, badges and nox
doit preflight --check # fail if any file would be edited, printing diffsIn check mode, the editing steps run on a temporary copy of the project, which is then compared with the original, so the check is exactly "would an edit run change anything" — including interactions between tools, like formatters rewriting files generated by mkinit. Badges are generated from the test results and compared too, so committed badges must be up to date. Check mode is the default when the CI environment variable is set, as it is by most CI providers.
The format task passes an explicit list of paths to each formatter — the existing package directories plus the paths in ProjectConfig.test_paths and extra_code_paths (src/<package>, test, doc, examples, root *.py and *.toml files by default), the latter two accepting glob patterns — so formatters never wander into .venv or other unrelated trees. ProjectConfig.get_code_paths() resolves the same list for other uses.
NoxFactory.create_test_session() registers a test session which, for each pinned interpreter, syncs the project's dev dependency group via uv into the session environment and runs pytest:
$ nox -l
* test-3.12.13
* test-3.13.13
* test-3.14.6Extra arguments pass through to pytest: nox -s test-3.12.13 -- -k my_test.
All tasks share the same layout:
__cache__/: caches (doit db, pytest cache, coverage data, nox envs)__out__/: generated artifacts (test/coverage + JUnit results,doc/html,analysis/,uv/build artifacts)badges/: generated badge SVGssrc/<package>: package sources consumed by theinittask; the containing directory is configurable viaProjectConfig.source_path(default"src","."for a flat layout), and per package viaPackageSpec, e.g.packages=("my_package", PackageSpec("my_plugin", source_path="plugins")); tests live inProjectConfig.test_paths(default("test",)), which is also synced to pytest'stestpaths
There is no profile registry: a shared configuration is just a function or global in a module you control, which each project imports.
# in your shared module, e.g. mycompany_config.py
from dataclasses import replace
from pyprojkit import AnalysisConfig, ToolsConfig, create_recommended_tools
def create_mycompany_tools() -> ToolsConfig:
return replace(create_recommended_tools(), analysis=AnalysisConfig())# in each project's pyprojconf.py
from mycompany_config import create_mycompany_tools
from pyprojkit import ProjectConfig, PythonVersions, RequiresPython
config = ProjectConfig(
packages="my_package",
requires_python=RequiresPython((3, 12)),
pinned_python=PythonVersions(3, (12, 14)),
tools=create_mycompany_tools(),
)New formatters are equally pluggable: subclass BaseFormatterConfig (declaring the tool's [tool.X] table and command line) and include it in a FormattingConfig — the sync engine and format task pick it up automatically.