The diagnosis at the close of the previous lesson was clear: final_price() is copied into price_list.py and other scripts, find_book() lives locked inside menu.py, and every copy is a source of inconsistencies — the day Ana changes the rounding, how many places will need touching? The solution is the module: a .py file whose functions and constants can be imported from any other file. In fact, you've been consuming modules without knowing it every time you installed packages with pip (lesson 01-06); today you'll learn the other half: creating your own. We'll build papyrus_utils.py, the shop's first module of its own, you'll understand the variants of import, the if __name__ == "__main__" idiom, what a package with __init__.py is, and where Python looks for the modules you ask it for.

Contents

  1. What a module is (and why you already use modules)
  2. import, from ... import and aliases with as
  3. Creating papyrus_utils.py, the shop's module
  4. if __name__ == "__main__": module and script at once
  5. Packages: folders with __init__.py
  6. Where Python looks for modules: sys.path

What a module is (and why you already use modules)

A module is, quite simply, a .py file. Everything it defines at its top level — functions, constants, variables — can be made available to other files via import. Modules come from three sources:

Source Examples Do they need installing?
Standard library (ships with Python) math, random, datetime No
Third-party (installed with pip into the .venv) requests, pandas Yes (lesson 01-06)
Your own (you write them) papyrus_utils No: a .py file is enough

The import mechanics are identical for all three. Let's start with a standard one to see the syntax on familiar ground:

import math

print(math.sqrt(16))    # 4.0
print(math.pi)          # 3.141592653589793

import math loads the module and makes it available under its name; from then on, everything it owns is used with dot notation: math.sqrt, math.pi. The module name acts as a "surname" that avoids collisions with your own variables.

import, from ... import and aliases with as

There are three main variants, each with its use case:

# 1) Import the whole module (used with a prefix)
import math
math.sqrt(16)

# 2) Import specific names (used without a prefix)
from math import sqrt, pi
sqrt(16)

# 3) Alias: rename the module (or the name) as you import it
import datetime as dt
dt.date.today()
Variant Advantage Drawback When to use it
import module It's clear where everything comes from More typing (module.function) By default; whenever in doubt
from module import name Short calls You lose the "surname"; risk of name collisions Few names, heavily used in the file
import module as alias Shortens long module names A whimsical alias confuses Aliases entrenched by convention (numpy as np, module 11)

There's a fourth form, from module import * (import everything without a prefix), which you should know so you never use it: it fills your file with invisible names — you don't know what you imported or what you overwrote — and PEP 8 reserves it for very rare cases.

Two important PEP 8 conventions:

  • Imports go at the top of the file, after the module docstring, one per line.
  • Order them in three blocks separated by a blank line: standard library → third-party packages → your own modules.

Creating papyrus_utils.py, the shop's module

Down to work. In the papyrus folder (next to menu.py), create papyrus_utils.py with the pieces we've been copying from script to script:

"""Shared utilities for the Papyrus bookshop.

Store constants plus the pricing and search functions
used by menu.py, receipt.py and member_sale.py.
"""

STORE_NAME = "Papyrus"
BOOK_VAT = 0.04
MEMBER_DISCOUNT = 0.05


def final_price(base, member=False, vat=BOOK_VAT):
    """Return the final price: member discount (if applicable) and then VAT."""
    if member:
        base = base * (1 - MEMBER_DISCOUNT)
    return round(base * (1 + vat), 2)


def find_book(catalog, wanted):
    """Return the index of the title in the catalog, or None if absent.

    The comparison ignores upper and lower case.
    """
    for i, title in enumerate(catalog):
        if title.lower() == wanted.lower():
            return i
    return None

Note two design decisions:

  1. The module has a docstring too: the first string in the file documents the whole module (help(papyrus_utils) shows it).
  2. find_book() has changed its signature: in lesson 03-01 it read the global catalog list from menu.py; that worked because the function and the list lived in the same file. A module can't (and shouldn't) depend on the variables of whoever imports it, so it now receives the catalog as a parameter — the "receive data, don't read globals" lesson (03-01) applied for real. The constants (BOOK_VAT, MEMBER_DISCOUNT) do live in the module: they belong to the shop, not to any particular script.

And now, the reward. Any script in the folder can write:

import papyrus_utils

catalog = ["The Odyssey", "Hamlet", "Don Quixote"]
prices = [12.50, 9.95, 15.90]

index = papyrus_utils.find_book(catalog, "hamlet")
if index is not None:
    luis_price = papyrus_utils.final_price(prices[index], member=True)
    print(f"{catalog[index]} for Luis: {luis_price} EUR")   # Hamlet for Luis: 9.83 EUR

Or, if it prefers short names:

from papyrus_utils import final_price, find_book, STORE_NAME

One single definition, as many users as needed. The day the VAT changes, you edit one line of one file and every script is up to date. In the next lesson we'll rewrite menu.py on top of this module.

A useful technical detail: Python executes the module's file only once, on the program's first import; subsequent imports reuse the already-loaded module. That's why importing is cheap, and why you shouldn't leave loose "working code" in a module... which brings us to the next section.

if __name__ == "__main__": module and script at once

When importing, Python executes the whole file: the defs define functions (harmless), but a stray print() or input() would run too, sneaking into the importer's program. How do you get a module that can also run on its own — for example, to try it out? Python gives every file an automatic variable, __name__:

  • If the file is run directly (python papyrus_utils.py), __name__ is "__main__".
  • If the file is imported, __name__ is its module name ("papyrus_utils").

The standard idiom exploits that difference. Add to the end of papyrus_utils.py:

if __name__ == "__main__":
    # Small demo: only runs when the file is executed directly.
    print(f"Utility module for {STORE_NAME}")
    print(f"Hamlet, member: {final_price(9.95, member=True)} EUR")        # 9.83 EUR
    demo = ["The Odyssey", "Hamlet", "Don Quixote"]
    print(f"Index of 'don quixote': {find_book(demo, 'don quixote')}")    # 2
flowchart TD
    A[Python executes papyrus_utils.py] --> B{"__name__?"}
    B -->|"python papyrus_utils.py<br/>__name__ == '__main__'"| C[Runs the demo/tests]
    B -->|"import papyrus_utils<br/>__name__ == 'papyrus_utils'"| D[Only defines functions and constants:<br/>total silence]

Result: python papyrus_utils.py prints the demo; import papyrus_utils from menu.py prints nothing. Get into the habit of closing your "main" scripts this way too: it's the trademark of well-mannered Python code, and in module 9 this block will be the natural springboard for tests.

Packages: folders with __init__.py

When modules multiply, they're grouped into packages: folders containing modules and a special file, __init__.py (usually empty), which tells Python "this folder is a package". If Papyrus keeps growing, its project could be organized like this:

papyrus/                    <- project folder (with its .venv)
    menu.py                 <- main script
    papyrus_utils.py        <- our module (current stage)
    shop/                   <- a PACKAGE (future stage)
        __init__.py         <- marks the folder as a package
        pricing.py          <- module: final_price, BOOK_VAT...
        inventory.py        <- module: find_book, stocks...

Dot notation crosses the levels:

import shop.pricing
shop.pricing.final_price(9.95, member=True)

from shop.inventory import find_book
find_book(catalog, "Faust")
  • __init__.py can be empty; it can also run package initialization code or re-export convenient names — for now, empty is perfect.
  • Packages can nest (shop.reports.monthly), forming the tree you've already seen in third-party libraries.

For Papyrus's current size, a single flat papyrus_utils.py is the right decision — creating packages prematurely is bureaucracy. But now, when you meet from flask import Flask or import pandas as pd in modules 10 and 11, you'll know exactly what structure lies behind them: folders with __init__.py and .py files.

Where Python looks for modules: sys.path

When you write import papyrus_utils, how does Python find the file? It walks, in order, a list of folders called sys.path:

  1. The folder of the script you're running — that's why menu.py finds papyrus_utils.py: they're neighbors.
  2. The standard library folders — that's where math, random and datetime live.
  3. The site-packages of the active environment — that's where pip installs; with the .venv activated, it's the project's site-packages (lesson 01-06, now making complete sense).

It uses the first module it finds and, if it exhausts the list, raises ModuleNotFoundError. You can inspect the list yourself:

import sys
print(sys.path)     # the list of folders, in search order

Two practical consequences of the search order:

  • If your script and your module are in the same folder, everything works with no configuration. That's our case and the case of most small projects.
  • Because your folder comes first, a file of yours named after a standard module shadows it: create a throwaway math.py and import math will load yours, breaking things with baffling errors. Never name your files after well-known modules (random.py, string.py, test.py...).

Common Mistakes and Tips

  • ModuleNotFoundError: No module named 'papyrus_utils': it almost always means you're running the script from another folder or the .py isn't next to the script. Check where you are (pwd in the terminal) and where the file is.
  • Shadowing standard modules: a random.py of your own breaks import random in the whole folder. If a standard module "acts weird", look for files of yours with its name.
  • Writing import papyrus_utils.py: import takes the module name, without the extension. With .py you get an error about a nonexistent py module.
  • Loose code in the module: a print() outside functions and outside the __main__ block will run on every first import, making a mess for the importer. Everything executable goes inside functions or the __main__ block.
  • from module import *: it pollutes your namespace and makes it impossible to know where each function came from. Import specific names or the whole module.
  • Editing the module and not seeing the changes in the REPL: the REPL caches imported modules; restart it after editing the file (scripts don't suffer this: every run starts from scratch).
  • Tip: the key question before moving a function into a module: "do two or more scripts need it, or might one need it tomorrow?". If yes, into the module it goes. Business constants (BOOK_VAT) included.

Exercises

Exercise 1: member_sale.py reborn on top of the module

Rewrite member_sale.py so that it defines no functions or constants of its own: it should import what it needs from papyrus_utils, ask for a title with input(), look it up in catalog = ["The Odyssey", "Hamlet", "Don Quixote"] (with prices = [12.50, 9.95, 15.90]), and print Luis's member price, or '<title>' is not in the catalog. if it doesn't exist. Use the from ... import variant.

Exercise 2: the self-running module

Add to papyrus_utils.py the function is_valid_member(code), which returns True if code is a string of exactly 4 digits (hint: len() and the isdigit() method you know from module 2). Extend the if __name__ == "__main__" block with three checks that print the result of is_valid_member("0042"), is_valid_member("42") and is_valid_member("ABCD"). Verify that python papyrus_utils.py shows the checks and that importing it from another script shows nothing.

Exercise 3: anatomy of a package

Without writing code, answer with reasons: (a) what turns an ordinary folder into a package?; (b) if Papyrus had the structure shop/pricing.py with __init__.py, which two import forms would let you call final_price?; (c) in what order will Python look for the shop module when menu.py does import shop, and why does it find it?

Solutions

Exercise 1:

from papyrus_utils import final_price, find_book

catalog = ["The Odyssey", "Hamlet", "Don Quixote"]
prices = [12.50, 9.95, 15.90]

wanted = input("Title for Luis: ")
index = find_book(catalog, wanted)

if index is None:
    print(f"'{wanted}' is not in the catalog.")
else:
    print(f"'{catalog[index]}' for Luis: {final_price(prices[index], member=True)} EUR")

All the business knowledge (VAT, discount, how to compare titles) lives in the module; the script merely orchestrates input, call and output. Try it with hamlet: 'Hamlet' for Luis: 9.83 EUR.

Exercise 2:

def is_valid_member(code):
    """Return True if the member code is exactly 4 digits."""
    return len(code) == 4 and code.isdigit()


if __name__ == "__main__":
    print(f"Utility module for {STORE_NAME}")
    print(f"Hamlet, member: {final_price(9.95, member=True)} EUR")
    print(is_valid_member("0042"))   # True
    print(is_valid_member("42"))     # False (length 2)
    print(is_valid_member("ABCD"))   # False (not digits)

The and short-circuits (module 2): if the length isn't 4, it doesn't even evaluate isdigit(). When the module is imported from another file, the __main__ block stays silent because __name__ is "papyrus_utils".

Exercise 3: (a) Containing an __init__.py file (even an empty one). (b) import shop.pricing and calling shop.pricing.final_price(...), or from shop.pricing import final_price and calling final_price(...) directly. (c) Python walks sys.path in order: first the script's folder (papyrus/), where it finds the shop folder with its __init__.py; it never gets as far as the standard library or site-packages.

Conclusion

Papyrus finally has a central toolbox: papyrus_utils.py, with the shop's constants, final_price(base, member=False, vat=BOOK_VAT) and a find_book(catalog, wanted) that no longer depends on someone else's globals — plus an if __name__ == "__main__" block that makes it testable just by running it. You know how to choose between import module, from module import name and the as aliases, you know what a package is (__init__.py), and you understand the map of folders sys.path walks to resolve every import. That same mechanism opens an enormous door: the standard library, dozens of ready-made, tested, documented modules that ship with Python — math, randomness, dates, system... In the next lesson we'll take a guided tour of the most useful ones and close the module by rewriting menu.py as it deserves: importing papyrus_utils.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved