Project structure affects everything: imports, testing, packaging, collaboration. Here's how to do it right.
The Two Layouts
Flat Layout
myproject/
├── myproject/
│ ├── __init__.py
│ └── main.py
├── tests/
│ └── test_main.py
├── pyproject.toml
└── README.md
Simple and common. Works well for most projects.
Src Layout
myproject/
├── src/
│ └── myproject/
│ ├── __init__.py
│ └── main.py
├── tests/
│ └── test_main.py
├── pyproject.toml
└── README.md
Extra src/ directory. Why bother?
The src layout prevents a subtle bug: In flat layout, your local package directory can shadow the installed package. Tests might pass locally but fail after installation.
Use src layout for libraries you'll distribute. Flat layout is fine for applications.
Basic Structure
myproject/
├── src/
│ └── myproject/
│ ├── __init__.py # Package marker
│ ├── main.py # Entry point
│ ├── config.py # Configuration
│ ├── models/ # Data models
│ │ ├── __init__.py
│ │ └── user.py
│ └── utils/ # Helpers
│ ├── __init__.py
│ └── helpers.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py # pytest fixtures
│ └── test_main.py
├── docs/ # Documentation
├── scripts/ # Utility scripts
├── pyproject.toml # Project config
├── README.md
└── .gitignore
The init.py File
Marks a directory as a Python package. Can be empty or export public API:
# src/myproject/__init__.py
from myproject.main import run
from myproject.config import Config
__version__ = "0.1.0"
__all__ = ["run", "Config"]Now users can:
from myproject import run, Configpyproject.toml
The modern standard for Python project configuration:
[project]
name = "myproject"
version = "0.1.0"
description = "A useful project"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"httpx>=0.24",
"pydantic>=2.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"ruff>=0.1",
]
[project.scripts]
myproject = "myproject.main:cli"
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
line-length = 88One file for everything: dependencies, scripts, tools.
Managing Imports
Absolute Imports (Recommended)
# src/myproject/models/user.py
from myproject.config import settings
from myproject.utils.helpers import format_nameAlways works. Clear where things come from.
Relative Imports
# src/myproject/models/user.py
from ..config import settings
from ..utils.helpers import format_nameShorter but can be confusing. Use sparingly.
Entry Points
Script Entry Point
In pyproject.toml:
[project.scripts]
myproject = "myproject.main:cli"The function:
# src/myproject/main.py
def cli():
"""Entry point for CLI."""
print("Hello from myproject!")
if __name__ == "__main__":
cli()After pip install, users can run myproject directly.
Module Entry Point
# src/myproject/__main__.py
from myproject.main import cli
if __name__ == "__main__":
cli()Now python -m myproject works.
Configuration Files
Keep configuration separate:
myproject/
├── src/myproject/
├── config/
│ ├── default.toml
│ └── production.toml
├── .env.example
└── pyproject.toml
Load with environment awareness:
# src/myproject/config.py
import os
from pathlib import Path
ENV = os.getenv("ENV", "development")
CONFIG_PATH = Path(__file__).parent.parent.parent / "config" / f"{ENV}.toml"Testing Structure
tests/
├── __init__.py
├── conftest.py # Shared fixtures
├── unit/
│ ├── __init__.py
│ └── test_models.py
├── integration/
│ ├── __init__.py
│ └── test_api.py
└── fixtures/
└── sample_data.json
conftest.py for shared fixtures:
# tests/conftest.py
import pytest
@pytest.fixture
def sample_user():
return {"name": "Owen", "email": "owen@example.com"}Development Installation
Install in editable mode for development:
pip install -e ".[dev]"Changes to source are immediately available. No reinstall needed.
What Goes Where
| Content | Location |
|---|---|
| Source code | src/myproject/ |
| Tests | tests/ |
| Documentation | docs/ |
| Scripts/tools | scripts/ |
| Config files | config/ or root |
| Static assets | src/myproject/data/ |
Common Mistakes
Circular imports
# Bad - a imports b, b imports a
# models/user.py
from myproject.services import UserService
# services/user_service.py
from myproject.models import UserFix: Move shared code to a third module, or import inside functions.
Too deep nesting
myproject/core/services/internal/helpers/utils/...
Keep it flat. 2-3 levels max.
No init.py
# Fails without __init__.py
from myproject.models import UserEvery directory that's a package needs __init__.py.
My Template
myproject/
├── src/
│ └── myproject/
│ ├── __init__.py
│ ├── __main__.py
│ ├── cli.py
│ └── core.py
├── tests/
│ ├── conftest.py
│ └── test_core.py
├── pyproject.toml
├── README.md
└── .gitignore
Start simple. Add structure as needed.