Emmanuel Corels
← Blog
Automation & Scripting

Stop Python imports from running your command-line program

Move side effects behind a small main function, return process status cleanly, keep imports testable, and prevent multiprocessing spawn loops across platforms.

By Emmanuel Corels

A test imports report.py to call one formatter. Instead, the module parses the test runner’s arguments, connects to production and starts generating a report. Python executes module-level statements during import. The main guard is the boundary that says which behavior belongs only to direct program execution.

Observe the two names

# identity.py
print(f"name={__name__!r}")

$ python identity.py
name='__main__'

$ python -c 'import identity'
name='identity'

The first user-specified module running in the top-level environment receives the name __main__. An imported module receives its import name. Function and class definitions still execute in the sense that Python creates those objects, but their bodies do not run until called.

Put behavior in functions, not merely under a large guard

# report.py
from __future__ import annotations

import argparse

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser()
    parser.add_argument("--customer", required=True)
    parser.add_argument("--dry-run", action="store_true")
    return parser

def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    generate_report(args.customer, dry_run=args.dry_run)
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

Keep the guarded block tiny. Local variables inside main cannot accidentally become mutable module globals, and tests can call main([...]) without replacing sys.argv.

Understand what the guard does not protect

DB = connect_to_database()       # still runs on import
CONFIG = load_remote_config()    # still runs on import

def main():
    ...

The guard only controls statements inside its suite. Imports should generally define constants, functions and classes—not open sockets, create threads, mutate files, parse command-line arguments or read secrets. Some module initialization is legitimate, but it should be deterministic and cheap.

Make import safety a testable property

python -I -c 'import report; print("imported")'

python -X importtime -c 'import report' 2> import-time.txt

pytest -q

The isolated -I mode reduces influence from user site packages and environment path settings. The import-time report helps find heavyweight dependencies, but speed alone does not prove safety. Run the import with network denied and a temporary read-only working directory in CI where practical.

Return exit status through SystemExit

def main(argv: list[str] | None = None) -> int:
    try:
        return run(argv)
    except ConfigurationError as error:
        print(f"configuration error: {error}", file=sys.stderr)
        return 2

if __name__ == "__main__":
    raise SystemExit(main())

Returning an integer keeps main callable. SystemExit turns it into the process status. Returning a string to sys.exit prints the string to standard error and signals failure, which can surprise code that treats it as normal output.

Prevent recursive multiprocessing startup

from multiprocessing import get_context

def transform(path: str) -> str:
    return path.upper()

def main() -> int:
    context = get_context("spawn")
    with context.Pool(processes=4) as pool:
        for result in pool.map(transform, ["a.csv", "b.csv"]):
            print(result)
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

With spawn, a child starts a fresh interpreter and imports the main module to locate callables. If pool creation happens at module level, every child tries to create more children and Python raises an error or spirals. Current Python multiprocessing documentation explicitly requires safe import of the main module.

Keep worker targets importable

# Good: module-level function
def transform(path: str) -> str:
    return path.upper()

# Fragile for spawn/pickling:
def main():
    def nested_transform(path: str) -> str:
        return path.upper()

Workers need to locate serialized callables by module and name. Prefer module-level functions or methods on importable classes. Do not pass open database connections, locks or request contexts unless the multiprocessing API explicitly supports their serialization and lifecycle.

Use freeze_support only for the deployment that needs it

from multiprocessing import freeze_support

if __name__ == "__main__":
    freeze_support()
    raise SystemExit(main())

Python documents freeze_support() for programs frozen into executables that use multiprocessing. It is harmless in ordinary runs, but it does not replace the main guard or fix non-picklable workers.

Separate CLI adaptation from reusable logic

def generate_report(customer_id: str, *, dry_run: bool) -> Report:
    """Pure application boundary: no argument parsing."""
    ...

def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    report = generate_report(args.customer, dry_run=args.dry_run)
    print(report.location)
    return 0

The command line is one adapter. A web job, scheduler and unit test can call the application function without pretending to be a shell process. Inject configuration and clients rather than reading global environment throughout the logic.

Test the CLI at two levels

def test_main_passes_customer(capsys):
    status = report.main(["--customer", "cus_42", "--dry-run"])
    assert status == 0

def test_module_entry_point():
    completed = subprocess.run(
        [sys.executable, "-m", "reports", "--customer", "cus_42", "--dry-run"],
        text=True,
        capture_output=True,
        check=False,
    )
    assert completed.returncode == 0

The first test isolates parsing and application wiring. The subprocess test verifies packaging, standard streams and exit status. Never call live external systems in an import smoke test.

Give a package a minimal __main__.py

# reports/__main__.py
from .cli import main

raise SystemExit(main())
python -m reports --customer cus_42 --dry-run

Python’s documentation recommends keeping package __main__.py minimal and delegating to an importable function. Console-script entry points created by packaging tools can point to the same function.

Avoid importing a file by path under multiple names

Running python report.py loads the file as __main__. Importing report from that same process can execute the file a second time under a different module name. Use package execution with python -m package.module, consistent imports and a single entry-point module rather than manipulating sys.path.

python -m reports.cli --customer cus_42

Audit an existing script before adding the guard

  • Move argument parsing, logging setup and environment validation into main.
  • Move network, filesystem and process creation behind callable boundaries.
  • Keep worker functions importable for spawn-based multiprocessing.
  • Return explicit status codes and send diagnostics to standard error.
  • Add an import smoke test plus a subprocess entry-point test.
  • Check schedulers and wrappers still invoke the intended module.

The main guard does not make imports safe by magic. It gives you a precise place to put execution-only behavior so the rest of the module can remain reusable, testable and spawn-safe.

Sources: the solved Stack Overflow explanation (question by Devoted; accepted answer by Mr Fooz, CC BY-SA), and Python’s official __main__ documentation and multiprocessing safe-import guidance.

Primary source: Review the official reference ↗