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
- What a module is (and why you already use modules)
import,from ... importand aliases withas- Creating
papyrus_utils.py, the shop's module if __name__ == "__main__": module and script at once- Packages: folders with
__init__.py - 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 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 NoneNote two design decisions:
- The module has a docstring too: the first string in the file documents the whole module (
help(papyrus_utils)shows it). find_book()has changed its signature: in lesson 03-01 it read the globalcataloglist frommenu.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 EUROr, if it prefers short names:
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')}") # 2flowchart 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__.pycan 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:
- The folder of the script you're running — that's why
menu.pyfindspapyrus_utils.py: they're neighbors. - The standard library folders — that's where
math,randomanddatetimelive. - The
site-packagesof the active environment — that's where pip installs; with the.venvactivated, it's the project'ssite-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:
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.pyandimport mathwill 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.pyisn't next to the script. Check where you are (pwdin the terminal) and where the file is.- Shadowing standard modules: a
random.pyof your own breaksimport randomin the whole folder. If a standard module "acts weird", look for files of yours with its name. - Writing
import papyrus_utils.py:importtakes the module name, without the extension. With.pyyou get an error about a nonexistentpymodule. - Loose code in the module: a
print()outside functions and outside the__main__block will run on every firstimport, 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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
