Python Modules & Packaging

Modules, Packages & Project Layout

How import actually works, how packages are put together, and how to lay out a project other people can install.

Python 3 ✓ Essential 4 Topics Interview Key
01

Modules & the import Statement

A module is just a .py file. Importing it runs the file once, top to bottom, and binds the resulting namespace to a name you can reach through.

Python — the four import forms
import math                    # math.pi
import numpy as np             # np.array — an alias
from math import pi, sqrt       # pi, sqrt — bound directly
from math import sqrt as root  # root

from math import *             # avoid: hides where a name came from

The import runs the module body once per process. Python caches it in sys.modules, so a second import of the same module is a dictionary lookup, not a re-execution. That is why a print statement at module level fires once however many files import it.

Python — imports are cached
import sys
import json
import json                    # no re-execution

sys.modules['json']              # the same module object both times
json.__file__                   # where it was loaded from
Because module bodies execute on import, expensive work at module level — reading a file, opening a connection — runs at import time, for every consumer, whether they need it or not. Keep the body to definitions and put the work in a function.
02

Packages & __init__.py

A package is a directory of modules. __init__.py marks it as one and runs when the package is first imported — which makes it the place to present a public API, and the place people accidentally put slow work.

Project layout
myapp/
├── __init__.py         # runs on: import myapp
├── config.py
├── models/
│   ├── __init__.py
│   ├── user.py
│   └── order.py
└── services/
    ├── __init__.py
    └── billing.py
Python — myapp/models/__init__.py
# Re-export so callers write the short form
from .user import User
from .order import Order

__all__ = ['User', 'Order']     # what 'from myapp.models import *' gives

# now: from myapp.models import User
# instead of: from myapp.models.user import User

Absolute imports name the full path from the project root; relative imports use leading dots to mean "this package" and "the one above it".

Python — absolute vs relative
from myapp.models.user import User     # absolute — clear, and PEP 8 prefers it

from .user import User                 # same package
from ..config import SETTINGS          # one package up

# Relative imports only work inside a package — running the file
# directly gives: ImportError: attempted relative import with no known parent package
Since Python 3.3 a directory without __init__.py still imports, as a namespace package. That is a feature for splitting one package across distributions — but as an accident it silently changes behaviour, so keep the __init__.py unless you specifically want the other thing.
03

How Python Finds a Module

On import x, Python checks sys.modules first, then walks sys.path in order and takes the first match. sys.path starts with the script's own directory, then PYTHONPATH, then the installed site-packages.

Python — inspecting the path
import sys
for p in sys.path:
    print(p)

# 1. the directory of the script you ran (or cwd for -m)
# 2. anything in the PYTHONPATH environment variable
# 3. the standard library
# 4. site-packages — where pip installs
Shadowing: because your own directory comes first, a file named random.py or json.py next to your script wins over the standard library, and every later import of that name gets your file. The symptom is an AttributeError on a function that certainly exists. Rename the file and delete the stale __pycache__.

A circular import is two modules importing each other. The second import finds a half-executed module in sys.modules and gets a name that does not exist yet.

Python — the circular case and three fixes
# a.py
from b import helper          # b runs, and b imports a, which is half-built

# 1. import the module, not the name — resolution is deferred
import b
b.helper()

# 2. import inside the function that needs it
def run():
    from b import helper
    return helper()

# 3. the real fix: move the shared thing into a third module
04

Structuring & Packaging a Project

Once a project outgrows one file, the layout below is the one the ecosystem expects. Keeping the package inside src/ stops your working directory from shadowing the installed copy, so tests exercise what users actually get.

Project — the src layout
myproject/
├── pyproject.toml      # metadata, dependencies, build config
├── README.md
├── src/
│   └── myapp/
│       ├── __init__.py
│       └── main.py
└── tests/
    └── test_main.py
pyproject.toml
[project]
name = "myapp"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["requests>=2.31", "pydantic>=2"]

[project.scripts]
myapp = "myapp.main:cli"      # installs a `myapp` command

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Shell — install and build
pip install -e .        # editable: your edits take effect without reinstalling
python -m build         # produces dist/*.whl and dist/*.tar.gz

# A wheel is the built, installable artefact; the .tar.gz is the source
# distribution. pip prefers the wheel because it needs no build step.

Run the package with python -m myapp rather than by path: -m puts the current directory on sys.path and executes the module as part of its package, so relative imports resolve. Running python src/myapp/main.py directly is what produces "attempted relative import with no known parent package".

Pin your dependencies for applications and keep them loose for libraries. An application wants a reproducible install; a library that pins exactly will eventually conflict with everything else in the user's environment.
05

Quick Quiz

Five questions on imports, packages and layout.

1. You import the same module twice in one process. What happens the second time?
2. A file named random.py sits next to your script. What breaks?
3. What does __init__.py do?
4. Two modules import each other and you get an ImportError on a name that exists. Best fix?
5. Why run a package with python -m myapp instead of python src/myapp/main.py?