The structure of a project (2): passes on my machine, fails when installed
By Dorian Chávez · founder and CEO of Hábil ·
Your tests pass and your package breaks for whoever installs it. PyPA's guide asks for src/, but an editable install also reads your folder. With a template.
The structure of a project · installment 2 of 7 · Python
Scenario: the characters and the story are made up. The technical data and the quotes carry their source at the end.
In 1 minute
- If you run the tests from the project root, Python may find your folder before the installed package. A file that never made it into the package passes your tests and breaks for whoever installs it [1][3].
- The Python packaging guide (PyPA) solves it with one folder:
src/. With it, the code only runs if it's installed [1]. - But watch out: the editable install (
pip install -e .) also reads your folder. With hatchling, the whole ofsrc/(measured) [5][6]. - That's why this installment's template tests twice: fast against your folder, and for real against what's installed. And it brings a script that proves it by turning red when it should.
- For Monday: run your tests against the installed package in a fresh environment. If something breaks, you already know what it is.
In this article
- What happened on Friday at 9:30?
- What does the packaging guide ask for?
- What are the five most common mistakes?
- Exercise: can you repeat Iván's mistake?
- Key takeaways
- What's next?
- The template and the course
- What does Hábil do?
- References
What happened on Friday at 9:30?
Iván's tests all passed. The package Sofía installed didn't have the file those tests used.
Thursday, 18:10. Third floor.
After Go, the second assignment looked easy: "Make the Python checker export the report to CSV." The checker was the same program as the week before, but in Python: it read a list of services, queried them and said which ones responded.
The repository, inherited once again, looked like this:
revisor/ el paquete
tests/
pyproject.tomlIván wrote the function in a new file, exportar.py. He left it at the root, next to everything else, because that's where he could see it. In revisor/cli.py he added one line: import exportar. He ran the tests the way he'd been taught, from the root:
$ python -m pytest -q
. [100%]
1 passed in 0.00sGreen. He pushed the change and went home.
Friday, 9:30.
Sofía, from operations, installed the new version in her environment with pip install . and ran the command. The terminal answered with this (paths trimmed):
Traceback (most recent call last):
File ".../bin/revisor", line 3, in <module>
from revisor.cli import main
File ".../site-packages/revisor/cli.py", line 1, in <module>
import exportar
ModuleNotFoundError: No module named 'exportar'Iván opened his folder: exportar.py was there. He ran the tests again. Green again.
"It passes on my machine," he wrote in the channel.
Lucía, the tech lead, answered with a question:
"Did your tests test what Sofía installed, or your folder?"
Iván sat staring at the screen. It had never occurred to him that those were two different things.
"From the root, Python sees everything at the root," Lucía went on. "Sofía doesn't have your root. She has the package. And exportar.py never made it into the package."
What does the packaging guide ask for?
That importable code live in src/, so that it only runs after being installed.
The official Python packaging guide, from PyPA, compares the two layouts in src layout vs flat layout [1]. In the flat layout, the package lives at the root, next to pyproject.toml. In the src/ layout, it goes down one level.
The difference that matters is on the first line of its list: with src/ you have to install the project to run its code, and with the flat layout you don't [1]. It looks like a drawback. It's the point.
The guide explains why: Python puts the current folder at the start of its search path, so if that folder holds a package with the same name as the installed one, the folder's wins. And that, it says, can leave the packaging configuration wrong without anyone noticing, «which could result in files not being included in a distribution» [1]. That's what happened to Iván.
The current folder has a nuance: it doesn't always get in. python -m pytest adds it; plain pytest doesn't [3][8]. Iván used the first form, and with it his whole root was in view.
This installment's template, estructura-python, uses the src/ layout:
estructura-python/
├── pyproject.toml el proyecto en un solo archivo
├── servicios.toml el ejemplo
├── src/
│ └── revisor/ EL PAQUETE: lo único que viaja al instalar
│ ├── __init__.py · __main__.py
│ ├── cli.py opciones y código de salida
│ ├── servicio.py el vocabulario. Sin red ni archivos
│ ├── config.py lee servicios.toml
│ ├── revisar.py consulta muchos servicios a la vez
│ ├── reporte.py tabla y JSON
│ └── py.typed
├── tests/ las pruebas: no viajan
│ ├── conftest.py
│ └── test_*.py
├── herramientas/
│ ├── _comun.sh
│ ├── probar-instalado.sh
│ └── probar-frontera.sh
├── .github/workflows/verificar.yml
└── README.md · LICENSE · LICENSE-CONTENIDO.mdWhat decides what ships?
One line of pyproject.toml.
[tool.hatch.build.targets.wheel]
packages = ["src/revisor"]What gets installed is a wheel, a .whl file built from the project. That line tells hatchling, the tool that builds it, that the wheel carries src/revisor and nothing else [6]. Without it, hatchling guesses: it looks first for revisor/__init__.py and then for src/revisor/__init__.py [7]. The template writes it out so it can be read.
So everything that lives inside src/revisor/ ships. Whatever lives outside doesn't.
So src/ already protects me?
From your root, yes. From the editable install, not entirely.
For day-to-day work, the convenient thing is to install in editable mode: pip install -e .. That way you copy nothing; Python reads your files where they are and every change to the code shows up on the next run [5]. pip warns that the editable install and the regular one can behave differently, depending on the build tool, and that whoever uses your package «will see the behaviour of regular installs» [5].
And what does hatchling do? Its documentation says that, for an editable install, it decides which folders to add to the search path based on the selected files [6]. We measured it: it adds the whole of src/. If someone leaves a src/suelto.py outside the package, the editable install finds it and the wheel doesn't carry it.
The same page offers a way out, the dev-mode-exact option [6]. Measured: with it, import suelto gives ModuleNotFoundError. But it has a cost, and the page warns about it: «The dev-mode-exact mechanism is not supported by static analysis tools & IDEs» [6], so your editor stops autocompleting. And it doesn't see what the wheel excludes inside the package either (measured). That's why the template prefers to test the wheel, which is what whoever installs receives.
PyPA's guide already anticipates it: the typical setup is to use the editable install for development «and a regular installation is used for testing» [1]. The template does exactly that:
pytest # rápido: lee tu carpeta src/
bash herramientas/probar-instalado.sh # lo que ve quien instala tu paqueteThe second command creates a new, empty environment, installs the project without -e (that is, it builds the wheel and installs it) and runs the same tests. At the end it counts the results by reading pytest's report, not its exit code. These are its last three lines:
revisor bajo prueba: …/site-packages/revisor (el paquete INSTALADO)
pruebas: 62 · pasaron 62 · fallaron 0 · con error 0 · saltadas 0
✅ lo instalado pasa sus pruebasWhy do the tests live outside the package?
So they don't ship, and so they test the package the way anyone would see it.
tests/ sits next to src/, not inside it. pytest runs in importlib mode, the one its documentation recommends for new projects [2]: in that mode it doesn't put project folders on the search path to import the tests [4]. What gets tested is whatever is installed in the environment.
And how do you know what's installed? tests/conftest.py says so in the header of every run (the one pytest -q omits):
revisor bajo prueba: src/revisor (la CARPETA del proyecto)With the variable REVISOR_EXIGIR_INSTALADO=1, it also refuses to run if revisor comes from your folder. The installed-package script turns it on, so it can't test your src/revisor while believing it tested the wheel. Watch out: the guard only looks at where revisor comes from. If another module from your folder sneaks into the tests, the boundary catches it, and that comes next.
What's in pyproject.toml?
The whole project, in a single file.
PyPA's guide describes three tables: [build-system], which says what builds it; [project], with the project's data; and [tool], for each tool [13]. The template uses all three, plus a fourth: [dependency-groups], where pytest, ruff and mypy live with their pinned versions. That group exists since PEP 735, which defines it exactly like that: requirements stored in pyproject.toml that are not included in any distribution of the project [16]. Whoever installs revisor doesn't get pytest.
A measured detail: pip install --group dev needs pip 25.1 or newer [17], and a freshly created environment on Ubuntu 24.04, the base of Linux Mint 22 [10], comes with pip 24.0 [11]. The answer is no such option: --group. That's why the README asks for python -m pip install --upgrade pip before anything else.
And where does Iván's CSV go?
In the package, next to the report's other formats.
In the template, the report already has a home: reporte.py, which today turns results into a table or into JSON. An a_csv function goes there, next to a_json, and cli.py adds csv to the --formato options. No file at the root. No new folder.
On Monday, Iván moved his function into the package and ran the tests against what was installed. Green. This time, truly green.
How do I know the boundary exists?
Because a script breaks it on purpose and demands that the installed package not see what was left outside.
herramientas/probar-frontera.sh works on a temporary copy of the project. It plants src/suelto.py, a module that lives in src/ but outside the package, and a test that imports it. Then it runs the full suite twice, in two fresh environments with the same Python, the same copy and the same pytest version. Between the two, only one pip flag changes, -e:
- Control, with
pip install -e. Python reads the folder, so every test has to pass, the planted one included. If they don't pass, the experiment is set up wrong and the script stops in red. - Violation, with
pip install. The wheel gets installed. Exactly one test has to fail, the planted one, and with the exact message.
This is the real output:
1/2 control: pip install -e (la carpeta) · src/suelto.py está fuera del paquete
revisor bajo prueba: src/revisor (la CARPETA del proyecto)
✅ 63 de 63 pasaron: desde la carpeta, suelto.py se ve
2/2 violación: pip install (la rueda) · el mismo archivo, la misma prueba
revisor bajo prueba: …/site-packages/revisor (el paquete INSTALADO)
✗ test_suelto_viaja_con_el_paquete: ModuleNotFoundError: No module named 'suelto'
✅ 62 de 63 pasaron; falló solo la que usa suelto.py, por la razón exacta
✅ frontera: se sostieneWhy the control? Because an experiment that only expects a red also turns "green" when it fails for the wrong reason: a badly written test, a broken environment. We checked it: with the planted test broken on purpose, or with the control installed without -e, the script stops at "the experiment is set up wrong."
And if someone changes the packages line so the wheel takes all of src/, the violation passes and the script answers ❌ PASÓ: lo instalado vio src/suelto.py, que no está en el paquete. La frontera no existe. We checked that too.
GitHub CI is written to run everything with Python 3.12 and 3.14, on every change, manually and every Monday: ruff, strict mypy, the tests against the installed package and the boundary. If the public repository goes 60 days without activity, GitHub turns off the Monday run and you have to turn it back on [23].
What are the five most common mistakes?
The first three come from not knowing which folder Python sees; the last two, from describing the project halfway.
1. Testing the folder and believing you tested the package
The flat layout leaves the root in view, and the root isn't what gets installed.
It's Iván's mistake. PyPA's guide explains that src/ helps prevent accidental use of the in-development copy [1]. pytest says it outright: «it is strongly suggested to use a src layout», especially with its default mode [2]. And uv's documentation says that uv init prefers to put the code in src/<nombre>/ [18]; we checked it with uv 0.12.23.
The way out isn't just switching folders: it's also testing what's installed, as the template does.
2. pip install into the system Python
The operating system already manages that Python, and since PEP 668 it tells you so.
PEP 668 proposes that, by default, tools like pip neither install nor remove packages in a Python that the system marks as managed by it, and that they guide whoever tries toward a virtual environment [9]. On Ubuntu 24.04, without an environment, this is the answer (measured):
error: externally-managed-environment
× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
python3-xyz, where xyz is the package you are trying to
install.The PEP asks that the flag to force it «should carry some connotation that its use is risky» [9]. That's why it's called --break-system-packages. Everything goes inside a .venv, as in the template.
3. A file with the name of a standard module
A stray random.py hides Python's random.
The official tutorial warns about it: the folder of the script being run goes at the start of the search path, so its files are loaded instead of the library modules with the same name, and that «is an error unless the replacement is intended» [19]. Python 3.13 added a message that points it out [12]. Measured with a random.py that imports random:
Python 3.12:
AttributeError: partially initialized module 'random' has no attribute 'randint' (most likely due to a circular import)
Python 3.14:
AttributeError: module 'random' has no attribute 'randint' (consider renaming '…/random.py' since it has the same name as the standard library module named 'random' and prevents importing that standard library module)In 3.12 the message talks about a circular import and not about your file; Ubuntu 24.04's 3.12.3, the one in Mint, says the same (measured). Inside a package, running it as a package (python -m revisor.cli or the installed command), the clash doesn't happen: revisor/random.py is called revisor.random. But if you run the file as a script, python src/revisor/cli.py, it comes back (measured on 3.12 and 3.14).
4. Two test files with the same name
In pytest's default mode, tests/config/test_formato.py and tests/reporte/test_formato.py clash.
pytest's documentation explains it: if the tests aren't organized as packages, each file needs a unique name, or pytest flags an error [4]. Measured with pytest 9.1.1:
import file mismatch:
imported module 'test_formato' has this __file__ attribute:
…/tests/config/test_formato.py
which is not the same as the test file we want to collect:
…/tests/reporte/test_formato.py
HINT: remove __pycache__ / .pyc files and/or use a unique basename for your test file modulesThe usual fix was to add __init__.py to tests/, but that makes pytest put the repository root on the search path, and in a flat layout your package goes back to being imported from the folder [2]. importlib mode doesn't have that problem [2]: with it, both files pass (measured).
5. setup.py or requirements.txt as the only description of the project
pip doesn't read a requirements.txt when installing your package.
For new projects, PyPA's guide asks for the [project] table of pyproject.toml, and to keep setup.py only if something needs configuring with code, like compiling C extensions [13]. And a requirements.txt isn't parsed at install time: it's only used when someone runs pip install -r [14]. If your dependencies live only there, whoever installs your package doesn't get them.
Do you want every machine to install exactly the same versions? For that there's now a standard lock file format, pylock.toml, from PEP 751 [15]. It's another file, for another problem.
| Mistake | What gives it away | Source |
|---|---|---|
| 1. Testing the folder | Green on your machine, ModuleNotFoundError installed | [1][2] |
| 2. pip in the system Python | externally-managed-environment | [9] |
3. A stray random.py | has no attribute on a module that does have it | [12][19] |
| 4. Tests with the same name | import file mismatch | [2][4] |
5. requirements.txt as the only description | Installed, it's missing dependencies | [13][14] |
Exercise: can you repeat Iván's mistake?
You're going to put a file outside the package, watch your folder tell you everything is fine, and watch the installed package tell you it isn't.
You need Python 3.12 or newer (python3 --version), Git and bash, about 20 minutes. Linux Mint 22 comes with 3.12; if python3 -m venv complains that ensurepip is missing, run sudo apt install python3.12-venv and repeat the command (lesson 1 of the course explains it). On macOS, the system python3 is Apple's, «usually older» [24] (here, 3.9.6): install a recent one from python.org. On Windows, use WSL.
Step 1. Clone, set up the environment and check that everything is green.
git clone https://github.com/HabilMX/estructura-python
cd estructura-python
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install --group dev -e .
pytest -q
bash herramientas/probar-instalado.shWhat you should see: 62 passed and, at the end, ✅ lo instalado pasa sus pruebas.
Step 2. Predict before you touch. You're going to create src/exportar.py, outside the package, and a test that imports it. Write down: what will pytest say? And the installed-package script?
Step 3. Repeat the mistake.
cat > src/exportar.py <<'EOF'
def a_csv(filas: list[tuple[str, str]]) -> str:
return "\n".join(",".join(fila) for fila in filas)
EOF
cat > tests/test_exportar.py <<'EOF'
from exportar import a_csv
def test_a_csv() -> None:
assert a_csv([("pagos", "ARRIBA")]) == "pagos,ARRIBA"
EOF
pytest -q
bash herramientas/probar-instalado.shpytest says 63 passed: your folder assures you everything is fine. The script says something else (the part that matters; below it come pytest's output and a ❌):
pruebas: 63 · pasaron 62 · fallaron 0 · con error 1 · saltadas 0
✗ tests.test_exportar: ModuleNotFoundError: No module named 'exportar'That's Sofía's Friday, on your machine.
Step 4. Fix it like Iván: put the file into the package. Copy only the sed line for your system: macOS's needs an empty argument after -i.
mv src/exportar.py src/revisor/exportar.py
# En Linux:
sed -i 's/^from exportar import/from revisor.exportar import/' tests/test_exportar.py
# En macOS:
sed -i '' 's/^from exportar import/from revisor.exportar import/' tests/test_exportar.py
bash herramientas/probar-instalado.shNow you get pruebas: 63 · pasaron 63 and ✅ lo instalado pasa sus pruebas.
Step 5. Try to fool the guard. Ask pytest to demand the installed package, from your editable environment:
REVISOR_EXIGIR_INSTALADO=1 pytestERROR: REVISOR_EXIGIR_INSTALADO=1, pero revisor viene de la carpeta src/revisor. Estas pruebas probarían tu carpeta, no lo que se instala. Usa: bash herramientas/probar-instalado.shIt refused to give you a green. That's the control at work.
Step 6. Put everything back.
git checkout -- . && git clean -fd
bash herramientas/probar-frontera.shWatch out: git clean -fd also deletes any new files you created there, like your notes.
How you know you did it right: you ran seven times and saw three outcomes. Green, five times: the two in step 1, pytest in step 3, step 4 and the boundary in step 6. Red, once: the installed package in step 3, with No module named 'exportar'. And the guard's refusal in step 5. If in step 3 the script comes out green, check that exportar.py ended up in src/ and not in src/revisor/. If in step 4 it's still red, check that the first line of tests/test_exportar.py says from revisor.exportar import a_csv.
To go further (optional): add a_csv to reporte.py, add csv to the --formato options in cli.py and write its test. Before running it, predict what probar-instalado.sh will count.
Key takeaways
Three questions to ask your repository this week.
- Where do your tests run from? If the answer is "from the root, with
python -m pytest", which files at the root can Python see that don't go into the package? - Have you ever run your tests against the installed package, in a fresh environment and without
-e? Do it once. If it breaks, you found a bug before your users did. - Is your project described in
pyproject.toml, or do you still have to read asetup.pyand arequirements.txtto know what it needs?
What's next?
In Go, the boundary is guarded by the tool. In Python, by the installation. In Rust, by the compiler, and it's stricter than you expect.
Two weeks later, Iván got a program in Rust. All the logic lived in a 900-line main.rs. He wanted to write an integration test, the kind that lives outside the code, in tests/.
Cargo found nothing to import. The program worked, but from the outside nobody could touch it.
How do you test a function nobody can import? In installment 3: a file called lib.rs, and why the compiler is giving you a hint, not a punishment.
The template and the course
- Template: github.com/HabilMX/estructura-python. Use it as a GitHub template or clone it [22]. The README explains each folder and when to add each thing. The code is MIT; the text and explanations are CC BY-SA 4.0.
- Versions: it requires Python 3.12 or newer and was tested up to 3.14, on macOS and Linux: with 3.14.9, the latest 3.14 release on October 22 [21], with 3.12.15 and with 3.12.3, Ubuntu 24.04's [11]. Not 3.15 yet. 3.14 is supported until October 2030; 3.12 only gets security patches now, until October 2028 [20]. It uses only the standard library; pytest 9.1.1, ruff 0.17.1 and mypy 2.4.0 are pinned in the development group.
- Hábil's Python course: if
pyproject.toml,python -mor the editable install are new to you, lesson 7 covers them from scratch: Python course · Lesson 7, A package with a command line.
I write from what we see every day at Hábil, where we build systems for banking, insurance and companies in Mexico. If at work or at school you run into a repository nobody knows where to touch, let's talk: habil.mx
What does Hábil do?
We teach programming with the same things we use to build: tested, explained and open code.
This series comes from the same place as our courses. The Python course goes from zero to a first complete program, and we publish it openly, under a Creative Commons license; its lesson 7 covers the package with a command line, pyproject.toml and the editable install that this article's template uses. And the catalog has more programming courses, free and with code tested and explained step by step.
See the Python course and the Programming courses catalog.
If your team inherits a system nobody knows where to touch, or needs to build one that can keep being maintained, we do that too: see Build your custom system. Request your first conversation, at no cost. In it we review whether it makes sense to configure what you already have, use a product or build, and what you would get in each case. Write to us on WhatsApp.
Sources accessed on October 22, 2026. Quotes in English are reproduced as is.
References
- Python Packaging Authority, «src layout vs flat layout», Python Packaging User Guide. https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/
- pytest, «Good Integration Practices», docs.pytest.org (pytest 9.1). https://docs.pytest.org/en/stable/explanation/goodpractices.html
- pytest, «How to invoke pytest»: «Calling pytest through python -m pytest», docs.pytest.org. https://docs.pytest.org/en/stable/how-to/usage.html
- pytest, «pytest import mechanisms and sys.path/PYTHONPATH», docs.pytest.org. https://docs.pytest.org/en/stable/explanation/pythonpath.html
- pip, «Local project installs»: «Editable installs», pip.pypa.io. https://pip.pypa.io/en/stable/topics/local-project-installs/
- Hatch, «Build configuration»: «Packages» and «Dev mode», hatch.pypa.io. https://hatch.pypa.io/latest/config/build/
- Hatch, «Wheel builder»: «Default file selection», hatch.pypa.io. https://hatch.pypa.io/latest/plugins/builder/wheel/
- Python, «Command line and environment»: the
-moption, docs.python.org. https://docs.python.org/3/using/cmdline.html - G. Thomas, M. Klose, F. Laíns, D. Stufft, T. Chung, S. Rivera, E. Hashman and P. Gedam, «PEP 668 – Marking Python base environments as “externally managed”», May 18, 2021. https://peps.python.org/pep-0668/
- Linux Mint, «Linux Mint 22.3 Release Notes» («Linux Mint 22.x is based on Ubuntu 24.04»), linuxmint.com. https://www.linuxmint.com/rel_zena.php
- Launchpad (Canonical), packages published in Ubuntu 24.04 «noble»:
python3-defaults3.12.3 andpython-pip24.0. https://api.launchpad.net/1.0/ubuntu/+archive/primary?ws.op=getPublishedSources&source_name=python3-defaults&exact_match=true · https://api.launchpad.net/1.0/ubuntu/+archive/primary?ws.op=getPublishedSources&source_name=python-pip&exact_match=true - Python, «What's New In Python 3.13»: «Improved error messages», docs.python.org. https://docs.python.org/3/whatsnew/3.13.html
- Python Packaging Authority, «Writing your pyproject.toml», Python Packaging User Guide. https://packaging.python.org/en/latest/guides/writing-pyproject-toml/
- Python Packaging Authority, «install_requires vs requirements files», Python Packaging User Guide. https://packaging.python.org/en/latest/discussions/install-requires-vs-requirements/
- Brett Cannon, «PEP 751 – A file format to record Python dependencies for installation reproducibility», July 24, 2024 (accepted on March 31, 2025). https://peps.python.org/pep-0751/
- Stephen Rosen, «PEP 735 – Dependency Groups in pyproject.toml», November 20, 2023 (accepted on October 10, 2024). https://peps.python.org/pep-0735/
- pip, «Changelog», version 25.1 (April 26, 2025), pip.pypa.io. https://pip.pypa.io/en/stable/news/
- Astral, «Creating projects» (uv), docs.astral.sh. https://docs.astral.sh/uv/concepts/projects/init/
- Python, «The Python Tutorial», 6.1.2 «The Module Search Path», docs.python.org. https://docs.python.org/3/tutorial/modules.html
- Python Developer's Guide, «Status of Python versions». https://devguide.python.org/versions/
- Python Software Foundation, «Python 3.14.9», October 21, 2026. https://www.python.org/downloads/release/python-3149/
- GitHub Docs, «Creating a template repository». https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-template-repository
- GitHub Docs, «Disabling and enabling a workflow». https://docs.github.com/en/actions/how-tos/manage-workflow-runs/disable-and-enable-workflows
- Python, «Using Python on macOS» (§5.1), docs.python.org. https://docs.python.org/3/using/mac.html · Installers: https://www.python.org/downloads/macos/
Does your operation face these challenges?
Prefer email? Write to us at hola@habil.mx