A modern, opinionated Python project skeleton. Clone it, rename mypackage,
and start building — the toolchain, structure, and conventions are already in
place.
Stack at a glance: uv · hatchling · ruff · mypy · pydantic-settings · loguru · typer · fastapi · pytest · Docker (multi-stage)
- Quick start
- Directory walkthrough
- File reference
- Tool documentation
- Python workflow model
- Development workflow
- Docker usage
- Renaming the package
- CI / GitHub Actions
- uv installed
globally (
curl -LsSf https://astral.sh/uv/install.sh | sh) - Docker + Docker Compose (optional, for containerised runs)
# 1. Clone (or use GitHub's "Use this template" button)
git clone https://github.com/you/python-base-template.git my-project
cd my-project
# 2. Create your .env from the example
cp .env.example .env
# Edit .env and fill in any API keys or settings your project needs
# 3. Install all dependencies (runtime + dev) into an isolated .venv
make dev # equivalent to: uv sync
# 4. Confirm the toolchain works
make check # lint → format-check → typecheck → testpython-base-template/
│
├── .github/
│ └── workflows/
│ └── ci.yml # GitHub Actions — runs on every push + PR
│
├── src/
│ └── mypackage/ # Rename this to your actual package name
│ ├── __init__.py # Package marker; exposes __version__
│ ├── config.py # Pydantic-settings: .env → typed Settings object
│ ├── logger.py # Loguru: the single place logging is configured
│ ├── main.py # Typer CLI entry point
│ ├── api.py # FastAPI application (ASGI)
│ └── core/
│ └── __init__.py # Business logic sub-package (pure functions)
│
├── tests/
│ ├── test_config.py # Unit tests for Settings
│ ├── test_api.py # FastAPI tests via TestClient (no server needed)
│ └── e2e/
│ └── test_pipeline.py # End-to-end tests (marked @pytest.mark.e2e)
│
├── docs/ # Human-readable documentation (Markdown, ADRs, etc.)
│ └── .gitkeep # Keeps the empty dir tracked in git
│
├── output/ # Default runtime output directory
│ └── .gitkeep # Contents are gitignored; directory is tracked
│
├── .env.example # Template for .env — tracked in git, no secrets
├── .gitignore
├── .python-version # Pins Python version for uv / pyenv / mise
├── AGENTS.md # Generic AI coding-agent entrypoint
├── CLAUDE.md # AI assistant context (Claude Code reads this)
├── docker-compose.yml # Two services: app (CLI) and api (FastAPI)
├── Dockerfile # Multi-stage: builder (uv) → runtime (slim)
├── Makefile # Developer convenience commands
├── pyproject.toml # Single source of truth: metadata + all tool config
├── README.md # This file
├── tools_choice_evaluation.md # Rationale for included/default tools
└── uv.lock # Frozen dependency graph — commit this
Each file below is explained by its purpose, what you should change, and what you should leave alone.
The single source of truth for the project. Contains:
| Section | Purpose |
|---|---|
[project] |
Package name, version, Python floor, runtime deps |
[dependency-groups] |
Dev-only deps (pytest, ruff, mypy) |
[project.scripts] |
(commented out) Exposes a CLI command after pip/uv install |
[build-system] |
Tells build tools to use hatchling |
[tool.hatch.build.targets.wheel] |
Points hatchling at the src/ layout |
[tool.pytest.ini_options] |
Adds src to pythonpath; registers markers |
[tool.ruff] / [tool.ruff.lint] |
Linting rules and formatter settings |
[tool.mypy] |
Strict type checking; one place to add per-module overrides |
[tool.coverage] |
Coverage source and reporting thresholds |
Change: package name, version, description, dependencies,
requires-python, and [tool.hatch.build.targets.wheel] packages.
Leave alone: the [build-system] block and tool configs unless you have a
specific reason to deviate.
A single line containing 3.11. Read by uv, pyenv, mise, and asdf to select
the correct Python interpreter automatically. Change the version number here
when you upgrade Python; do not change it anywhere else.
The public contract for environment variables. Every variable your application
reads from the environment must appear here with a placeholder or default.
The real .env file is gitignored. New developers copy this file to .env
and fill in secrets.
Rule: if you add a field to config.py, add the corresponding variable
here.
Standard Python gitignore augmented with:
.env(secrets — never commit)output/directory contents (runtime artifacts)- All caches (
.mypy_cache/,.ruff_cache/,.pytest_cache/) .venv/(reconstructed fromuv.lock)
Note: .vscode/ is not ignored — sharing editor settings is intentional.
This template includes two files for AI coding assistants:
| File | Audience | Purpose |
|---|---|---|
AGENTS.md |
Any coding agent that follows the common agent-instructions convention | Tells the agent where to find repository-specific instructions |
CLAUDE.md |
Claude Code | Holds the actual repository operating brief for the agent |
Keep human onboarding instructions in this README. Keep CLAUDE.md short and
agent-oriented: project facts, commands to run, conventions to preserve, and
things an agent should avoid while editing.
Explains why each default tool is included, how necessary it is for a local-first API template, and which alternatives are reasonable. Use it when deciding whether to keep, remove, or swap template defaults such as Loguru, Typer, Docker, or Compose.
Convenience wrapper around uv run commands. Run make help to see all
targets.
| Target | Command | Purpose |
|---|---|---|
make dev |
uv sync |
Install all deps incl. dev extras |
make install |
uv sync --no-dev |
Runtime deps only (mirrors Docker) |
make lint |
ruff check src tests |
Find lint errors |
make format |
ruff format src tests |
Fix formatting in place |
make format-check |
ruff format --check |
Verify formatting (CI mode) |
make typecheck |
mypy src |
Static type analysis |
make test |
pytest -m "not integration and not e2e" |
Fast unit tests |
make test-all |
pytest |
All tests including slow ones |
make test-cov |
pytest --cov |
Unit tests + coverage report |
make check |
all of the above | Full local CI gate |
make docker-build |
docker build |
Build the image |
make docker-up |
docker compose up api |
Start the API container |
make clean |
find . -type d ... |
Remove all caches |
Multi-stage build with two stages:
Stage 1 — builder
- Copies the
uvbinary fromghcr.io/astral-sh/uv:latest(no pip install). - Copies
pyproject.toml,uv.lock, andREADME.mdfirst (cache-friendly). - Runs
uv sync --frozen --no-devto populate.venv.
Stage 2 — runtime
- Starts from a fresh
python:3.11-slim. - Copies only the
.venvfrom the builder andsrc/from the repo. - Sets
PYTHONPATHandPATHso the venv is active without activation.
This pattern produces a lean image (no build tools in the final layer) and maximises Docker layer caching when only source files change.
Change: the ENTRYPOINT module path and any system packages in the RUN apt-get block.
Defines two services:
| Service | Mode | Port |
|---|---|---|
app |
CLI — docker compose run --rm app <args> |
none |
api |
FastAPI — docker compose up api |
8000 |
Both services mount ./output so files written by the container appear on
the host. The api service also mounts ./src for live-reload in
development.
Default runtime output directory (configurable via OUTPUT_DIR in .env).
The directory itself is tracked via .gitkeep, but its contents are gitignored.
Use output/ for runtime artifacts that are not API responses and should not be
committed: generated reports, exports, processed files, downloaded assets, model
outputs, batch job results, or other files the app creates while running.
This matters more in Docker. A container has its own filesystem. If your code
writes a file only inside the container, that file can disappear when the
container is removed. The compose file mounts the host directory ./output to
/app/output inside the container:
volumes:
- ./output:/app/outputThat means code running in the container can write to settings.output_dir
(/app/output in the image), and the file will appear in the repo's local
output/ directory on the host. Use this pattern for any file output that needs
to survive the container or be picked up by a person, test, or downstream tool.
For API responses, return the data directly from the route. For durable files,
write them under output/ and return a path, identifier, or download endpoint
from the API as appropriate for your application.
Minimal. Exposes __version__ only. Do not add imports here — this file is
executed on every import mypackage and heavy imports here slow down the CLI
startup time.
Defines a Settings class that inherits from pydantic_settings.BaseSettings.
Fields map 1:1 to environment variables (case-insensitive).
# Example: add a required API key
class Settings(BaseSettings):
openai_api_key: str # required — startup fails if missing
log_level: str = "INFO" # optional — has a defaultA module-level settings = Settings() singleton is instantiated at import
time. Import settings from this module everywhere; do not call Settings()
again in other modules.
Single configuration point for loguru. Removes the default handler and adds
one configured handler that reads log_level from settings.
Import pattern everywhere else in the codebase:
from mypackage.logger import logger
logger.info("message")
logger.success("done")
logger.warning("watch out")Never from loguru import logger in any other file.
Typer app. Each @app.command() decorated function becomes a CLI subcommand.
Rich's Console is used for formatted terminal output (progress bars, panels,
tables).
Entrypoint is declared in pyproject.toml under [project.scripts]
(currently commented out). Uncomment and adjust the path once you have a real
command.
FastAPI application. Route functions should be thin: validate input, call
into core/, return output. Keep business logic in core/.
The CORS middleware allows all origins by default — acceptable for local dev
tools and Docker-internal services. Restrict allow_origins before deploying
publicly.
If you want CORS to be environment-driven, add a setting and read it from the middleware:
# config.py
class Settings(BaseSettings):
cors_allow_origins: list[str] = ["http://localhost:3000"]# api.py
from mypackage.config import settings
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_allow_origins,
allow_methods=["*"],
allow_headers=["*"],
)Then add the corresponding variable to .env.example:
CORS_ALLOW_ORIGINS=["http://localhost:3000","http://localhost:5173"]Sub-package for pure business logic. Add modules here (e.g. core/processor.py,
core/parser.py). Core modules should:
- Accept and return plain Python types or Pydantic models.
- Have no knowledge of HTTP, CLI args, or the filesystem layout.
- Be easy to unit-test in isolation.
Unit tests for Settings. Uses pytest.MonkeyPatch to inject environment
variables without touching the filesystem — no .env file required.
Unit tests for the FastAPI application. Uses fastapi.testclient.TestClient
(backed by httpx) — no server process is started.
End-to-end tests marked with @pytest.mark.e2e. These are excluded from the
default make test run and only execute via make test-all. Use this layer
for tests that:
- Require real network calls or credentials.
- Write to or read from the filesystem.
- Spin up the Docker container or a real database.
Human-readable documentation that does not belong in source comments. Good candidates:
- Architecture Decision Records (ADRs)
- Deployment runbooks
- Data model diagrams
- API versioning notes
The .gitkeep file is a zero-byte sentinel that keeps the empty directory
tracked in git. Delete it once you add a real document.
Auto-generated by uv sync. Commit this file. It is the frozen
dependency graph that guarantees every developer, CI runner, and Docker build
uses identical package versions.
Never edit it by hand. Update it with:
uv add <package> # add a new dep and re-lock
uv remove <package> # remove a dep and re-lock
uv sync # re-sync after a manual pyproject.toml edit
uv lock --upgrade # upgrade all deps to latest allowed versionsOfficial documentation for every tool in the stack:
| Tool | Role | Docs |
|---|---|---|
| uv | Package manager, virtual env, Python version management | https://docs.astral.sh/uv/ |
| hatchling | PEP 517 build backend | https://hatch.pypa.io/latest/config/build/ |
| ruff | Linter + formatter (replaces black, isort, flake8) | https://docs.astral.sh/ruff/ |
| mypy | Static type checker | https://mypy.readthedocs.io/en/stable/ |
| pydantic v2 | Data validation and modelling | https://docs.pydantic.dev/latest/ |
| pydantic-settings | Settings management via .env + env vars | https://docs.pydantic.dev/latest/concepts/pydantic_settings/ |
| loguru | Structured logging | https://loguru.readthedocs.io/en/stable/ |
| typer | CLI framework built on Click | https://typer.tiangolo.com/ |
| rich | Terminal formatting (panels, tables, progress) | https://rich.readthedocs.io/en/stable/ |
| FastAPI | ASGI web framework | https://fastapi.tiangolo.com/ |
| uvicorn | ASGI server | https://www.uvicorn.org/ |
| pytest | Test framework | https://docs.pytest.org/en/stable/ |
| pytest-cov | Coverage plugin for pytest | https://pytest-cov.readthedocs.io/en/latest/ |
| httpx | HTTP client; used by FastAPI TestClient | https://www.python-httpx.org/ |
| Docker | Container image builds and runtime isolation | https://docs.docker.com/ |
| Docker Compose | Local multi-service orchestration | https://docs.docker.com/compose/ |
| GitHub Actions | CI workflow runner | https://docs.github.com/actions |
PyPI pages (for version pinning reference):
| Package | PyPI |
|---|---|
| uv | https://pypi.org/project/uv/ |
| hatchling | https://pypi.org/project/hatchling/ |
| ruff | https://pypi.org/project/ruff/ |
| mypy | https://pypi.org/project/mypy/ |
| pydantic | https://pypi.org/project/pydantic/ |
| pydantic-settings | https://pypi.org/project/pydantic-settings/ |
| loguru | https://pypi.org/project/loguru/ |
| typer | https://pypi.org/project/typer/ |
| rich | https://pypi.org/project/rich/ |
| fastapi | https://pypi.org/project/fastapi/ |
| uvicorn | https://pypi.org/project/uvicorn/ |
| pytest | https://pypi.org/project/pytest/ |
| pytest-cov | https://pypi.org/project/pytest-cov/ |
| httpx | https://pypi.org/project/httpx/ |
This template uses a project-local environment, but you usually do not manage it by hand.
Traditional Python workflows often start with:
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pytestThat works, but it leaves several responsibilities on each developer: creating the environment, activating it in every shell, keeping runtime and dev dependencies in sync, and making sure CI, Docker, and local machines all install the same package versions.
The uv workflow keeps the same underlying idea, an isolated .venv, but moves
the routine environment management into project commands:
uv sync # create/update .venv from pyproject.toml + uv.lock
uv run pytest # run pytest inside that .venv
uv run ruff check src # run ruff inside that .venvYou can think of this like the common JavaScript project loop:
npm install # populate node_modules from package metadata + lockfile
npm test # run the project test command with local dependencies
npm run lint # run the local linter versionIn this repository, uv sync is the Python equivalent of installing the
project's declared dependency graph. uv.lock is the equivalent of a package
lockfile: it records the exact resolved versions so local development, Docker,
and CI agree. uv run ... is the equivalent of running a command through the
project environment instead of relying on whatever happens to be installed
globally.
hatchling plays a different role. It is not the virtual environment manager.
It is the build backend named in pyproject.toml, which tells Python packaging
tools how this src/ layout becomes an installable package. When uv installs
the project into .venv, hatchling is the backend that knows how to build the
package metadata and wheel.
The practical rule is:
- Use
uv syncwhen dependency metadata or the lockfile changes. - Use
uv run <command>for tools and app commands. - Do not commit
.venv; it is rebuilt fromuv.lock. - Do commit
uv.lock; it is the reproducibility contract.
The Makefile wraps this workflow so day-to-day commands stay short:
make dev # uv sync
make check # lint, format-check, typecheck, test- Keep importable application code under
src/mypackage/until you rename the template package. Do not add runnable scripts at the repo root. - Load configuration through
pydantic-settings. If code needs a new environment variable, add a field tosrc/mypackage/config.pyand document it in.env.example. - Import
loggerfrommypackage.loggerinstead of importinglogurudirectly in application modules. - Leave unit tests unmarked. Mark slower integration tests with
@pytest.mark.integrationand full-stack tests with@pytest.mark.e2e. - Do not add
__init__.pytotests/; pytest does not need it, and it can interfere with thesrc/layout. - Avoid
# type: ignoreunless the line includes a short reason. - Do not commit
.envor.venv. Commit.env.exampleanduv.lock. - Use
uv add <package>oruv add --dev <package>instead ofpip installso dependency metadata anduv.lockstay in sync.
# Add a runtime dependency
uv add httpx
# Add a dev-only dependency
uv add --dev pytest-asyncio
# Run a one-off script without activating the venv
uv run python scripts/seed_db.py
# Run a specific test file
uv run pytest tests/test_config.py -v
# Run all checks before pushing
make check# Fast unit tests only (default CI gate)
uv run pytest
# Include integration tests
uv run pytest -m "integration"
# Include e2e tests
uv run pytest -m "e2e"
# Everything
uv run pytest --no-header -rN# Upgrade a specific package
uv lock --upgrade-package httpx
# Upgrade everything to latest allowed by pyproject.toml constraints
uv lock --upgrade
# Apply the new lock
uv sync# Build the image
make docker-build
# Run the CLI (replace "hello" with your actual args)
docker compose run --rm app hello --verbose
# Start the API server (http://localhost:8000/docs)
make docker-up
# Or with make, passing args
make docker-run URL="https://example.com"The api service mounts ./src into the container so uvicorn --reload picks
up source changes without rebuilding the image. Remove that volume mount in a
production compose file.
When you use this template for a real project:
-
Rename the source directory
mv src/mypackage src/your_package_name
-
Update
pyproject.toml[project] name = "your-package-name" # distribution name (hyphens ok) [tool.hatch.build.targets.wheel] packages = ["src/your_package_name"] [tool.ruff.lint.isort] known-first-party = ["your_package_name"]
-
Update all import paths
# macOS / BSD sed: grep -rl "mypackage" src tests README.md CLAUDE.md Dockerfile docker-compose.yml \ | xargs sed -i '' 's/mypackage/your_package_name/g'
-
Update
DockerfileENTRYPOINTENTRYPOINT ["python", "-m", "your_package_name.main"]
-
Update agent guidance in
CLAUDE.mdif the real project has new commands, conventions, or safety constraints.AGENTS.mdcan remain a simple pointer unless your tooling expects different content. -
Re-lock
uv sync
.github/workflows/ci.yml runs on every push and pull request to main.
Matrix: Python 3.11 and 3.12.
Steps:
- Checkout
- Install uv via
astral-sh/setup-uv(with caching) - Install the matrix Python version
uv sync --frozen— install all deps from lockfileruff check— lintruff format --check— formatting gatemypy src— type checkpytest -m "not integration and not e2e"— fast unit tests only
Integration and e2e tests are excluded from CI by default. Add a separate job or workflow when those tests are stable and have the required credentials available as GitHub Secrets.