Command Palette

Search for a command to run...

Hectal
PHASE 6Intermediate ~29 min· topic 8 of 9

Topic 6.8

Laying Out a Real Project

In one line

A real project has a predictable shape: code in src/<package>/, tests in tests/, a pyproject.toml, a README, a .gitignore, and settings and secrets kept out of the code. It runs with python -m package or an installed command.

Think of it like this

A school bag. Each subject has its own folder. Books go in one compartment, the lunchbox in another, and pocket money stays in your pocket, not written on the cover of your notebook. Anyone who opens the bag can find things without asking. A good project layout is a well-packed bag: every kind of file has its place, and secrets stay out of sight.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

src layout
Putting the importable package under a src/ folder, so it can only be imported after installation.
Editable install
Installing a project so that Python imports straight from your source folder, and your edits take effect at once.
Entry point
The place a program starts: a file run with python -m package, or a command created from [project.scripts].
__main__.py
A file inside a package that Python runs for python -m package.
.gitignore
A file listing paths that git must not track.
Environment variable
A setting given to a program by its surroundings, read with os.environ.
.env file
A text file of NAME=value lines for local settings. It is not committed to git.
Secret
A value that gives access to something, such as an API key (a long password a program uses to prove who it is) or a password.
Version control (git)
A tool that records the history of your files. To commit means to save a snapshot into that history. See the Git course.
Editable install flag -e
pip install -e . installs the project in the current folder (.) so edits show up at once.
README
The first file a visitor reads: what the project is, how to install and run it.

Step by step

01The shape of a project

This is the layout used by most modern Python projects. Memorise the roles, not the names: code in src/<package>, tests in tests/, metadata in pyproject.toml, and a few files at the root for humans and tools.

layoutwhole filebash
stockbox/
  pyproject.toml
  uv.lock
  README.md
  .gitignore
  .env.example
  src/
    stockbox/
      __init__.py
      __main__.py
      cli.py
      inventory.py
  tests/
    test_inventory.py
The shape of a projectdiagram
Rendering diagram…

02Keep logic importable, entry points thin

inventory.py holds a plain function with no printing and no argument parsing. cli.py calls it and prints. The test imports the function and never touches the command line. This split is the most important habit in the whole topic.

src/stockbox/inventory.pywhole filepython
def low_stock(items, limit=5):
    return sorted(name for name, qty in items.items() if qty < limit)

03Running it: python -m

__main__.py just calls the same main(). After the project is installed, python -m stockbox runs it from any folder. This form also sets up the package context, so relative imports inside the package work (Topic 6.3).

src/stockbox/__main__.pywhole filepython
from stockbox.cli import main

main()
terminal
$ python -m stockbox
── expected output ──
low stock: pens, tape

04Installing the project (editable)

With uv, a project that has a [build-system] table (for example one made with uv init --package) is installed into .venv in editable mode by uv sync, so import stockbox finds src/stockbox. A plain uv init application has no [build-system], and uv then installs only its dependencies, not the project itself. With pip, the equivalent is python -m pip install -e .. After that, edits to the source are live. A [build-system] table in pyproject.toml tells these tools how to build and install the project (Topic 6.9).

terminalwhole filebash
uv sync                          # installs the project itself, editable (if it has a build system)
python -m pip install -e .       # the pip way, inside an activated venv

05.gitignore: what git must never see

List environments, caches, build output and secret files. Patterns ending in / mean folders. A file that was already committed stays tracked even after you add it here: .gitignore only affects files git has not yet seen.

.gitignorewhole filebash
.venv/
__pycache__/
*.pyc
dist/
.env
.pytest_cache/
.ruff_cache/

06Configuration from the environment

Read settings with os.environ.get("NAME", default). For local development, a .env file holds the values, loaded by your tool or a small helper (Pydantic settings or python-dotenv, Topic 15.3). A real environment variable overrides the file. That lets the same code run on your laptop (values from .env) and in production (values from the platform).

Use os.environ["API_KEY"] (without .get) for a value that must exist: a missing key then fails immediately and loudly with KeyError, rather than later with a confusing message.

.env.examplewhole filebash
# copy to .env and fill in real values; .env is never committed
APP_NAME=stockbox
LOW_LIMIT=5
API_KEY=replace-me

07If a secret ever leaks

Deleting the file in a later commit does not help; the secret remains in the history, and in anyone's clone. Do these in order: (1) revoke the key in the provider's dashboard so it stops working, (2) create a new one and put it in the environment, (3) only then clean the history if you must. Scanners such as gitleaks can check a repository and run before commits (Topic 11.8).

Try it yourself

  1. 1

    Add a module

    In the first example, add a new file src/stockbox/report.py with a function summary(items) that returns f"{len(items)} items", and call it from cli.py. Rerun. Notice you only changed the files dictionary; the structure stayed the same.

  2. 2

    Override a setting

    In the second example, remove the line os.environ["LOW_LIMIT"] = "2" and run again. low_limit becomes 5 from the file. Put it back and the environment wins. This is the rule you rely on in production.

  3. 3

    Build a real one

    On your computer run uv init --package demo (the --package flag creates a src/ layout), then cd demo, uv sync, and uv run demo. Look at the generated pyproject.toml for the [project.scripts] and [build-system] tables. The exact files depend on your uv version.

Code & diagrams

Build a src layout and run it with python -m New tab

The program creates the whole project on disk (`mkdir(parents=True, exist_ok=True)` makes a folder and any missing parents, and `rglob("*")` lists everything below the current folder; Topic 7.6 teaches `pathlib`), lists the files, then does what an editable install achieves by putting `src` on the import path. `runpy.run_module` then runs the package exactly like `python -m stockbox`.

Sign in to run this example in your browser.

Expected output

.gitignore
README.md
src/stockbox/__init__.py
src/stockbox/__main__.py
src/stockbox/cli.py
src/stockbox/inventory.py
tests/test_inventory.py
--- python -m stockbox ---
low stock: pens, tape
Settings from the environment, secrets kept quiet New tab

A tiny `.env` reader. `setdefault` means a variable that is already set wins over the file, which is the rule on real servers. The class never prints the key itself, only whether it is set. The key shown in the file is a fake placeholder. `class Settings:` (Phase 8 teaches classes) is read here as "one labelled bundle of settings", and `__repr__` is the method that decides what `print(Settings())` shows. `key, _, value = line.partition("=")` splits a line at the first `=` (Topic 3.2).

Sign in to run this example in your browser.

Expected output

Settings(app_name='stockbox', low_limit=2, api_key=set)
secret never printed: True
Why src/ keeps tests honestdiagram
Rendering diagram…

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Read a setting that is not there

Use os.environ["API_KEY"] when the variable is not set.

main.pywhole filepython
import os

key = os.environ["API_KEY"]
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 3, in <module>
key = os.environ["API_KEY"]
~~~~~~~~~~^^^^^^^^^^^
File "<frozen os>", line 716, in __getitem__
KeyError: 'API_KEY'

Break #2

Run a file inside the package by path

Use a relative import in src/stockbox/cli.py and run python src/stockbox/cli.py.

terminal
$ python src/stockbox/cli.py
── what you'll see ──
Traceback (most recent call last):
File "src/stockbox/cli.py", line 1, in <module>
from .inventory import low_stock
ImportError: attempted relative import with no known parent package

Myth vs fact

Myth

The project layout is a matter of taste and doesn't matter.

Fact

Layout decides what is importable, what is tested and what is shipped. A predictable layout lets new contributors and tools work without a guide.

Myth

A secret is safe if I delete it in the next commit.

Fact

Git keeps history. Treat any committed secret as exposed: revoke and replace it.

Myth

.env is secure.

Fact

It is just a text file, convenient for local development. It stays out of git, and in production secrets come from the platform's secret store or environment.

Myth

Tests can sit next to the code in src/ or anywhere.

Fact

They can, but a separate tests/ folder keeps them out of the package users install.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Packaging tools differ in how they discover src/. Build backends such as hatchling and setuptools know the src/ layout, but you may need to say which package to include if the folder name differs from the project name. If uv sync installs the project but the import fails, check the [build-system] and package settings first.

  • ▸

    In a monorepo (the word for one git repository that holds several projects), several projects share one repository, each with its own pyproject.toml, and a workspace feature in uv (or Poetry/Hatch) shares one lock file. The layout rules still apply per project.

  • ▸

    For applications, separate pure logic (functions that take data and return data) from I/O (files, network, databases). The pure part can be tested with no setup at all (a test, Topic 11.1, calls the function and checks the answer); the I/O part is thin and tested with fakes (Topic 11.4). Many AI projects keep the model call behind one small function or class for this exact reason.

  • ▸

    Treat a secret as a special type, not a plain string, so it cannot leak by accident into logs, tracebacks and repr output. The code example hides the key inside __repr__; libraries such as Pydantic offer SecretStr, which prints as stars and needs an explicit call to reveal its value. Also log with care: a request logged with its headers can contain an Authorization token.

  • ▸

    Pre-commit hooks and CI secret scanners are your safety net for leaks (Topic 11.8). Cloud providers also scan public repos and may revoke leaked keys, but never rely on that.

Remember this

  1. 1

    The commonest professional shape is the **src/ layout**. The root folder holds pyproject.toml, README.md, .gitignore, the lock file, a tests/ folder, and a src/ folder. Inside src/ is your package, the folder you import (src/stockbox/). Everything outside src/ is project housekeeping, not importable code.

  2. 2

    Why put the package under src/ instead of beside pyproject.toml? Because of the search path (Topic 6.2). If stockbox/ sits in the project root, then running Python or tests from that folder puts the root on sys.path and import stockbox works *by accident*, from your source folder, even if the package is broken or not installed. With src/, the code is importable only after the project is installed (usually in editable mode, uv sync or pip install -e .), so your tests exercise the same thing your users get.

  3. 3

    Entry points are how people start the program. src/stockbox/__main__.py makes python -m stockbox work. Recall from Topic 6.2: -m runs something by its import name, and for a package Python runs its __main__.py file. A [project.scripts] entry (Topic 6.7) such as stockbox = "stockbox.cli:main" makes a real command called stockbox appear when the project is installed. Keep the entry point thin: it parses arguments and calls a function, and the real logic lives in importable, testable functions.

  4. 4

    Tests live in a top-level tests/ folder, with names like test_inventory.py (Phase 11). Keeping them outside src/ means they are not shipped to users. A README answers three questions for a stranger: what is this, how do I install it, how do I run it. A **.gitignore** keeps files out of version control. (Version control, usually git, records the history of your project's files; the Git course teaches it.) Typical entries are .venv/, __pycache__/, .env, dist/ and *.pyc. Committing a virtual environment or a cache folder is a classic beginner mistake.

  5. 5

    Configuration is not code. Settings that differ between your laptop, a test server and production (a database address, a model name, an API key) must not be typed into source files. Read them from environment variables (os.environ) or a .env file that is listed in .gitignore, and give safe defaults for the harmless ones. This follows the "twelve-factor" habits, a well-known list of good practices for programs that run on servers. One of them is: keep settings in the environment, not in the code (Topic 15.3 goes deeper).

  6. 6

    Secrets (API keys, passwords, tokens) are the most important case. Anything committed to git is permanent: removing it in the next commit does not remove it from history, and public repositories are scanned by bots within minutes. If a key is ever committed, treat it as stolen: revoke it and issue a new one. Keep a .env.example with fake placeholder values so others know which variables exist, and never print a secret, not even in debug output.

  7. 7

    Structure should grow with the project. A single script is a single file. When it needs a second module, make a package. Adding src/, tests/ and a lock file is cheap at that point and saves pain later. Do not create ten empty folders on day one "for the future".

Explain it without notes

01

Describe the src/ layout and why it is preferred.

02

What is the difference between python script.py and python -m package, and what is __main__.py?

03

How should configuration and secrets be handled in a Python project?

04

What should be in .gitignore for a Python project, and why?

Practice

01

Write the lines of a .gitignore file that keep these out of git: the .venv folder, the __pycache__ folders with the .pyc files inside, and the .env file.

02

Write code that reads the environment variable PORT, turns it into an integer, and uses 8000 when PORT is not set. Print the number.

03

Write the full content of a __main__.py file, in the package stockbox, that imports main from the module cli of the same package (use an absolute import) and calls it.

Trade-offs

  • ↔

    **src/ layout vs flat layout.** src/ catches packaging mistakes and import accidents, at the cost of needing an install step before running anything. A flat layout is simpler for tiny scripts and notebooks-to-code transitions.

  • ↔

    **.env files vs a secrets manager.** A .env file is quick to set up and fine for local development. In production a platform secret store gives access control, rotation and audit logs that a file cannot.

Done when you can

  • Done when you can sketch the src/ layout and say what each part is for.

  • Done when you can run a package with python -m package and explain __main__.py.

  • Done when you can write a .gitignore for a Python project.

  • Done when you can read config from environment variables with a default, and fail loudly on a required one.

  • Done when you can explain what to do if a secret is committed.