Post

How I Structure Python Projects: A Practical Guide

After building 20+ open-source Python projects, here's the opinionated structure I use for every new project — from the base template to logging, CLI commands, API layers, and CI workflows.

How I Structure Python Projects: A Practical Guide

After building and maintaining 20+ Python open-source projects, I’ve converged on a consistent structure that I apply to every new project. This post walks through the full picture — from day zero setup to production-ready patterns.


The Foundation: Start From a Template

Every project I start comes from dev-template-repository — a GitHub template that wires up everything before I write a single line of business logic:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
my-project/
├── .github/
│   ├── workflows/
│   │   └── publish_to_pypi.yml     # automated PyPI publishing
│   ├── CODEOWNERS                  # default reviewers per path
│   ├── ISSUE_TEMPLATE/             # bug reports, feature requests
│   └── pull_request_template.md    # contribution checklist
├── .vscode/
│   ├── settings.json               # format on save, lint integration
│   ├── extensions.json             # recommended extensions
│   └── launch.json                 # debug configurations
├── .cursor/                        # Cursor IDE config
├── requirements.txt
├── MANIFEST.in
├── env.template                    # .env structure, committed to git
├── Taskfile.yml                    # Go-Task for common dev commands
├── .pre-commit-config.yaml         # Black, Ruff, Pylint
├── CHANGELOG.md
├── LICENSE
└── README.md

Key decisions baked in:

  • Pre-commit hooks (Black + Ruff + Pylint) run on every git commit. Code style is never a PR discussion.
  • env.template is committed. .env is gitignored. New team members know exactly what variables to fill in.
  • Go-Task standardizes task lint, task test, task build across every project.
  • CODEOWNERS means the right person gets tagged on every PR automatically.

Setup takes 5 minutes:

1
2
3
4
5
6
git clone https://github.com/aviz92/my-new-project.git
cd my-new-project
uv venv && source .venv/bin/activate
uv sync
cp env.template .env
pre-commit install

Logging: One Call, Everywhere

Every project uses custom-python-logger — configured once at the entry point, then imported wherever needed:

1
2
3
4
5
6
7
8
9
# main.py or app.py
from custom_python_logger import build_logger

logger = build_logger(
    project_name='my-project',
    log_file=True,
    utc=True,
    extra={'env': 'production'}
)
1
2
3
4
5
6
7
# anywhere else in the codebase
from custom_python_logger import get_logger

logger = get_logger('my-module')
logger.step("Starting phase 2...")   # custom level for pipeline steps
logger.info("Connected to DB")
logger.exception("Failed to parse response")

Two custom levels that I use constantly:

  • STEP — marks a named phase in a pipeline. Makes logs scannable at a glance.
  • EXCEPTION — dedicated level for caught exceptions, separate from ERROR. Easy to grep in production.

CLI Tools: Django-Style Without Django

When a project needs a CLI — scripts, automation tools, data pipelines — I use python-base-command.

The pattern is identical to Django management commands, but with zero Django dependency:

1
2
3
4
5
6
7
my-project/
├── cli.py              # 2-line entry point
└── commands/
    ├── __init__.py
    ├── sync.py
    ├── export.py
    └── validate.py
1
2
3
# cli.py
from python_base_command import Runner
Runner(commands_dir="commands").run()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# commands/sync.py
from python_base_command import BaseCommand, CommandError

class Command(BaseCommand):
    help = "Sync data from upstream source"
    version = "1.0.0"

    def add_arguments(self, parser):
        parser.add_argument("--env", choices=["staging", "prod"], required=True)
        parser.add_argument("--dry-run", action="store_true")

    def handle(self, **kwargs):
        if kwargs["dry_run"]:
            self.logger.warning("Dry run — no changes written.")
            return

        self.logger.step("Connecting to upstream...")
        # logic here
        self.logger.info(f"Sync complete for env={kwargs['env']}")
1
2
3
python3 cli.py --help
python3 cli.py sync --env staging --dry-run
python3 cli.py export --format json

Every command gets --version, --verbosity, --traceback for free. self.logger is wired up automatically. Testing uses call_command() — identical to Django.


REST API: Two Templates for Two Use Cases

Django REST Framework — Data-Heavy APIs

For APIs that need versioned data, complex filtering, or admin interfaces: django-basic-app.

Out of the box it includes:

  • CRUDUtils — one static class handles all five CRUD operations with wildcard filtering, pagination, and FK lookups
  • VersionedModel — every model participates in release management automatically
  • Data status workflowDRAFT → FUTURE → APPROVED, where CI always queries approved data
1
2
3
4
# One mixin, full versioning
class MyModel(VersionedModel):
    name = models.CharField(max_length=255)
    # That's it. Versioned, locked, auto-discovered.

The CI flow:

1
create_release → architects edit data → approve_release → run tests → lock_release

FastAPI — Lightweight Services

For simpler services, microservices, or prototypes: fast-api-template.

Pre-configured with JWT authentication, SQLite (swappable), and example routes ready to extend.

1
2
3
4
5
git clone https://github.com/aviz92/fast-api-template.git
cd fast-api-template
uv sync
uv run uvicorn main:app --reload
# → http://localhost:8000/docs

MCP Services: AI Tool Integration

For projects that expose tools to LLMs (Claude Desktop, etc.): docker-mcp-service-template.

The pattern: a FastMCP server + service layer + Docker:

1
2
3
4
5
6
7
mcp-service/
├── server.py           # MCP tool definitions
├── mcp_services/
│   ├── weather.py      # business logic, separate from MCP layer
│   └── ping.py
├── Dockerfile
└── .env
1
2
3
4
5
# server.py
@mcp.tool()
def get_weather(city: str) -> dict:
    """Fetch current weather for a given city."""
    return weather_service.fetch_weather(city)
1
2
3
4
5
6
7
8
9
// Claude Desktop config
{
  "mcpServers": {
    "my-service": {
      "command": "docker",
      "args": ["run", "--rm", "-i", "my-mcp-service"]
    }
  }
}

Business logic stays in service classes. MCP tool definitions stay thin. Adding a new tool means adding one service file and registering one decorator.


The Full Picture

Here’s how the layers stack across a typical project:

1
2
3
4
5
6
7
8
9
10
11
┌─────────────────────────────────────────┐
│         dev-template-repository          │  base structure, CI, pre-commit
├─────────────────────────────────────────┤
│         custom-python-logger             │  logging everywhere
├─────────────────────────────────────────┤
│         python-base-command              │  CLI tools and scripts
├─────────────────────────────────────────┤
│    django-basic-app / fast-api-template  │  API layer
├─────────────────────────────────────────┤
│         docker-mcp-service-template      │  LLM tool exposure (if needed)
└─────────────────────────────────────────┘

Every layer is optional — a simple automation script might only use the base template and the logger. A full data platform uses all of them.


What This Gives You

  • Zero setup time — clone the template and you have CI, linting, and pre-commit from minute one
  • Consistent structure — any project looks familiar to anyone on the team
  • Opinionated defaults — you don’t decide formatting style on every PR
  • Testable by designcall_command() for CLI, standard pytest for APIs
  • Production logging from day one — no print() debugging that leaks into production

The templates are all open source and available on GitHub. Clone any of them, use the template button, or just steal the patterns that work for you.


This post is licensed under CC BY 4.0 by the author.