Modules and packages in Python — practice with a report card, a self-testing module and a package (and the assert that brought my program down on import)
I wrote a few tests with assert for my validation module and left them at the end of the file, just like that. They worked wonderfully… until the day I changed a constant in the module and one of those tests stopped holding. Then it was not the module that complained: it was my whole application, which crashed with an AssertionError right at startup, before reaching any of its own lines. My tests ran every time anyone imported the module.
What I learned all at once is that writing a module is one thing, but deciding what should happen when it is imported and what when it is launched is another. Today we practise it with three complete programs: a module of your own with the program that imports it, a module that tests itself and a package with a subpackage.
This practice builds on the theory of modules and packages: import, from … import, __name__ and packages. If you are coming from functions in Python, here you will reuse them from several files.
Table of Contents
How to use this article
Each program is made of several files that go in the same folder (and, in the last one, in a folder structure). Create them yourself, copy the code and launch it from the terminal with python3 name.py. If you use an editor with a run button, check that it runs from the project folder; otherwise your imports will not find your modules.
No program uses input(). The data is fixed in the code so that your output is identical to the one you see here.
Program 1 · A module of your own and the program that imports it
A grade report card needs an average and a written mark. We split it into two files: report_card.py, which is the library, and report.py, which is the program that uses it.
report_card.py
"""Utilities for the grade report card."""
GRADE_MAX = 10
PASSING = 5.0
GOOD = 7.0
EXCELLENT = 9.0
def average(a, b, c):
"""Arithmetic average of three grades."""
return (a + b + c) / 3
def to_label(grade):
"""Turns a numeric grade into its written mark."""
if grade >= EXCELLENT:
return "Excellent"
if grade >= GOOD:
return "Good"
if grade >= PASSING:
return "Pass"
return "Fail"
report.py
import report_card as rc
from report_card import average, to_label
print("=== Lucía's report card (out of", rc.GRADE_MAX, ") ===")
fp1 = average(7.0, 8.5, 9.5)
algebra = average(4.0, 5.5, 3.5)
ic1 = average(6.5, 5.0, 7.5)
print("FP1: ", round(fp1, 2), "->", to_label(fp1))
print("Algebra:", round(algebra, 2), "->", to_label(algebra))
print("IC1: ", round(ic1, 2), "->", to_label(ic1))
if rc.PASSING > algebra:
print("Warning: Algebra is below", rc.PASSING)
Launch it with python3 report.py:
=== Lucía's report card (out of 10 ) === FP1: 8.33 -> Good Algebra: 4.33 -> Fail IC1: 6.33 -> Pass Warning: Algebra is below 5.0
Between the lines:
report_card.pyonly defines things: constants and functions. If you launch it on its own it prints nothing, and that is exactly what you expect from a library.- In
report.pywe use both forms at once:from report_card import average, to_labelfor the functions we use a lot, andimport report_card as rcfor the constants, with a short alias. - Why
rc.PASSINGand not justPASSING? Because we did not import it withfrom. If we had brought it withfrom report_card import PASSING, that would work too, but this way you can read where the value comes from. - Notice the order of the checks in
to_label: it goes from the highest grade to the lowest. When it enters anif, it stops looking at the others, becausereturnends the function. - What if the
Goodthreshold changed? You only have to touch it inreport_card.py, and every program that uses the module updates by itself. That is the point of modularizing.
Try it yourself
- Add to
report_card.pya functionpassed(grade)that returnsTrueorFalse, and use it inreport.pyinstead of comparing withrc.PASSING. - Add a fourth subject to the report card. How many lines did you have to touch in
report_card.py? (Hint: none.)
Program 2 · A module that tests itself
Some modules are a library and, at the same time, can be launched to check that they work. That is the trick of if __name__ == "__main__":. Here we have a module to validate grades, with its tests inside, and an application that uses it.
validation.py
"""Grade validation."""
GRADE_MIN = 0
GRADE_MAX = 10
print("validation.py: __name__ =", __name__)
def is_valid_grade(grade):
return GRADE_MIN <= grade <= GRADE_MAX
def clamp(grade):
"""Clips the grade to the valid range."""
return max(GRADE_MIN, min(grade, GRADE_MAX))
if __name__ == "__main__":
# Tests: they only run if you launch this file directly
print("Tests for validation.py")
assert is_valid_grade(7.5)
assert not is_valid_grade(11)
assert clamp(12) == 10
assert clamp(-3) == 0
assert clamp(6.5) == 6.5
print("All correct")
app.py
from validation import is_valid_grade, clamp
grade = 11.5
print("Valid?", is_valid_grade(grade))
print("Corrected grade:", clamp(grade))
First we launch the application, with python3 app.py:
validation.py: __name__ = validation Valid? False Corrected grade: 10
And now the module, directly, with python3 validation.py:
validation.py: __name__ = __main__ Tests for validation.py All correct
Between the lines:
- The first line you see in both outputs comes from the
printwe deliberately left at the top level of the module. Look at the value of__name__:validationwhenapp.pyimports it,__main__when you launch it yourself. - The tests do not show up when
app.pyis launched: they are under theif. In the second case they run, and since all of them hold, you only see “All correct”. assert conditiondoes nothing if the condition is true, and stops the program with anAssertionErrorif it is false. It is a very quick way to leave your tests written down.clampusesminandmaxto clip to the range:min(grade, GRADE_MAX)prevents going over the top andmax(GRADE_MIN, ...)going under the bottom. If it is not clear, try it with12and with-3.- And what if you removed the tests from the
ifand left them loose? You would be back in my story from the beginning: any program that imported the module would run them and, if one failed, it would crash before starting.
Try it yourself
- Break a test on purpose (for example,
assert clamp(12) == 11) and launchpython3 validation.py. Read theAssertionErrormessage. Now launchpython3 app.py: does it still work? - Add a function
round_grade(grade)that rounds to one decimal, and its test. Where do you write it: above or inside theif?
Program 3 · A package with a subpackage
Now we organize an academic record into a package. It has a module with the degree credits, and a grades subpackage with two modules: one that computes and another that formats text.
main.py
record/
├── __init__.py
├── credits.py
└── grades/
├── __init__.py
├── calculations.py
└── text.py
Both __init__.py files are empty. These are the other files:
record/credits.py
DEGREE_CREDITS = 240
def percentage_completed(credits):
"""Percentage of the degree completed (1 decimal)."""
return round(credits / DEGREE_CREDITS * 100, 1)
record/grades/calculations.py
def weighted_average(grade1, cr1, grade2, cr2):
"""Average of two subjects weighted by their credits."""
return (grade1 * cr1 + grade2 * cr2) / (cr1 + cr2)
record/grades/text.py
from record.grades.calculations import weighted_average
def summary(grade1, cr1, grade2, cr2):
average = weighted_average(grade1, cr1, grade2, cr2)
return f"Weighted average: {average:.2f}"
main.py
import record.credits
from record.grades.calculations import weighted_average
from record.grades import text
print("Degree completed:", record.credits.percentage_completed(60), "%")
print("Weighted average:", weighted_average(9.0, 6, 6.0, 6))
print(text.summary(9.0, 6, 6.0, 6))
Launch it with python3 main.py, from the folder that contains record:
Degree completed: 25.0 % Weighted average: 7.5 Weighted average: 7.50
Between the lines:
text.pyuses the full path to reachcalculations.py:from record.grades.calculations import weighted_average. Paths are always written from the folder above, which is wheremain.pyis.main.pyuses all three possible forms:import record.credits(full path when using it),from record.grades.calculations import weighted_average(bare function) andfrom record.grades import text(the module, without the full path).- That last one tends to go unnoticed:
from package import moduleis also valid and lets you usemodule.function. It is a middle ground between importing the function and writing the whole path. - The numbers add up: the weighted average of a 9.0 (6 credits) and a 6.0 (6 credits) is 7.5, and 60 out of 240 credits is 25.0 % of the degree.
f"{average:.2f}"formats with two decimals, which is whytext.summarygives7.50andweighted_averagegives7.5.
Try it yourself
- Add
record/grades/scale.pywith a function that converts a 0–10 grade to a 0–4 scale and use it frommain.py. Which import line do you need? - Delete one
__init__.pyand launch the program again. Does it fail? Read the 🟣 note in the theory and reason why it might not.
Watch it step by step in Python Tutor
Since Python Tutor only accepts one file, we merge into one the module of Program 1 and the program that uses it. Choose Python 3 and paste the code. You will see the call stack grow and empty as some functions call others, which is what happens between your modules even when they live in different files.
PASSING = 5.0
def average(a, b, c):
return (a + b + c) / 3
def to_label(grade):
if grade >= 9:
return "Excellent"
if grade >= PASSING:
return "Pass"
return "Fail"
def report(a, b, c):
m = average(a, b, c)
return to_label(m)
if __name__ == "__main__":
print(report(9.0, 9.5, 8.5))
print(report(4.0, 5.0, 3.5))
Expected output:
Excellent Fail
What to watch, step by step:
- At the start, only
PASSINGand the three functions exist in the global variables. None has run yet:defonly creates them. - When it enters the
if __name__ == "__main__":block, Python Tutor treats it as the main program. It is the same block that invalidation.pywould be skipped if the file were imported. - The call
report(9.0, 9.5, 8.5)opens a new frame. Inside it,average(a, b, c)opens another on top: you see the stack with two frames, andmholds9.0when the second one closes. to_label(m)opens another frame, returns"Excellent"and closes. Thenreportcloses. Between files, exactly the same thing happens.- In the second call, notice that
mis4.166…and the function goes out through the lastreturn: the grade is belowPASSING.
To wrap up
Today you have seen the three patterns you will always use: a library that only defines, a self-tested module thanks to __main__, and a package with a subpackage where every piece has its place. If something does not work out, go back over the theory; and when you feel comfortable, move on to the exercises, where the bugs come included.
Did this article help you? Here is how you can support SergioLearns →
