Create your own
Lesson illustration

Setting Up an Installable Python Project with a src Layout

Hello, and welcome to the first lesson of the course. We will build an incident-management API gradually, but we will begin with the part that makes every later step reliable: treating the codebase as an installable Python project rather than a collection of scripts.

This first module establishes professional Python project foundations. Today you will create a small, empty-but-valid package using a src layout, run it in an isolated virtual environment, and describe it with pyproject.toml. The result will be ready for FastAPI code, tests, linting, and CI without requiring import-path workarounds later.


Why an application should still be a package

A Python project is the whole repository: documentation, configuration, tests, source code, and deployment assets. A Python package is the importable code unit inside it.

For this course, use these two related names:

  • Distribution/project name: incident-api
  • Python import package: incident_api

The hyphenated project name is conventional for a package installed by pip. Python identifiers cannot contain hyphens, so imports use an underscore:

import incident_api

An installable package is useful even if you never publish it to PyPI. Installation gives Python a deliberate, reproducible way to locate the application code. That prevents a common failure mode in script-grown projects: code works only when a developer runs it from one particular directory, or only after setting PYTHONPATH.

The src layout makes this discipline concrete:

incident-api/
├── .gitignore
├── pyproject.toml
├── README.md
└── src/
    └── incident_api/
        └── __init__.py

The repository root is deliberately different from the source directory. When you start Python from incident-api/, Python can see that directory, but it does not automatically see incident-api/src/. Therefore, import incident_api succeeds only after the project has been installed into the environment. This is a productive constraint: your local execution and tests exercise the package as installed, much closer to how it will run in CI or a container.

An example Python project using a `src` directory: importable package code lives under `src/super_package`, while `pyproject.toml`, documentation, and generated build artifacts remain at the repository root. Your project will begin with the same separation, without yet creating `dist/` artifacts.

My 2025 uv-based Python Project Layout for Production Apps

In “My 2025 uv-based Python Project Layout for Production Apps,” Hynek Schlawack explains the central reason for the src layout: it prevents local files from accidentally masquerading as an installed package. This short portion is tool-independent; use it for the rationale, then follow the venv workflow below.

Watch the src rationale. Focus on the distinction between a directory that merely happens to contain Python files and a package that is actually installed and imported.

Two practical consequences follow:

  1. Do not add src to PYTHONPATH to “fix” imports.
  2. Do not modify sys.path inside application code.

Both can hide a broken package configuration until a deployment or another developer’s machine exposes it.


Create the repository and its isolated environment

Create a new Git repository named incident-api. You can create the directories in your editor or from a terminal. From the repository root, create this source path:

src/incident_api/

Inside src/incident_api/, create __init__.py with only a package docstring:

"""Incident management API package."""

An __init__.py file marks this directory as a regular Python package. Keeping it free of application startup code is important: importing incident_api should not open network connections, load production configuration, or start a server.

Create a minimal README.md at the repository root:

# Incident API

An internal service for managing operational incidents.

Also create .gitignore:

.venv/
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/

The virtual environment is local machine state, not source code. Generated metadata such as *.egg-info/ and future distribution artifacts under dist/ should likewise stay out of version control.

The virtual environment

A virtual environment gives this project its own Python interpreter context and its own installed packages. It is analogous to keeping a CI job’s dependencies isolated rather than relying on whatever tools happen to be installed on a shared build agent.

First, verify the Python version you intend to use:

python --version

For this course, use Python 3.12 or later. If multiple interpreters are installed, invoke the desired one explicitly when creating the environment.

EnvironmentCreate the environment in the project root
Linux or macOSpython3.12 -m venv .venv
Windows with the Python launcherpy -3.12 -m venv .venv
Any platform where python is already Python 3.12+python -m venv .venv

The .venv convention makes the environment visible to editors while clearly identifying it as project-local.

venv — Creation of virtual environments

Read the official Python documentation, “venv — Creation of virtual environments,” to understand what Python creates and why a virtual environment should be recreated rather than copied between machines.

In “Creating virtual environments,” read the creation model, noting the environment-specific interpreter and site-packages directory. Then, in “How venvs work,” read the prefix check and the activation table for your shell. Finish with the portability warning: environments are disposable local state and must be recreated at a new path.

Activate the environment for your shell:

ShellActivation command
Linux/macOS Bash or Zshsource .venv/bin/activate
PowerShell.venv\Scripts\Activate.ps1
Windows Command Prompt.venv\Scripts\activate.bat

After activation, check that the interpreter really is inside the environment:

python -c "import sys; print(sys.executable); print(sys.prefix != sys.base_prefix)"

The first line should point into .venv; the second should print True.

Activation is a shell convenience: it adjusts PATH so that python and installed commands refer to .venv. It is not magic, and it is not required in automation. A CI pipeline can always call the environment’s Python executable directly. For interactive work, activation reduces mistakes.

If PowerShell refuses to run Activate.ps1, it may be subject to an execution policy. Follow your organization’s policy; the Python documentation describes the user-scope RemoteSigned option. Avoid bypassing execution controls blindly.


Describe the project with pyproject.toml

At the repository root, create pyproject.toml:

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
name = "incident-api"
version = "0.1.0"
description = "An internal API for managing operational incidents."
readme = "README.md"
requires-python = ">=3.12"

[tool.setuptools.packages.find]
where = ["src"]
include = ["incident_api*"]

This small file has three separate responsibilities.

1. The build system

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

This tells installation tools how to build your project. pip can create a temporary isolated build environment, install the declared build requirement there, and invoke the named backend.

setuptools is the build backend selected for this course because it is mature, standards-based, and adequate for an application package. It is not an application dependency. FastAPI, database drivers, and test tools will be classified deliberately in the next lessons.

2. Standard project metadata

[project]
name = "incident-api"
version = "0.1.0"
requires-python = ">=3.12"

The [project] table contains metadata defined by the Python packaging standard.

  • name is the installed distribution name. Package tools normalize names, so hyphens and underscores are generally treated consistently for installation.
  • version identifies this build of your project. 0.1.0 is a reasonable initial version; the versioning strategy can evolve as the service matures.
  • requires-python states the versions your project supports. It does not install Python or switch your current interpreter, so keep your local .venv aligned with it.
  • readme and description make the package understandable to a future maintainer and, if you later publish it internally, to consumers.

3. Source-package discovery

[tool.setuptools.packages.find]
where = ["src"]
include = ["incident_api*"]

This is setuptools-specific configuration. It says that importable packages should be discovered beneath src, including incident_api and any future subpackages such as incident_api.domain.

For a conventional src layout, setuptools can often discover packages automatically. Declaring where = ["src"] explicitly makes the intended boundary clear and reduces ambiguity when the repository later contains additional directories.

Configuring setuptools using pyproject.toml files

The setuptools guide connects the configuration you just wrote to the PEP 621 packaging standard. Read it to distinguish standard project metadata from build-backend-specific settings.

In the introductory material before “Setuptools-specific configuration,” read the core tables. Compare its [build-system] and [project] tables with yours; do not copy its optional publishing metadata yet. Then go to “Setuptools-specific configuration” and read the Tip beginning the discovery rule, continuing through the find configuration example. Notice that the src layout is a supported conventional layout.

Install your project in editable mode

With .venv activated and your terminal at the directory containing pyproject.toml, install the project:

python -m pip install --editable .

The dot means “this directory.” The --editable option, also written -e, installs a development link to the project rather than freezing a copy of its source into site-packages.

That distinction matters during development:

  • Changes to Python files under src/ are immediately used the next time Python imports or runs the package.
  • Changes to pyproject.toml affect installation metadata and usually require rerunning the same install command.
  • The project is now importable independently of the terminal’s current directory.

Validate it while still at the project root:

python -c "import incident_api; print(incident_api.__file__)"
python -m pip show incident-api

The first command should print a path ending in:

src/incident_api/__init__.py

Now move to the parent directory and repeat the import:

cd ..
python -c "import incident_api; print(incident_api.__file__)"

It should still work because the package is installed into .venv, not because you happen to be standing next to its source. Return to the repository afterward:

cd incident-api

You may notice a directory such as src/incident_api.egg-info/ after installation. This is generated metadata used by the build and installation tooling. It is expected and is why .gitignore excludes *.egg-info/.

A useful checkpoint is your repository’s state:

incident-api/
├── .gitignore
├── .venv/                    # local and ignored
├── README.md
├── pyproject.toml
└── src/
    ├── incident_api/
    │   └── __init__.py
    └── incident_api.egg-info/  # generated and ignored after installation

At this point, your project has no runtime dependencies and no web endpoint. That is intentional. It is a correctly installable foundation, and we can now add dependencies without mixing application requirements, development tools, and machine-local state.


Key takeaways

You have established four professional habits:

  • The repository is a project; src/incident_api is its importable package.
  • The src layout prevents accidental imports from the checkout and exposes packaging mistakes early.
  • .venv isolates local installed packages and is disposable; recreate it rather than committing or copying it.
  • pyproject.toml declares how the project is built, its standard metadata, supported Python version, and package discovery rules.
  • python -m pip install --editable . makes your source package available in the active environment while you develop it.

In the next lesson, you will add runtime and development dependencies separately and generate a reproducible lock file. That will turn this clean package shell into a controlled environment for FastAPI, testing, formatting, and type checking.

Can't find a good explanation? Sign up and we'll make it for you

Sign up