Laying Out a Project
Where files sit decides which imports work: packages, __init__.py, the src layout, and __main__.py for a runnable package.
python -m package_name # imports the package, then runs package_name/__main__.pyA package is a directory Python can import. With an __init__.py it is a regular package; without one it is still importable as a namespace package (PEP 420, since 3.3), which is why a directory you expected to fail sometimes imports successfully and contains nothing. Keep __init__.py small: re-export the names that are the package's surface (from .core import mean, __all__ = ["mean"]) and do no work, because every import of any submodule runs it.
The import that works from one directory and not another
Python prepends a directory to sys.path based on how it was started: running python path/to/script.py prepends that script's directory, while python -m pkg, python -c and the REPL prepend the current working directory. So python src/reporting/core.py puts src/reporting on the path — where import reporting cannot be resolved — and python -m reporting from the project root works. Since 3.11, -P or PYTHONSAFEPATH disables that prepending entirely.
Install it instead of patching sys.path
The fix is not sys.path.insert in your source. Put the package under src/, declare it in pyproject.toml, and run pip install -e . in the environment: imports then resolve the same way from any directory, including from your tests. The src layout also stops an accidental import of the uninstalled copy sitting in the repository root.
Relative and absolute
from .core import mean inside the package and from reporting.core import mean are the same import; the relative form works only when the module is being imported as part of a package, never in a file run directly. Add __main__.py and the package itself becomes runnable with python -m reporting.
Example
When to use it
- Make `import myapp` resolve the same way in tests, in a REPL and in production
- Ship a command-line tool that is also importable as a library
- Split a single long module into a package without changing what callers import
- Work out why a script runs from its own directory and fails from the project root
More examples
A layout that stops surprising you
tests/ sits outside the package so your test files are never shipped and never importable as reporting.tests. The one-way dependency - cli imports core, core never imports cli - is what lets you test the logic without constructing command-line arguments.
reporting/ <- repository root, not a package
|-- pyproject.toml
|-- README.md
|-- requirements.txt <- the lockfile
|-- src/
| `-- reporting/ <- the importable package
| |-- __init__.py <- re-exports only
| |-- __main__.py <- `python -m reporting`
| |-- cli.py <- argument parsing, calls into core
| |-- core.py <- the logic, imports nothing from cli
| `-- adapters/
| |-- __init__.py
| `-- http.py
`-- tests/
|-- conftest.py <- fixtures, auto-discovered by pytest
|-- test_core.py
`-- test_cli.py
# [project] in pyproject.toml plus `pip install -e .` makes
# `import reporting` work from any directory, tests included.__main__.py and the entry point it wraps
main(argv=None) is the detail that makes a CLI testable: a test passes a list and reads the return code, with no subprocess and no sys.argv patching. Keep __main__.py to two lines so there is nothing in it that only runs under python -m.
# src/reporting/cli.py
import argparse
from reporting.core import mean
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="reporting")
parser.add_argument("numbers", nargs="+", type=float)
args = parser.parse_args(argv) # None -> sys.argv[1:]
print(mean(args.numbers))
return 0
# src/reporting/__main__.py
from reporting.cli import main
if __name__ == "__main__":
raise SystemExit(main())
# Three ways to start it, all reaching the same function:
# python -m reporting 1 2 3 <- works from the project root
# reporting 1 2 3 <- [project.scripts] after pip install
# from reporting.cli import main; main(["1", "2", "3"]) <- from a testThe failure, and why it is not a Python bug
Both errors are the same fact: the directory containing the package was never on sys.path. Export PYTHONPATH=src and the commands work too, but then every shell, cron job and CI step needs that variable set - installing the project records it once, in the environment, where it belongs.
$ cd src/reporting
$ python core.py # sys.path[0] = src/reporting
Traceback (most recent call last):
File "src/reporting/core.py", line 1, in <module>
from reporting.adapters.http import fetch
ModuleNotFoundError: No module named 'reporting'
$ cd ../..
$ python -m reporting # sys.path[0] = '' (cwd); still no src/ on it
ModuleNotFoundError: No module named 'reporting'
$ python -m pip install -e . # declares src/ as the package root, once
$ python -m reporting 1 2 3
2.0
$ cd /tmp && python -m reporting 1 2 3 # and from anywhere
2.0
Discussion