Multi-File Projects

The Course
•
January 11, 2026
•
7 min read

A single file is fine up to a few hundred lines. Past that, finding anything becomes the hard part. Splitting a program into modules is mostly about making it navigable.

Warm-up: from Days 1 to 6

The last warm-up of the week, from memory first. It mixes days deliberately: the capstone at the end of today uses all of them at once.

1. Predict both lines.

Warm-up: predict Python
from dataclasses import dataclass

@dataclass
class Habit:
    name: str
    minutes: list[int]

    def total(self):
        return sum(self.minutes)

habits = [Habit("read", [20, 30]), Habit("walk", [15]), Habit("code", [60, 45, 30])]
best = max(habits, key=lambda h: h.total())
print(best.name, best.total())
print([h.name for h in habits if len(h.minutes) > 1])
Check your prediction
code 135
['read', 'code']

key= can call a method: each habit is compared by its total. The comprehension keeps the habits with more than one session.

2. Fix. Two playlists should each have their own songs: this should print 1 0.

Warm-up: fix Python
class Playlist:
    songs = []

    def __init__(self, name):
        self.name = name

    def add(self, song):
        self.songs.append(song)

a = Playlist("gym")
b = Playlist("sleep")
a.add("Run")
print(len(a.songs), len(b.songs))
Check your fix
class Playlist:
    def __init__(self, name):
        self.name = name
        self.songs = []

    def add(self, song):
        self.songs.append(song)

a = Playlist("gym")
b = Playlist("sleep")
a.add("Run")
print(len(a.songs), len(b.songs))
1 0

A list in the class body is one list shared by every instance. Built in __init__, each playlist gets its own.

3. Write summary(expenses): given a dictionary of category → list of amounts, it returns a dictionary of category → total. This should print {'food': 20.75, 'travel': 30}:

Warm-up: write Python
# def summary(expenses): ...

print(summary({"food": [12.5, 8.25], "travel": [30]}))
Check your answer
def summary(expenses):
    return {category: sum(amounts) for category, amounts in expenses.items()}

print(summary({"food": [12.5, 8.25], "travel": [30]}))
{'food': 20.75, 'travel': 30}

A dictionary comprehension over .items(): same keys, a new value for each. A loop that fills an empty dictionary is just as good.

Several files, one program

Every playground on this page holds several files — use the tabs above the editor to move between them. Run always executes the entry file, marked with a dot.

Two files

A module beside the program Python
from tax import with_tax, TAX_RATE prices = [19.99, 4.50, 120.00] print(f"Tax rate: {TAX_RATE:.0%}") for price in prices: print(f"{price:8.2f} -> {with_tax(price):8.2f}") """Tax calculations, in one place.""" TAX_RATE = 0.2 def with_tax(amount, rate=TAX_RATE): """Return amount including tax, rounded to cents.""" return round(amount * (1 + rate), 2) def without_tax(amount, rate=TAX_RATE): """Return the pre-tax amount of a tax-inclusive price.""" return round(amount / (1 + rate), 2)

from tax import with_tax looks for tax.py in the same folder. Everything defined at the top level of that file — functions, classes, constants — is importable.

Packages

A folder with an __init__.py is a package: a module that contains other modules. It lets you group related files and gives the group a name.

A package with three modules Python
from shop import Cart from shop.pricing import with_tax cart = Cart("Ada") cart.add("keyboard", 49.99) cart.add("mouse", 25.50) print(cart) print(f"Subtotal: {cart.subtotal():.2f}") print(f"With tax: {with_tax(cart.subtotal()):.2f}") """The shop package. Re-exporting Cart here lets callers write `from shop import Cart` instead of `from shop.cart import Cart`. """ from .cart import Cart __all__ = ["Cart"] from dataclasses import dataclass, field @dataclass class Cart: customer: str items: list = field(default_factory=list) def add(self, name, price): self.items.append((name, price)) def subtotal(self): return sum(price for _, price in self.items) def __str__(self): return f"Cart({self.customer}, {len(self.items)} items)" TAX_RATE = 0.2 def with_tax(amount, rate=TAX_RATE): return round(amount * (1 + rate), 2)

Note from .cart import Cart inside __init__.py — the leading dot means “from this package”, a relative import. Use relative imports between modules of the same package, and absolute imports (from shop import Cart) from outside it.

The main guard

Code at the top level of a module runs when it is imported. That is fine for definitions, and a problem for anything that acts.

Import should not run your program Python
import greeter print("main.py is running") print(greeter.greet("Ada")) def greet(name): return f"Hello, {name}!" print("this line runs the moment greeter is imported") if __name__ == "__main__": # Only when greeter.py is the file being executed. print("greeter.py is being run directly") print(greet("world"))

Run it: the unguarded print fires during the import, while the guarded block does not. __name__ is "__main__" in the file you executed and the module’s own name everywhere else. Put your script’s actual work behind that guard, so the file can be both imported and run.

Circular imports

If a.py imports b.py and b.py imports a.py, Python cannot finish either.

The error you will eventually meet Python
import order print(order.describe()) from customer import Customer def describe(): return f"order for {Customer('Ada').name}" from order import describe class Customer: def __init__(self, name): self.name = name

The fix is almost never a clever import trick — it is to notice that the two modules are really one concern split badly, or that a third module should hold what they share.

A layout that scales

myproject/
├── README.md
├── requirements.txt
├── src/
│   └── shop/
│       ├── __init__.py      # what the package exposes
│       ├── cart.py          # one concern per module
│       ├── pricing.py
│       └── cli.py           # the entry point, behind a main guard
└── tests/
    ├── test_cart.py
    └── test_pricing.py

Two rules carry most of the weight: one concern per module, and dependencies point one way — cli imports cart, cart never imports cli.

Before you write your own

Choose. No code for this one. In the layout above, where would each of these go — and which one is a trick question?

  1. A function that applies a discount code to a price.
  2. The Cart class.
  3. The code that reads what the user typed and prints a receipt.
  4. An import cli at the top of cart.py, so the cart can print messages.
Check your choices
  1. pricing.py — it’s a pricing rule, with no input or output.
  2. cart.py — one concern per module.
  3. cli.py — talking to the user is the entry point’s job, behind its main guard.
  4. Nowhere. That makes cart depend on cli while cli depends on cart: a circular import. If the cart needs to report something, it should return it, and let cli decide how to show it.
Exercise

Split a program up

The single file below does three things at once. Split it into temperature.py (the conversions), report.py (the formatting) and main.py (the entry point, behind a main guard). Use the file tabs to add code to each.

Run it once before you change anything and keep the output in view: after the split, it should print exactly the same four lines. That’s what makes this a refactor rather than a rewrite.

# Move the conversions to temperature.py and the formatting to report.py, # then import them here. def to_fahrenheit(celsius): return celsius * 9 / 5 + 32 def to_kelvin(celsius): return celsius + 273.15 def format_row(celsius): return f"{celsius:6.1f}C {to_fahrenheit(celsius):6.1f}F {to_kelvin(celsius):6.1f}K" for c in [-40, 0, 21.5, 100]: print(format_row(c)) # Your conversions here # Your formatting here
Hint 1: the idea

Sort the lines by job: which ones calculate, which ones turn numbers into text, and which one decides what happens? Each job becomes a file. Each file imports only from the one “below” it — main uses report, report uses temperature — never the other way.

Hint 2: a first step

Move the two conversion functions into temperature.py unchanged, and put from temperature import to_fahrenheit, to_kelvin where they used to be. Run it: the output shouldn’t change. Then do the same with format_row and report.py, and finally wrap the loop in main() behind the guard.

Show one solution

temperature.py holds the pure calculations, report.py turns numbers into text, and main.py decides what to do — each importable without side effects:

# temperature.py
def to_fahrenheit(celsius):
    return celsius * 9 / 5 + 32

def to_kelvin(celsius):
    return celsius + 273.15
# report.py
from temperature import to_fahrenheit, to_kelvin

def format_row(celsius):
    return f"{celsius:6.1f}C {to_fahrenheit(celsius):6.1f}F {to_kelvin(celsius):6.1f}K"

def format_table(values):
    return "\n".join(format_row(c) for c in values)
# main.py
from report import format_table

def main():
    print(format_table([-40, 0, 21.5, 100]))

if __name__ == "__main__":
    main()

Dependencies point one way — main → report → temperature — so temperature.py can be tested without anything else existing.

What you learned

  • Any .py file beside yours is importable as a module.
  • A folder with __init__.py is a package; from .module import X is a relative import.
  • if __name__ == "__main__": separates “imported” from “run directly”.
  • Circular imports mean the split is wrong, not that you need a trick.
  • One concern per module, and dependencies pointing one way.
Last updated on January 11, 2026

Was this article helpful?

Your response is saved on this device.