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=valuelines 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.
stockbox/
pyproject.toml
uv.lock
README.md
.gitignore
.env.example
src/
stockbox/
__init__.py
__main__.py
cli.py
inventory.py
tests/
test_inventory.py02Keep 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.
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).
from stockbox.cli import main
main()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).
uv sync # installs the project itself, editable (if it has a build system)
python -m pip install -e . # the pip way, inside an activated venv05.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.
.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.
# copy to .env and fill in real values; .env is never committed
APP_NAME=stockbox
LOW_LIMIT=5
API_KEY=replace-me07If 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
Add a module
In the first example, add a new file
src/stockbox/report.pywith a functionsummary(items)that returnsf"{len(items)} items", and call it fromcli.py. Rerun. Notice you only changed the files dictionary; the structure stayed the same. - 2
Override a setting
In the second example, remove the line
os.environ["LOW_LIMIT"] = "2"and run again.low_limitbecomes 5 from the file. Put it back and the environment wins. This is the rule you rely on in production. - 3
Build a real one
On your computer run
uv init --package demo(the--packageflag creates asrc/layout), thencd demo,uv sync, anduv run demo. Look at the generatedpyproject.tomlfor the[project.scripts]and[build-system]tables. The exact files depend on your uv version.
Code & diagrams
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`.
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, tapeA 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).
Expected output
Settings(app_name='stockbox', low_limit=2, api_key=set)
secret never printed: TrueBreak 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.
import os
key = os.environ["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.
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 thesrc/layout, but you may need to say which package to include if the folder name differs from the project name. Ifuv syncinstalls 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
reproutput. The code example hides the key inside__repr__; libraries such as Pydantic offerSecretStr, 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 anAuthorizationtoken. - ▸
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
The commonest professional shape is the **
src/layout**. The root folder holdspyproject.toml,README.md,.gitignore, the lock file, atests/folder, and asrc/folder. Insidesrc/is your package, the folder you import (src/stockbox/). Everything outsidesrc/is project housekeeping, not importable code. - 2
Why put the package under
src/instead of besidepyproject.toml? Because of the search path (Topic 6.2). Ifstockbox/sits in the project root, then running Python or tests from that folder puts the root onsys.pathandimport stockboxworks *by accident*, from your source folder, even if the package is broken or not installed. Withsrc/, the code is importable only after the project is installed (usually in editable mode,uv syncorpip install -e .), so your tests exercise the same thing your users get. - 3
Entry points are how people start the program.
src/stockbox/__main__.pymakespython -m stockboxwork. Recall from Topic 6.2:-mruns something by its import name, and for a package Python runs its__main__.pyfile. A[project.scripts]entry (Topic 6.7) such asstockbox = "stockbox.cli:main"makes a real command calledstockboxappear 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
Tests live in a top-level
tests/folder, with names liketest_inventory.py(Phase 11). Keeping them outsidesrc/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
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.envfile 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
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.examplewith fake placeholder values so others know which variables exist, and never print a secret, not even in debug output. - 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
Describe the src/ layout and why it is preferred.
What is the difference between python script.py and python -m package, and what is __main__.py?
How should configuration and secrets be handled in a Python project?
What should be in .gitignore for a Python project, and why?
Practice
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.
Write code that reads the environment variable PORT, turns it into an integer, and uses 8000 when PORT is not set. Print the number.
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. - ↔
**
.envfiles vs a secrets manager.** A.envfile 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 packageand explain__main__.py.Done when you can write a
.gitignorefor 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.