Skip to content
Back to skills

Project Structure

ASecurity

This skill should be used when the user is setting up a Python project structure, choosing between src/ and flat layout, organizing __init__.py files, creating a new package directory, or structuring a monorepo. Covers src/ layout, flat layout, __init__.py design, __all__ exports, _internal/ convention, py.typed marker, naming conventions, test placement, root-level files, and monorepo vs single-package patterns.

  • 34 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 26, 2026
developmentpythonrustgofastapiflaskgitapidatabasebackendsecurity

Works with

  • cli
  • api

Security analysis

A92/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned October 26, 2026

npx -y skills add oborchers/fractional-cto --skill project-structure --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Project Structure?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Project Structure
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/oborchers-project-structure/badge)](https://www.skillsdirectory.com/skills/oborchers-project-structure)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: project-structure
description: "This skill should be used when the user is setting up a Python project structure, choosing between src/ and flat layout, organizing __init__.py files, creating a new package directory, or structuring a monorepo. Covers src/ layout, flat layout, __init__.py design, __all__ exports, _internal/ convention, py.typed marker, naming conventions, test placement, root-level files, and monorepo vs single-package patterns."
version: 1.0.0
---

# Use the src/ Layout and a Canonical Directory Structure

A broken project structure causes packaging bugs that only surface after publishing. The most common failure: tests pass locally because Python adds the working directory to `sys.path`, but the installed package is missing files. The src/ layout eliminates this entire class of bugs by design. Every packaging expert -- Hynek Schlawack, the PyPA, the Pallets team -- has converged on this layout. Projects like pytest, pip, Flask, Black, Hatch, and attrs all use it. No major project has ever migrated away from src/ layout.

## src/ Layout vs Flat Layout

Place the importable package inside a `src/` directory. This forces an editable install (`pip install -e .` or `uv pip install -e .`) before tests can import the package, proving that packaging works as part of normal development.

| Aspect | src/ Layout | Flat Layout |
|--------|------------|-------------|
| Test safety | Tests cannot accidentally import from working directory | Tests may pass even when packaging is broken |
| Tool defaults | Default for `hatch new`, `uv init --lib`, `pdm init` | Default for `flit init` (historical) |
| Used by | pytest, pip, Flask, Black, Hatch, attrs | FastAPI, Pydantic, httpx (predate consensus) |
| Recommendation | **Use for all new packages** | Only for existing projects already using it |

**Flat layout mitigation** (if you must keep it): add `--import-mode=importlib` to pytest and test against the built wheel in CI.

## Canonical Directory Structure

Start every new Python package from this structure:

```
my-package/                         # Repository root (kebab-case)
    src/
        my_package/                 # Importable package (snake_case)
            __init__.py             # Public API, __all__, version
            py.typed                # PEP 561 marker (empty file)
            core.py                 # Primary public module
            models.py               # Data models
            exceptions.py           # Custom exception hierarchy
            _internal/              # Private implementation
                __init__.py
                _utils.py
                _compat.py          # Python version shims
            cli.py                  # CLI entry point (see cli-architecture skill)
    tests/
        conftest.py                 # Shared fixtures
        test_core.py
        test_models.py
    docs/
        index.md
    pyproject.toml                  # Single configuration file
    README.md
    LICENSE
    CHANGELOG.md
    .gitignore
    .pre-commit-config.yaml
```

## Naming Conventions

| Element | Convention | Example |
|---------|-----------|---------|
| GitHub repository | kebab-case | `my-package` |
| PyPI distribution name | kebab-case | `my-package` |
| Import name | snake_case | `my_package` |
| `src/` directory | snake_case (matches import) | `src/my_package/` |
| `[project] name` in pyproject.toml | kebab-case | `name = "my-package"` |

PyPI normalizes names -- `my-package`, `my_package`, and `My.Package` resolve to the same package. Use kebab-case for distribution, snake_case for import. This is what Hatch, uv, and PDM generate by default.

## Test Placement and Organization

Place tests at the repository root in `tests/`, never inside the source package. Every major package (FastAPI, Pydantic, httpx, pytest, Flask, Hatch, attrs, Rich, Polars) follows this convention. Shipping tests inside the package wastes disk space and requires dev dependencies that users do not have.

**Flat with markers** (small to medium packages, used by httpx, FastAPI):
```
tests/
    conftest.py
    test_core.py
    test_client.py
```

**Directory separation** (large packages, used by pytest, Pydantic):
```
tests/
    conftest.py
    unit/
        test_parsing.py
    integration/
        test_database.py
```

**Mirror the source directory structure** -- the `tests/` directory must mirror `src/my_package/` exactly, with the same subdirectories and a `test_`-prefixed file for every source module. This 1:1 mapping makes it obvious where tests live and immediately reveals untested modules.

```
src/my_package/              tests/
    core.py            →         test_core.py
    models.py          →         test_models.py
    _internal/         →         _internal/
        _utils.py      →             test_utils.py
        _compat.py     →             test_compat.py
```

Include `__init__.py` in `tests/` only when using nested test subdirectories to prevent file name collisions.

## `__init__.py` Design

The `__init__.py` defines the public API surface. Follow these rules:

1. **Re-export from private modules** -- users import from `__init__.py`, never from `_internal`
2. **Define `__all__`** -- makes the public API explicit for tools, type checkers, and humans
3. **Keep it clean** -- imports, re-exports, and `__all__` only. No implementation logic
4. **Expose version via `_version.py`** -- generated by hatch-vcs or setuptools-scm, gitignored

```python
"""My Package -- a well-structured Python library."""

from my_package._version import __version__, __version_tuple__
from my_package._internal._client import Client
from my_package._internal._config import Settings
from my_package.exceptions import MyPackageError
from my_package.models import Item, User

__all__ = ["Client", "Settings", "Item", "User", "MyPackageError"]
```

**Why `_version.py` instead of `importlib.metadata.version()`?** The `importlib.metadata` approach reads from installed package metadata, which is a static snapshot frozen at install time. With VCS-based dynamic versioning (hatch-vcs, setuptools-scm), this causes problems: the version goes stale after new commits without reinstalling, it fails during builds if the backend imports your package (circular dependency), and it does not work at all when running from source without installing. The `_version.py` file is generated by the build hook from git tags, gitignored (it is derived state), and imported as a plain Python file with zero dependencies. See the `pyproject-toml` skill for the full hatch-vcs configuration.

For packages with heavy dependencies, use lazy imports via `__getattr__` (as Pydantic does). For most packages, eager imports are simpler and sufficient.

## `_internal/` Convention

Use underscore-prefixed modules or directories for private implementation. The public API lives in `__init__.py` re-exports; internals can change without notice.

| Approach | Used By | Best For |
|----------|---------|----------|
| `_internal/` directory | Pydantic | Large packages with many private modules |
| `_module.py` files | httpx, Rich | Medium packages, flat internal structure |
| Both combined | Flask/Werkzeug | Packages with deep module trees |

## `py.typed` Marker (PEP 561)

Create an empty file at `src/my_package/py.typed`. Without it, type checkers (mypy, pyright) ignore all type annotations in your package for downstream users -- even if every function is fully typed. Every well-maintained typed package ships this file: Pydantic, httpx, FastAPI, Rich, attrs, Flask.

Add the `Typing :: Typed` classifier to `pyproject.toml` to signal typing support on PyPI.

## Root-Level Files

| File | Status | Purpose |
|------|--------|---------|
| `pyproject.toml` | **Required** | Single source of truth for build, metadata, tool config |
| `README.md` | **Required** | GitHub landing page and PyPI description |
| `LICENSE` | **Required** | SPDX-compliant license text |
| `.gitignore` | **Required** | Python + IDE + OS patterns |
| `CHANGELOG.md` | Recommended | Keep a Changelog format |
| `CONTRIBUTING.md` | Recommended | Dev setup, PR conventions |
| `SECURITY.md` | Recommended | Vulnerability reporting policy |
| `.pre-commit-config.yaml` | Recommended | Ruff + file hygiene hooks |
| `Makefile` or `justfile` | Recommended | Common dev commands |

Consolidate all tool configuration into `pyproject.toml`. Do not create `.flake8`, `mypy.ini`, `pytest.ini`, `.isort.cfg`, or `tox.ini` -- Ruff replaces most linting tools, and pytest/mypy/coverage all support `pyproject.toml` natively.

## Monorepo and Workspace Patterns

| Scenario | Pattern | Example |
|----------|---------|---------|
| Standard Python library | Single repo, single package | httpx, Flask, attrs |
| Python + substantial Rust core | Dual repo (separate release cadences) | Pydantic + pydantic-core |
| Tightly coupled Python + Rust | True monorepo (co-versioned) | Polars |
| Multiple related Python packages | uv workspace with shared lockfile | Internal libraries, plugin architectures |

Default to single repo, single package. Use uv workspaces (`[tool.uv.workspace]` with `members = ["packages/*"]`) only when you maintain multiple related packages that share dependencies and should be tested together.

## Anti-Patterns

| Anti-Pattern | Consequence |
|-------------|-------------|
| Source code at repository root (no `src/` or package dir) | Ambiguous structure, breaks editable installs |
| Tests inside the source package | Wastes user disk space, test deps unavailable |
| `setup.py` as primary config | Deprecated, removed from pip 24.0 |
| `requirements.txt` for dependency specification | Not a packaging standard, use `pyproject.toml` |
| Implementation logic in `__init__.py` | Bloated imports, hard to maintain |
| Missing `py.typed` in a typed package | Type checkers ignore all annotations for users |
| Multiple config files for one tool | Fragmented, hard to discover and maintain |

## Review Checklist

When reviewing a Python project structure:

- [ ] Package uses src/ layout (`src/my_package/`)
- [ ] Tests live at repository root (`tests/`), not inside the package
- [ ] Every public source module has a corresponding test file (1:1 mapping)
- [ ] `pyproject.toml` is the single configuration file (no `setup.py`, `setup.cfg`, `tox.ini`)
- [ ] `__init__.py` defines `__all__` with curated re-exports
- [ ] No implementation logic in `__init__.py` -- only imports, re-exports, and version
- [ ] Version exposed via `_version.py` (generated by hatch-vcs or setuptools-scm, gitignored)
- [ ] `py.typed` marker exists at `src/my_package/py.typed`
- [ ] Private modules use underscore prefix (`_internal/`, `_utils.py`)
- [ ] Repository name uses kebab-case, import name uses snake_case
- [ ] Required root files present: `pyproject.toml`, `README.md`, `LICENSE`, `.gitignore`
- [ ] No `requirements.txt` used as primary dependency specification
- [ ] Documentation follows Diataxis structure in `docs/`
- [ ] No scattered config files (`.flake8`, `mypy.ini`, `pytest.ini`) -- all in `pyproject.toml`

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.