Project Structure

A well-organized Python project separates importable source, tests, configuration, and docs so tools, collaborators, and packaging all work predictably.

Project structure is the arrangement of files and directories in a Python project: where the importable package lives, where tests live, where configuration and documentation live, and how a command-line entry point is exposed. There's no single mandatory layout, but a small set of conventions has become the de facto standard because tooling (pytest, build backends, IDEs) expects them.

What Good Structure Buys You

A predictable layout means: pip install . and python -m build find the right files automatically, pytest discovers tests without extra configuration, new contributors know where to look, and packaging metadata (see Packaging Python Projects) has an obvious source directory to point at. Structure is a tool for reducing ambiguity, not a set of rules to follow for their own sake — a one-file script doesn't need any of this.

src Layout Versus Flat Layout

Flat layout puts the importable package directly in the project root, alongside pyproject.toml. src layout nests it one level deeper, inside a src/ directory.

src layout's advantage: because src/ is not on Python's default import path, import myproj only succeeds if the package was actually installed (even in editable mode) — so tests and scripts can never accidentally pick up a stray, un-installed copy from the working directory. Flat layout is simpler for a quick script or a project that's never meant to be installed by others, but for anything published or CI-tested, src layout is the more widely recommended default.

# Flat layout
myproj/
├── pyproject.toml
├── myproj/
│   ├── __init__.py
│   └── core.py
└── tests/

# src layout
myproj/
├── pyproject.toml
├── src/
│   └── myproj/
│       ├── __init__.py
│       └── core.py
└── tests/

Package Layout

Inside the importable package itself: __init__.py marks a directory as a package and is a natural place for the package's public API (re-exporting the names users should import); subpackages are just nested directories with their own __init__.py; internal helper modules that aren't part of the public API are conventionally prefixed with an underscore.

src/myproj/
├── __init__.py        # from .core import Client, Config  (public API)
├── core.py
├── _internal.py        # implementation detail, not part of the public API
└── utils/
    ├── __init__.py
    └── formatting.py

Tests

Tests conventionally live in a top-level tests/ directory, separate from the package itself — they are not shipped as part of the installed package. Test file names typically mirror what they test (test_core.py for core.py), which lets test runners like pytest (third-party) auto-discover them by filename pattern with no configuration.

myproj/
├── src/myproj/
│   └── core.py
└── tests/
    ├── test_core.py
    └── conftest.py     # shared pytest fixtures (pytest-specific convention)

Configuration

Modern projects concentrate configuration for many tools into pyproject.toml itself, using each tool's own top-level table (e.g. [tool.pytest.ini_options], [tool.black], [tool.ruff]) rather than one scattered config file per tool. This keeps a single file as the entry point for 'how is this project built, tested, and linted' — though some tools still expect their own file by long-standing convention (e.g. .flake8, tox.ini).

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.ruff]
line-length = 100

Documentation

A README.md (or .rst) at the project root is the front door — referenced from pyproject.toml's readme field and shown on the package's PyPI page. Longer documentation commonly lives in a docs/ directory, often built by a third-party tool (Sphinx, MkDocs) into a browsable site, kept separate from both source and tests.

myproj/
├── README.md
├── CHANGELOG.md
├── LICENSE
└── docs/
    └── index.md

CLI Entry Points

A CLI entry point lets an installed package expose a runnable command, so users type myproj in their shell instead of python -m myproj or hunting for a script path. It's declared in pyproject.toml's [project.scripts] table, mapping a command name to a callable.

After installation, the build backend generates a small executable wrapper (placed in the environment's bin/Scripts directory) that imports myproj.cli and calls main() — so myproj becomes a real shell command once the package is installed.

# src/myproj/cli.py
def main() -> None:
    print("myproj running")

if __name__ == "__main__":
    main()
[project.scripts]
myproj = "myproj.cli:main"

A Complete Realistic Layout

myproj/
├── pyproject.toml
├── README.md
├── LICENSE
├── .gitignore
├── src/
│   └── myproj/
│       ├── __init__.py
│       ├── core.py
│       ├── cli.py
│       └── _internal.py
├── tests/
│   ├── conftest.py
│   ├── test_core.py
│   └── test_cli.py
└── docs/
    └── index.md

Practical Usage

Starting a new project from this pattern: create the skeleton, write pyproject.toml naming src layout implicitly (most build backends auto-detect a single package under src/), install in editable mode for development, and run tests against the installed (not the raw source) package.

python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"       # editable install + dev extras
pytest                         # discovers tests/ automatically
myproj                         # the installed CLI entry point

Edge Cases

Two packages under src/ with no pyproject.toml guidance about which is the 'main' one can confuse auto-detecting build backends — an explicit packages/include setting resolves the ambiguity. A tests/ directory that accidentally contains an __init__.py can change how some test runners import test modules; check the chosen test runner's own convention. Placing non-Python data files (templates, sample data) inside the package without configuring the backend's package-data inclusion means they silently won't ship in the built wheel.

Best Practices

Default to src layout for anything installable. Keep tests out of the shipped package. Centralize tool configuration in pyproject.toml where the tool supports it. Expose CLI behavior via [project.scripts] rather than telling users to run a bare script file. Keep README.md, LICENSE, and CHANGELOG.md at the root where packaging tools and code hosts expect them.

Quick Reference

src/<pkg>/       importable package (src layout)
tests/           test suite, not shipped with the package
docs/            longer-form documentation
pyproject.toml   build config + [tool.*] config for many tools
README.md        front door, shown on PyPI
[project.scripts] name = "pkg.module:function"   -> installed CLI command

Key Takeaways

src layout is the widely recommended default for installable projects because it prevents accidentally importing an uninstalled source tree.

Tests live outside the package, in a top-level tests/ directory, and are not part of the shipped distribution.

pyproject.toml increasingly centralizes not just build metadata but per-tool configuration via [tool.*] tables.

[project.scripts] turns a function into a real, installed shell command.

Structure exists to reduce ambiguity for tools and collaborators — scale it to the project, not the other way around.

myproj/ ├── myproj/ # flat layout, package sits next to project root │ └── core.py └── tests/ └── test_core.py # can import the raw source tree even if never installed myproj/ ├── src/ │ └── myproj/ │ └── core.py └── tests/ └── test_core.py # can only import the ACTUALLY INSTALLED package

Flat layout lets tests accidentally succeed against source that was never installed correctly, hiding real packaging bugs that src layout surfaces immediately.

src/myproj/ └── core.py tests/ └── test_core.py # kept outside the shipped package

Tests placed inside the package directory get bundled into the built distribution, bloating it and shipping test-only code to every installer.

[project.scripts] myproj = "myproj.cli:main" # users just run: myproj

A hardcoded script path is fragile and unfriendly; a declared entry point gives users a real installed command that works regardless of where the package was installed.

What problem does src layout specifically prevent that flat layout does not?

It prevents Python from accidentally importing the package straight from the working directory's source tree when it was never properly installed, since src/ isn't on the default import path.

Why do tests typically live outside the importable package directory?

So they aren't bundled into the built distribution and shipped to every installer — tests are a development-time concern, not part of the package's public API.

How does pyproject.toml support configuring third-party tools beyond just build metadata?

Many tools read their own settings from a [tool.<name>] table inside pyproject.toml (e.g. [tool.pytest.ini_options], [tool.ruff]), centralizing configuration in one file rather than scattering per-tool config files.

What does declaring a [project.scripts] entry actually produce after installation?

The build backend generates a small executable wrapper in the environment's bin/Scripts directory that imports the named module and calls the named function, so the command name becomes a real shell command.

Why might placing a template or data file inside a package directory fail to ship it in the built wheel?

Build backends only include package data files that are explicitly configured for inclusion; a file just sitting in the directory isn't automatically packaged unless the backend's package-data settings say to include it.

Is a single mandatory Python project layout enforced by the language or tooling?

No — there's no single mandatory layout; src layout and the tests/docs/pyproject.toml conventions are widely adopted best practice that tooling has come to expect, not a hard requirement.

Write a src-layout directory tree (as text) for a package named taskman with a core module, a cli module, and a tests directory with one test file.

taskman/ ├── pyproject.toml ├── src/ │ └── taskman/ │ ├── __init__.py │ ├── core.py │ └── cli.py └── tests/ └── test_core.py

The importable package sits under src/taskman/, isolated from the project root; tests sit in a separate top-level tests/ directory and are not part of the installed package.

After pip install . with this pyproject.toml entry, what command becomes available in the shell, and what does it call?

A taskman command becomes available in the environment's bin/Scripts directory; running it imports taskman.cli and calls its run() function.

Write a comment explaining why a test file that does import taskman will fail if taskman uses src layout and was never installed (not even in editable mode).

src layout deliberately keeps the package out of the default import path so only an actual install (regular or editable) makes it importable, catching packaging mistakes early.

[project.scripts]
taskman = "taskman.cli:run"