Skip to main content
Learning Center
Python Programming

Module 10: Automation and Reliable Scripts

Modules, Packages, and an Isolated Environment

Splitting a project across importable files, and giving it a dependency environment of its own so it runs the same way tomorrow and on somebody else's machine.

Lesson 31 of 46 in the recommended order · About 25 min (estimate)

On this page

Outcome

By the end of this lesson you can split a project across several importable files and say exactly what an import runs, and you can create an environment that pins the project's dependencies so it behaves the same on any machine.

Why it matters

The review assistant is now more code than fits comfortably in one file, and it is about to acquire dependencies. Both bring the same class of problem: something that works here and not there.

An import that runs code as a side effect turns "I imported a helper" into "I made a network call". A dependency installed globally means the project works on your machine, breaks on a colleague's, and breaks on yours the day another project needs a different version of the same library. Neither is difficult to prevent and both are unpleasant to diagnose afterwards.

Concept

A module is one .py file. A package is a directory of modules. import matching runs matching.py top to bottom, once per process, and binds the module object to the name.

That word "runs" is the important one. Anything at the top level of a module executes on import: a print, a file read, a database connection, a network call. Only definitions, def and class, and cheap constants belong at the top level. Work belongs inside a function that something calls deliberately.

The guard for a file that is both importable and runnable:

def main():
    ...


if __name__ == "__main__":
    main()

__name__ is "__main__" when the file was run directly and the module's own name when it was imported. Without the guard, importing a script to reuse one function also runs the whole script.

Prefer absolute imports from the project root, from opportunity.matching import qualifies, over relative ones. They read the same wherever they appear and do not change meaning when a file moves.

A virtual environment is a directory with its own Python executable (copied or linked, depending on the platform), configuration, and package directory. Create it with python -m venv .venv, activate it, and commands such as python -m pip install ... then target that environment rather than the system Python.

python -m venv .venv
source .venv/bin/activate          # macOS and Linux
.venv\Scripts\activate             # Windows
pip install requests
pip freeze > requirements.txt

Two habits improve repeatability. Verify activation before installing anything, because installing into the system interpreter succeeds and looks identical until the project moves. And pin versions: requirements.txt produced by pip freeze records exact installed versions. Pins do not by themselves guarantee identical builds across operating systems or package-index changes; stronger reproducibility can also require environment markers, hashes, and testing the rebuild on each supported platform.

Add .venv/ to .gitignore before the first commit. It is large, entirely reconstructible from requirements.txt, and specific to one machine and operating system. Getting it out of a repository afterwards is far more work than keeping it out.

This lesson's task runs on your own machine. Pyodide has a temporary virtual filesystem and can install some compatible packages with micropip, but it has no conventional operating-system shell, cannot create a normal platform virtual environment, and cannot directly manage the host machine's project files. Simulating venv and pip here would therefore teach the wrong workflow.

Read the code

Two files. First matching.py:

"""Matching rules for opportunity records."""

MINIMUM_VALUE = 100000

print("matching module loaded")


def qualifies(record, minimum=MINIMUM_VALUE):
    return record.get("amount", 0) >= minimum

Then report.py:

from matching import qualifies

BATCH = [{"notice_id": "A-1", "amount": 300000}, {"notice_id": "A-2", "amount": 40000}]


def main():
    selected = [record for record in BATCH if qualifies(record)]
    print(f"{len(selected)} of {len(BATCH)} qualify")


if __name__ == "__main__":
    main()

matching.py has one line of real work at the top level, the print, and it is there to be noticed. It runs when the module is imported, not when qualifies is called, which is why a top-level side effect is a design decision rather than an implementation detail.

report.py defines main and calls it only under the guard, so importing report from a test gets the function without running the report.

Trace the two entry paths after making your prediction

Two sibling files, two entry paths

Project folder: the two worked files

matching.py

Defines qualifies; also prints at import time.

report.py

Imports qualifies; defines main and guards its call.

Dependency
report.py → matching.py

Both paths below use these same files. Each begins in its own fresh Python process, with neither module already imported.

Direct run: python report.py

  • report.py starts

    Its __name__ is "__main__".

  • imports qualifies from

    matching.py executes

    Prints "matching module loaded"; defines qualifies.

Back in report.py: guard is True

Call main() → print "1 of 2 qualify"

The import-time print happens before the report output.

Import: import report

  • report.py is imported

    Its __name__ is "report".

  • imports qualifies from

    matching.py executes

    Prints "matching module loaded"; defines qualifies.

Back in report.py: guard is False

main is defined, but not called

The guard skips only its guarded call. Imports and other top-level statements still run.

These are alternative fresh-process paths, not consecutive runs. A normal repeat import reuses a cached module; explicit reloading is a different operation. The guard does not silence matching.py’s top-level print.

Predict the output

Predict what python report.py prints, and in what order.

Check your prediction
matching module loaded
1 of 2 qualify

The import line runs before anything in report.py's own body, so the module-level print in matching.py comes first. Nothing in report.py asked for that output; it arrived because a module was imported.

Now imagine that line were a database connection or an API call instead. A test that imports matching to check one pure function would open a connection, and the failure would appear to come from the test framework.

Modify the code

Remove the if __name__ == "__main__": guard from report.py and call main() directly at the top level. Predict what a test file containing from report import main now prints when it is collected.

What changes, and why

It prints matching module loaded and 1 of 2 qualify during collection, before a single test runs.

The import ran the whole report. With a report that only prints, this is noise. With a report that writes a CSV, updates a database, or sends a request, importing the module performs the action, and a test suite that imports every module performs all of them.

That is why the guard is standard practice rather than a convention. It is the line that separates "this file can be run" from "this file does something when looked at".

Debug the bug

A colleague reports that the project works for them and fails for you with ModuleNotFoundError: No module named 'requests'. They send this setup transcript.

python -m venv .venv
pip install requests
pip freeze > requirements.txt
python report.py
What's actually wrong

The environment was created and never activated. Every command after the first ran against the system Python.

pip install requests installed into the system interpreter, which is why it works on their machine: the package really is installed, just not where the project thinks. pip freeze then wrote out every package on that system, so requirements.txt is both incomplete for the project and full of unrelated entries. And .venv sits there containing nothing.

The corrected sequence, with the verification step that would have caught it immediately:

python -m venv .venv
source .venv/bin/activate
python -c "import sys; print(sys.prefix)"
pip install requests
pip freeze > requirements.txt

sys.prefix prints the root of the interpreter actually running. If it does not point inside .venv, nothing installed afterwards goes where you think.

Two related habits. Use python -m pip install ... rather than a bare pip, so the installer belongs unambiguously to the interpreter you just invoked. And be suspicious of any requirements.txt with fifty entries for a project with three imports; that is the signature of a freeze taken outside an environment.

Try it yourself

This task runs on your own machine, because a conventional virtual environment needs the host Python installation, project filesystem, and shell that this browser runner deliberately does not expose.

  1. In a new empty directory, run python -m venv .venv.
  2. Activate it: source .venv/bin/activate on macOS or Linux, .venv\Scripts\activate on Windows.
  3. Verify it: python -c "import sys; print(sys.prefix)" must print a path inside .venv.
  4. Install one package you are curious about with python -m pip install <name>.
  5. Run python -m pip freeze > requirements.txt and read the file.
  6. Create a .gitignore containing .venv/, run git init and git status, and confirm .venv is not listed.

Record your attempt below once you have completed step 6, or if you got stuck; both are useful.

Sign in to track your progress on this exercise.

Practical challenge (optional)

Optional: delete .venv entirely, recreate it, activate it, and run python -m pip install -r requirements.txt. Confirm the project runs again. This is the rebuild a colleague, a build server, or you-in-six-months will perform, and it is the only way to find out whether requirements.txt is actually complete. A file that has never been used to rebuild an environment is a file nobody has tested.

Sign in to track your progress on this exercise.

AI collaboration

Checkpoint

  1. What actually happens when Python executes import matching?
  2. What does the if __name__ == "__main__": guard prevent?
  3. Why verify sys.prefix before installing anything?
  4. Why does .venv/ belong in .gitignore?
Answers
  1. It runs the module's file top to bottom, once per process, and binds the resulting module object to the name. Every top-level statement executes, including side effects.
  2. It stops a file's work from running when the file is imported rather than executed directly, so tests and other modules can reuse its functions without triggering the whole script.
  3. Because installing into the system interpreter succeeds and looks identical to installing into the environment. sys.prefix is the only quick check that says which interpreter is actually running.
  4. It is large, machine- and platform-specific, and fully reconstructible from requirements.txt. Committing it bloats the repository and removing it from history later is significant work.

Sign in to track your progress on this exercise.

Summary and next step

Importing runs a module, so keep work out of the top level and behind the __main__ guard; create an environment, verify it before installing, pin what you install, and keep it out of version control. Next: giving the script arguments, configuration, and logs that say what happened without saying what your credentials are.

learning.goultergroup.com

The interactive parts of this page have not loaded. Reading and links still work; reload the page to try again.