Python project initialization (generic package)
Apply this skill end-to-end whenever bootstrapping a new Python package — a CLI, an analysis tool, an internal package, anything that ships as an importable package but isn't aimed at PyPI as a public library.
For public libraries (those meant to be published on PyPI and consumed by other projects), use python-lib-init instead — it ships a multi-Python CI matrix, build/publish flow, classifiers, LICENSE, CHANGELOG.md, and the rest of the distribution scaffolding that this skill deliberately skips.
For FastAPI services, use python-fastapi-init instead; the deltas there are non-trivial enough to keep them separate.
The configs below are designed to work together. Deviations need to be justified in the PR that introduces them.
Before you start
Verify with Bash that the user has the right tooling. Always check current versions first — the pinned numbers below were correct when this skill was written and drift over time. If uv or Python have moved on, use the newer versions; do not downgrade to match this file.
# uv — bumps almost monthly; >= 0.5 has [dependency-groups] + uv python install
uv --version
# git and (recommended) gh
git --version && gh --version
If uv is missing:
# macOS / Linux installer (official, single static binary)
curl -LsSf https://astral.sh/uv/install.sh | sh
Step 1 — Ask the user
Ask, one short prompt with both questions at once:
- Project / package name (e.g.
myapp— will be importable asimport myapp). Must be a valid Python identifier (snake_case, no hyphens). - Python version. Quick guide as of writing:
| Version | Status | Pick when… |
|---|---|---|
| 3.14 | Latest stable (Oct 2025) | Default. Faster start-up, optional free-threading, better error messages. |
| 3.13 | Mature stable (Oct 2024) | A critical dependency hasn't published 3.14 wheels yet. |
| 3.12 | Maintenance | You're integrating with an existing project still on 3.12. |
| ≤ 3.11 | EOL or near-EOL | Avoid unless required by an external constraint. |
Default to 3.14 unless the user says otherwise.
Make sure uv knows about the chosen version:
uv python install <version> # no-op if already installed
uv python list # confirm the version shows up
Step 2 — Scaffold with uv init --package
This creates a packaged project with src-layout (src/<name>/__init__.py), a build system (hatchling), .python-version, and a starter pyproject.toml. The --package flag is what distinguishes "real package" from "just some scripts".
uv init --package --python <version> <package-name>
cd <package-name>
Expected layout right after init:
<package-name>/
├── .python-version
├── pyproject.toml
├── README.md
└── src/
└── <package_name>/
└── __init__.py
The next step replaces pyproject.toml wholesale — what uv init generated is a starting point, but the strict configs need to land deterministically.
Step 3 — Replace pyproject.toml
Write exactly this, substituting <package_name>, <python-version> (e.g. 3.14), and <py-tag> (e.g. py314):
[project]
name = "<package_name>"
version = "0.1.0"
description = ""
readme = "README.md"
requires-python = ">=<python-version>"
dependencies = []
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[dependency-groups]
dev = [
"ruff>=0.7",
"mypy>=1.13",
"pytest>=8",
"pytest-cov>=5",
"pytest-asyncio>=0.24",
"pytest-mock>=3.14",
"pytest-randomly>=3.15",
"time-machine>=2.15",
]
[tool.ruff]
target-version = "<py-tag>"
line-length = 88
[tool.ruff.lint]
extend-select = [
"A", # flake8-builtins
"ARG", # flake8-unused-arguments
"ASYNC", # flake8-async
"B", # flake8-bugbear
"BLE", # flake8-blind-except
"C4", # flake8-comprehensions
"C90", # mccabe
"DTZ", # flake8-datetimez
"E", # pycodestyle errors
"EM", # flake8-errmsg
"FBT", # flake8-boolean-trap
"FLY", # flynt
"FURB", # refurb
"G", # flake8-logging-format
"I", # isort
"ICN", # flake8-import-conventions
"ISC", # flake8-implicit-str-concat
"INT", # flake8-gettext
"LOG", # flake8-logging
"N", # pep8-naming
"PERF", # perflint
"PGH", # pygrep-hooks
"PIE", # flake8-pie
"PL", # pylint
"PTH", # flake8-use-pathlib
"Q", # flake8-quotes
"RET", # flake8-return
"RSE", # flake8-raise
"RUF", # ruff-specific rules
"S", # flake8-bandit
"SIM", # flake8-simplify
"SLF", # flake8-self
"SLOT", # flake8-slots
"T10", # flake8-debugger
"T20", # flake8-print
"TC", # flake8-type-checking
"TID", # flake8-tidy-imports
"TRY", # tryceratops
"UP", # pyupgrade
"W", # pycodestyle warnings
"YTT", # flake8-2020
]
ignore = [
"PLR0913", # too many arguments — sometimes unavoidable
"PLR2004", # magic value comparison — too many false positives
"TRY003", # long exception messages — sometimes clearer inline
]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = [
"S101", # `assert` is how pytest works
"SLF001", # tests sometimes peek at private state
]
[tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "all"
[tool.ruff.lint.isort]
force-single-line = true
lines-between-types = 1
lines-after-imports = 2
known-first-party = ["<package_name>"]
[tool.mypy]
strict = true
python_version = "<python-version>"
namespace_packages = true
show_error_codes = true
enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"]
[tool.pytest.ini_options]
minversion = "8.0"
addopts = ["-ra", "--strict-markers", "--strict-config"]
testpaths = ["tests"]
asyncio_mode = "auto"
markers = [
"integration: tests that hit real external services (DB, Redis, HTTP)",
"slow: tests > 1s",
]
[tool.coverage.run]
source = ["src"]
branch = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
"@overload",
]
show_missing = true
skip_covered = true
Places that must agree on the Python version — change all of them when bumping the version later, or any one drifts:
.python-version[project]→requires-python[tool.ruff]→target-version(uses thepy<NN>tag, e.g.py314)[tool.mypy]→python_version(uses the dotted form, e.g.3.14)
Non-negotiables baked in: strict = true on mypy (with the three extra error codes that catch real bugs), the curated extend-select instead of select = ["ALL"] (which changes behaviour on every Ruff release), and a short ignore list whose every entry has a one-line justification.
Step 4 — Install dependencies
uv sync
This reads pyproject.toml, resolves the dependency graph, creates .venv/, writes uv.lock, and installs both the runtime deps (none yet) and the default dev group. Commit uv.lock — it pins the entire transitive graph and is what makes installs reproducible across machines.
Step 5 — py.typed marker
PEP 561: ship an empty py.typed next to __init__.py so that if any other project ever imports this package, their mypy/pyright picks up the inline type hints rather than seeing Any. The file is free (an empty marker) and removes a class of "I added types but downstream still sees Any" surprises that nobody remembers to fix later.
touch src/<package_name>/py.typed
Hatchling ships package data by default, so the marker will land in the wheel if/when the project is ever distributed.
Step 6 — Scaffold tests/
uv init --package does not create tests/. Do it now so the first pytest run finds something:
mkdir tests
touch tests/__init__.py
Write tests/test_smoke.py:
"""Sanity tests — keep them passing through any refactor.
If these break, something foundational moved (entry point, package name,
import path). Fix the cause, do not delete the test.
"""
from __future__ import annotations
def test_package_imports() -> None:
# Replace <package_name> with the actual import path.
import <package_name> # noqa: F401
Substitute <package_name> with the real one.
Step 7 — VS Code settings
Write .vscode/settings.json (commit it):
{
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.ruff": "explicit",
"source.organizeImports.ruff": "explicit"
}
},
"python.formatting.provider": "none",
"ruff.importStrategy": "fromEnvironment",
"mypy-type-checker.importStrategy": "fromEnvironment",
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python"
}
importStrategy: "fromEnvironment" makes the extensions use the ruff and mypy installed in the project's .venv/ rather than VS Code's bundled versions — keeps editor errors in lockstep with what CI will report.
Recommended .vscode/extensions.json:
{
"recommendations": [
"charliermarsh.ruff",
"ms-python.mypy-type-checker",
"ms-python.python"
]
}
Step 8 — Cheat sheet
Useful commands the user will run constantly. Mention them at the end of setup:
# add a runtime dep
uv add <pkg>
# add a dev dep (lands in [dependency-groups].dev)
uv add --dev <pkg>
# run something in the project's venv without activating it
uv run python -m <module>
uv run pytest
uv run mypy src
uv run ruff check .
uv run ruff format .
# sync the venv after pulling new commits
uv sync
# bump a single dep
uv lock --upgrade-package <pkg>
Step 9 — Verify
Run, in this order:
uv run ruff check .
uv run ruff format --check .
uv run mypy src
uv run pytest
Each must succeed. The smoke test from Step 5 keeps pytest from exiting with "no tests collected" (which --strict-config would otherwise turn into a failure if you ever add --strict flags later).
If mypy warns "no module named <package_name>", the .venv/ was not synced with the new package layout — re-run uv sync.
Step 10 — First commit
# Pull the canonical Python.gitignore (overwrites the one uv init
# scaffolded). github/gitignore is the upstream source of truth and
# tracks new tools/caches as they appear.
curl -sLo .gitignore https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore
git init
git add -A
git commit -m "chore: initial package scaffold"
git init (no -b flag) honours init.defaultBranch from the user's git config — works for main, master, or anything else without forcing a rename.
If a gh remote is desired, ask the user before creating it.
Anti-patterns this skill is built to prevent
select = ["ALL"]in Ruff. Behaviour changes silently on every Ruff release, and you ship a wall of false positives. Use the explicitextend-selectlist.- Adding rules to
ignorewithout a one-line justification of why. An undocumented ignore is debt. # type: ignorewithout a code (# type: ignore[<code>]).ignore-without-codealready errors; do not weaken it.# type: ignoreinside the package for things you control. Fix the type instead; only use ignores at the boundary with un-typed third-party code, and even then prefer adding a[[tool.mypy.overrides]] module = "x.*"block.- Naive
datetime.DTZalready errors; usedatetime.now(UTC), neverdatetime.utcnow()(deprecated in 3.12). - Relative imports.
flake8-tidy-importsis configured withban-relative-imports = "all"— use absolute imports always. - Globally relaxing mypy strictness to silence one file. Use
[[tool.mypy.overrides]] module = "<that-module>"anddisallow_untyped_defs = false, with a comment. - Committing without re-running
uv syncafter editingpyproject.toml.uv.lockmust move in the same commit as the spec it locks. - Skipping the smoke test. Looks pointless until the day you rename the package and discover at deploy time that nothing imports anymore.
Things this skill does NOT cover
- CI. Adding a GitHub Actions workflow is per-project. The pieces it needs are already in the scripts above (
uv sync --frozen,uv run ruff check .,uv run mypy src,uv run pytest). - Pre-commit hooks. Use the editor's format-on-save and run
uv run <…>before pushing. Add Lefthook or pre-commit per project if you want a belt over the suspenders. - Publishing to PyPI.
uv build+uv publishworks; out of scope for setup. - Optional HTTP / async test deps (
respxfor httpx,responsesfor requests,aioresponsesfor aiohttp,testcontainersfor real Postgres/Redis). Add them per project once the relevant runtime dep lands. - Pydantic settings, SQLAlchemy, httpx, etc. — runtime dependencies, picked per project. The
python-fastapi-initskill wires those for HTTP services specifically.
When this skill goes stale
- Check Python's release cycle (https://devguide.python.org/versions/) — the "Pick when…" table needs to move when 3.15 lands stable (Oct 2026 expected).
uvships fast;[dependency-groups](PEP 735) might be replaced by another standard or change semantics. Verify withuv helpif the install command behaves unexpectedly.- Ruff adds rule categories every few releases. The
extend-selectlist deliberately lists letters, not "ALL", so additions are opt-in — but if a new category likeDOCmatters to you, add it explicitly here. - If mypy's
strict = truegains a new sub-flag that's worth enabling explicitly (or that becomes the default), audit and update.
Source: M4RC0Sx/claude-skills — distributed by TomeVault.