Modules and Imports — Organising Your Code

8 min read

Once you've got more than one or two .py files, you need a way to share code between them. Python's mechanism is the module — every .py file is one — and the package — a folder of related modules. Imports stitch them together. This lesson covers the four import shapes you'll write daily, the difference between a module and a package, the role of __init__.py, the if __name__ == "__main__": idiom, and how to organise a real test project.

A module is just a file

Any .py file is a module. Save this as helpers.py:

# helpers.py
 
def generate_email(base: str = "qa") -> str:
    from datetime import datetime
    stamp = datetime.now().strftime("%Y%m%d%H%M%S")
    return f"{base}+{stamp}@test.com"
 
DEFAULT_TIMEOUT = 5

Now from another file in the same folder:

# main.py
import helpers
 
print(helpers.generate_email())
print(helpers.DEFAULT_TIMEOUT)

import helpers runs helpers.py once and exposes its public names under the helpers namespace. That's the whole module system at the simplest level.

The four import shapes

import json                                # whole module — call as json.loads(...)
from pathlib import Path                   # one name from a module
from datetime import datetime, timedelta   # multiple names from a module
import requests as req                     # imported with an alias

Each shape has its place:

  • import module — the safest. The module's name still appears at every call site, so a reader knows where each function comes from.
  • from module import name — the most readable in tight code. Beware of name collisions with your own variables.
  • from module import a, b, c — same idea, multiple names. Long lists are fine.
  • import module as alias — useful when the module's name is long (numpy as np, pandas as pd) or collides with a local name. Don't alias for sport — requests as req saves three letters and confuses readers.

One more form to know about and rarely use: from module import *. It dumps every public name into the current namespace. Avoid it — it makes "where did this come from?" much harder to answer.

A package is a folder

A package is a folder containing an __init__.py file plus other modules. The __init__.py can be empty — its presence is the signal that the folder is importable as a package:

my_tests/
├── __init__.py
├── helpers/
│   ├── __init__.py
│   ├── data_factory.py
│   └── api_client.py
├── pages/
│   ├── __init__.py
│   ├── login_page.py
│   └── product_page.py
└── tests/
    └── test_login.py

From tests/test_login.py:

from helpers.api_client import ApiClient
from pages.login_page import LoginPage

The dot in helpers.api_client reads "the api_client module inside the helpers package." Mirrors the folder structure exactly.

Modern Python (3.3+) also supports namespace packages — folders without __init__.py. They work in many cases. The convention is still to include the file: explicit, compatible with older tools, and a place to put package-level setup.

What goes in __init__.py

Three common shapes:

# 1. Empty — just declares the folder a package. The most common form.
# 2. Re-export the package's public surface
from .api_client import ApiClient
from .data_factory import create_user
 
__all__ = ["ApiClient", "create_user"]
# 3. Run package-level setup (logging config, etc.)
import logging
logging.getLogger(__name__).addHandler(logging.NullHandler())

The second form lets users from helpers import ApiClient instead of the longer from helpers.api_client import ApiClient. Useful for libraries you publish; for an internal test project it's optional.

Relative vs absolute imports

Inside a package, you can write imports relative to the package's location using a leading dot:

# pages/login_page.py
from .base_page import BasePage             # same package
from ..helpers.api_client import ApiClient  # parent package

Or fully absolute:

from pages.base_page import BasePage
from helpers.api_client import ApiClient

Both work. Prefer absolute imports — they survive when you move files around, and they read identically to imports that come from third-party packages. Use relative imports only when a deep path becomes unwieldy.

if __name__ == "__main__": — script vs module

When Python runs a file directly (python my_script.py), the special variable __name__ is set to "__main__". When the same file is imported by another, __name__ is set to the module's name. The idiomatic guard:

def run_smoke_tests():
    print("running smoke tests...")
    # ...
 
if __name__ == "__main__":
    run_smoke_tests()

This file behaves two ways:

  • Run it directly: python smoke.py → the if __name__ == "__main__": block fires and run_smoke_tests() runs.
  • Import it: from smoke import run_smoke_tests → the function is defined, but the if block does not run. Useful for unit-testing helpers without re-executing the whole script.

Memorise this idiom. Every script that's also importable should have one.

Where Python looks for imports

import helpers triggers a search path. Python checks each location in order:

  1. The directory containing the running script (or the current working directory when in the REPL).
  2. PYTHONPATH environment variable, if set.
  3. The standard library (json, os, pathlib, …).
  4. Site-packages — the directory pip installs third-party libraries into. Inside a venv this points at the venv's own site-packages, which is what isolation means in practice.

The first match wins. If your project has a file named json.py next to your script, import json will pick yours and break in confusing ways. Don't shadow standard-library names.

python -c "import sys; print(sys.path)" prints the current search path — useful when "module not found" doesn't make sense.

Circular imports — the one trap

Module A imports from B. B imports from A. The first one to load tries to access something in the other before that other has finished loading — ImportError.

Two fixes, both architectural:

  • Restructure so A and B don't both depend on each other. Often the shared piece belongs in a third module both can import.
  • Defer the import to inside a function: def f(): from a import x — the import runs only when f is called, by which time both modules have finished loading.

If you find yourself reaching for the second fix often, the codebase is signalling that some module is doing too much. Listen.

A real test-project layout

my_tests/
├── conftest.py                     # pytest config / shared fixtures
├── pyproject.toml                  # project metadata
├── requirements.txt                # dependencies (next lesson)
├── helpers/
│   ├── __init__.py
│   ├── api_client.py
│   ├── data_factory.py
│   └── errors.py                   # custom exception hierarchy
├── pages/
│   ├── __init__.py
│   ├── base_page.py
│   ├── login_page.py
│   └── product_page.py
└── tests/
    ├── __init__.py
    ├── test_login.py
    └── test_products.py

Three packages, four levels of organisation, each file responsible for one concept. From tests/test_login.py:

from helpers.api_client import ApiClient
from helpers.data_factory import create_user
from helpers.errors import TestDataError
from pages.login_page import LoginPage
 
def test_login_succeeds(playwright_page):
    user = create_user(role="admin")
    LoginPage(playwright_page).login(user["email"], user["password"])
    # ... assertions ...

The test reads as test logic; everything else lives behind imports. That's the payoff of organising by purpose.

How an import resolves

That cache step is why import helpers is cheap to do twice — Python only executes the module the first time. Repeated imports just re-bind names in the local namespace.

⚠️ Common mistakes

  • Naming a file the same as a stdlib module. json.py, email.py, random.py, requests.py next to your script will shadow the real library — and the error you get (AttributeError: module 'json' has no attribute 'loads') doesn't say why. Don't reuse stdlib names for your modules.
  • Forgetting the if __name__ == "__main__": guard. Without it, importing your script for a unit test runs all the top-level code (sometimes including a real API call). Wrap the "run this script" code in the guard so importing has no side effects.
  • Wildcard imports in production code. from helpers import * makes it impossible to tell at a glance where a name came from, and breaks when helpers adds a new public name that collides. Reserve * imports for the REPL or for __init__.py re-exports.

🎯 Practice task

Reshape a flat script into a package. 25-30 minutes.

  1. Create a folder my_tests/. Inside it create helpers/ and pages/ subfolders. Add an empty __init__.py in each (and one in my_tests/ too).

  2. In helpers/data_factory.py, define def create_user(name="QA Test", role="tester") -> dict: returning a user dict (use f"{name.lower()}@test.com" for the email).

  3. In helpers/errors.py, define class TestFrameworkError(Exception): pass plus a subclass TestDataError.

  4. In pages/base_page.py, define class BasePage: with __init__(self, page) and navigate(self, path). In pages/login_page.py, class LoginPage(BasePage): with a login(self, email, password) method (just print what would happen — no real Playwright).

  5. In my_tests/run_smoke.py, write:

    from helpers.data_factory import create_user
    from helpers.errors import TestDataError
    from pages.login_page import LoginPage
     
    def main():
        user = create_user("Alice", "admin")
        page = "PLAYWRIGHT-PAGE-PLACEHOLDER"
        LoginPage(page).navigate("/login")
        LoginPage(page).login(user["email"], "secure")
     
    if __name__ == "__main__":
        main()
  6. Run it from the project root: cd my_tests && python run_smoke.py. Confirm no ModuleNotFoundError.

  7. Edit helpers/__init__.py to re-export key names: from .data_factory import create_user. Now from helpers import create_user works directly. Update run_smoke.py to use the shorter form.

  8. Add a from helpers import create_user line in a new file tests/test_login.py and write a one-liner that prints create_user("Bob"). Run it to confirm packages and modules are wired up.

  9. Stretch: induce a circular import on purpose. Have helpers/data_factory.py import from pages/base_page.py and pages/base_page.py import from helpers/data_factory.py. Run and read the ImportError. Fix it by moving the shared bit (probably a constant) into a third module that both import from.

You can now structure a real Python project that scales beyond a single file. The next lesson covers the bookend to all of this: virtual environments, pip, and a clean requirements.txt so the dependency landscape stays predictable.

// tip to track lessons you complete and pick up where you left off across devices.