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.

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.py only 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.py we use both forms at once: from report_card import average, to_label for the functions we use a lot, and import report_card as rc for the constants, with a short alias.
  • Why rc.PASSING and not just PASSING? Because we did not import it with from. If we had brought it with from 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 an if, it stops looking at the others, because return ends the function.
  • What if the Good threshold changed? You only have to touch it in report_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.py a function passed(grade) that returns True or False, and use it in report.py instead of comparing with rc.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 print we deliberately left at the top level of the module. Look at the value of __name__: validation when app.py imports it, __main__ when you launch it yourself.
  • The tests do not show up when app.py is launched: they are under the if. In the second case they run, and since all of them hold, you only see “All correct”.
  • assert condition does nothing if the condition is true, and stops the program with an AssertionError if it is false. It is a very quick way to leave your tests written down.
  • clamp uses min and max to clip to the range: min(grade, GRADE_MAX) prevents going over the top and max(GRADE_MIN, ...) going under the bottom. If it is not clear, try it with 12 and with -3.
  • And what if you removed the tests from the if and 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 launch python3 validation.py. Read the AssertionError message. Now launch python3 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 the if?

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.py uses the full path to reach calculations.py: from record.grades.calculations import weighted_average. Paths are always written from the folder above, which is where main.py is.
  • main.py uses all three possible forms: import record.credits (full path when using it), from record.grades.calculations import weighted_average (bare function) and from record.grades import text (the module, without the full path).
  • That last one tends to go unnoticed: from package import module is also valid and lets you use module.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 why text.summary gives 7.50 and weighted_average gives 7.5.

Try it yourself

  • Add record/grades/scale.py with a function that converts a 0–10 grade to a 0–4 scale and use it from main.py. Which import line do you need?
  • Delete one __init__.py and 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:

  1. At the start, only PASSING and the three functions exist in the global variables. None has run yet: def only creates them.
  2. When it enters the if __name__ == "__main__": block, Python Tutor treats it as the main program. It is the same block that in validation.py would be skipped if the file were imported.
  3. 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, and m holds 9.0 when the second one closes.
  4. to_label(m) opens another frame, returns "Excellent" and closes. Then report closes. Between files, exactly the same thing happens.
  5. In the second call, notice that m is 4.166… and the function goes out through the last return: the grade is below PASSING.

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.

Next step: exercisesModules and packages in Python — exercises with temperatures, currencies and a shop (and the price with VAT that came out with a discount)

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *