Skip to content

Repository files navigation

python-base-template

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)


Table of contents

  1. Quick start
  2. Directory walkthrough
  3. File reference
  4. Tool documentation
  5. Python workflow model
  6. Development workflow
  7. Docker usage
  8. Renaming the package
  9. CI / GitHub Actions

1. Quick start

Prerequisites

  • 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 → test

2. Directory walkthrough

python-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

3. File reference

Each file below is explained by its purpose, what you should change, and what you should leave alone.


pyproject.toml

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.


.python-version

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.


.env.example

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.


.gitignore

Standard Python gitignore augmented with:

  • .env (secrets — never commit)
  • output/ directory contents (runtime artifacts)
  • All caches (.mypy_cache/, .ruff_cache/, .pytest_cache/)
  • .venv/ (reconstructed from uv.lock)

Note: .vscode/ is not ignored — sharing editor settings is intentional.


Agent guidance files

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.


tools_choice_evaluation.md

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.


Makefile

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

Dockerfile

Multi-stage build with two stages:

Stage 1 — builder

  • Copies the uv binary from ghcr.io/astral-sh/uv:latest (no pip install).
  • Copies pyproject.toml, uv.lock, and README.md first (cache-friendly).
  • Runs uv sync --frozen --no-dev to populate .venv.

Stage 2 — runtime

  • Starts from a fresh python:3.11-slim.
  • Copies only the .venv from the builder and src/ from the repo.
  • Sets PYTHONPATH and PATH so 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.


docker-compose.yml

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.


output/

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/output

That 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.


src/mypackage/__init__.py

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.


src/mypackage/config.py

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 default

A module-level settings = Settings() singleton is instantiated at import time. Import settings from this module everywhere; do not call Settings() again in other modules.


src/mypackage/logger.py

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.


src/mypackage/main.py

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.


src/mypackage/api.py

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"]

src/mypackage/core/__init__.py

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.

tests/test_config.py

Unit tests for Settings. Uses pytest.MonkeyPatch to inject environment variables without touching the filesystem — no .env file required.


tests/test_api.py

Unit tests for the FastAPI application. Uses fastapi.testclient.TestClient (backed by httpx) — no server process is started.


tests/e2e/test_pipeline.py

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.

docs/

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.


uv.lock

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 versions

4. Tool documentation

Official 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/

5. Python workflow model

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
pytest

That 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 .venv

You 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 version

In 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 sync when dependency metadata or the lockfile changes.
  • Use uv run <command> for tools and app commands.
  • Do not commit .venv; it is rebuilt from uv.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

6. Development workflow

Project conventions

  • 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 to src/mypackage/config.py and document it in .env.example.
  • Import logger from mypackage.logger instead of importing loguru directly in application modules.
  • Leave unit tests unmarked. Mark slower integration tests with @pytest.mark.integration and full-stack tests with @pytest.mark.e2e.
  • Do not add __init__.py to tests/; pytest does not need it, and it can interfere with the src/ layout.
  • Avoid # type: ignore unless the line includes a short reason.
  • Do not commit .env or .venv. Commit .env.example and uv.lock.
  • Use uv add <package> or uv add --dev <package> instead of pip install so dependency metadata and uv.lock stay in sync.

Day-to-day

# 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

Test markers

# 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

Upgrading dependencies

# 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

7. Docker usage

# 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.


8. Renaming the package

When you use this template for a real project:

  1. Rename the source directory

    mv src/mypackage src/your_package_name
  2. 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"]
  3. 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'
  4. Update Dockerfile ENTRYPOINT

    ENTRYPOINT ["python", "-m", "your_package_name.main"]
  5. Update agent guidance in CLAUDE.md if the real project has new commands, conventions, or safety constraints. AGENTS.md can remain a simple pointer unless your tooling expects different content.

  6. Re-lock

    uv sync

9. CI / GitHub Actions

.github/workflows/ci.yml runs on every push and pull request to main.

Matrix: Python 3.11 and 3.12.

Steps:

  1. Checkout
  2. Install uv via astral-sh/setup-uv (with caching)
  3. Install the matrix Python version
  4. uv sync --frozen — install all deps from lockfile
  5. ruff check — lint
  6. ruff format --check — formatting gate
  7. mypy src — type check
  8. pytest -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.

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages