Modules: splitting a script across files without breaking it
Every .py file is already a module
Put this in money.py:
TAX_RATE = 0.18
def with_tax(amount):
return round(amount * (1 + TAX_RATE), 2)And in main.py, in the same folder:
import money
print(money.with_tax(100)) # 118.0That is the whole mechanism. No registration, no build step. The file name without .py is the module name.
Three ways to bring things in:
import money # money.with_tax(...)
from money import with_tax # with_tax(...)
import money as m # m.with_tax(...)The first keeps the source of the name visible at every call, which is worth more than the characters it costs. The second is fine for a handful of names you use constantly. Avoid from money import *: it dumps every public name into your file, so nobody can tell where anything came from, and a new name in money can silently shadow one of yours.
What import actually does
The first time a module is imported, Python finds the file, executes the whole file top to bottom, and stores the resulting module object in sys.modules. Every later import of the same name finds it there and does not re-run anything.
Two consequences.
Top-level code runs on import. If money.py ends with print(with_tax(100)), that line runs the moment anybody imports the module — including a test runner and a documentation tool. Which is why every script that is also importable ends with:
def main():
print(with_tax(100))
if __name__ == "__main__":
main()__name__ is "__main__" when the file is the one you ran, and the module's own name when it was imported. The guard means "only do this when I am the program, not when I am a library".
Re-importing does not pick up edits. In a long-running REPL or notebook, editing money.py and re-running import money changes nothing, because the module is cached. Restart the interpreter, or use importlib.reload(money), which has enough caveats that restarting is usually quicker.
Where Python looks
sys.path is the list of folders searched, in order: the folder containing the script you ran, then anything in the PYTHONPATH environment variable, then the installed packages of the current environment.
import sys; print(sys.path)Two failures come straight out of that list.
ModuleNotFoundError for your own file usually means you ran Python from a different folder than the one the file is in. The first entry of sys.path is the script's folder, not your current directory — so python3 tools/run.py can import things next to run.py but not things next to you.
Your file shadows a real module. Name a file random.py, json.py, email.py or types.py and your file wins, because the script's own folder is searched first. The error that follows is bizarre — AttributeError: module 'random' has no attribute 'randint' — and the fix is to rename your file and delete the __pycache__ folder that still holds the compiled shadow.
Circular imports
a.py imports b, and b.py imports a. The second import finds a half-executed module and fails with ImportError: cannot import name 'x' from partially initialized module.
This is a design signal, not a puzzle to solve. Either the two modules are really one, or a third module holds what they share. Moving the import inside the function that needs it defers the problem and works, and it is a patch rather than a fix.
Two commands that end most import arguments
import money
print(money.__file__) # exactly which file got loaded
print(dir(money)) # every name the module defines__file__ settles "which version am I running" in one line — the answer is frequently a copy in a different folder, or a package installed in another environment. dir() shows what is actually available, which beats guessing at a name from a tutorial written for an older release.
The standard library is large and already installed
Before adding a dependency, check whether Python ships it: json, csv, pathlib, datetime, re, math, random, collections, itertools, statistics, sqlite3, urllib, unittest, logging, argparse, subprocess, zipfile, hashlib, secrets. A working SQL database, a JSON parser and a web client all come free with the interpreter.
Every dependency you do not add is one you never have to upgrade, audit or explain.
Try this now
Split any script you have written into two files: one with the functions, one that imports them and runs. Add the __name__ guard to both. Then rename the function file to json.py and run it, so you see the shadowing failure once, on purpose, when it is harmless.
The one thing to keep
An import executes the module once and caches it, and Python searches the script's own folder first, which is why naming a file `json.py` breaks the real one.
Before you move on
A colleague adds `analysis.py` to a project folder and the previously working `import numpy` in another file starts failing with an AttributeError deep inside numpy. They swear they changed nothing else. What is the first thing to check?
Pick the one you would defend. Nobody sees your answer.