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

I wanted to print a price with VAT and that same price with a discount. I wrote two modules, taxes.py and discounts.py, each with a function apply, and imported them with from … import apply. I ran the program and the output said that 100 € with VAT was 90 €. I checked the VAT function: it was fine. I checked the 1.21: it was fine. There was no error, no exception. The problem was not in what it computed, but in **which of the two apply functions I was calling**.

Modules have a handful of traps like this, silent ones that raise no error. Today we practise with three programs of increasing difficulty (the last one, a shop with a package and a subpackage), three snippets to debug and a cheat sheet to take away.

These exercises use the theory of modules and packages and review the practice. You can do them without those, but it will be harder.

How to use this article

  1. Read the statement of a level and do not look at the solution yet.
  2. Write your version, run it and compare your output with the expected one.
  3. If you get stuck, look only at the 🔵 hint and try again.
  4. When you finish, compare with the solution and notice why it is done that way, not just that it gives the same output.

No exercise uses input(). The data is fixed in the code so that your output is identical to the expected one. Create all the files of an exercise in the same folder and launch the program from it.

Level 1 · Basic: a temperature module

Statement. Create a module temperature.py with a constant ABSOLUTE_ZERO_C equal to -273.15 and two functions: c_to_f(c), which converts degrees Celsius to Fahrenheit, and c_to_k(c), which converts them to Kelvin. Then, in program.py, import the module with an alias (temp) to call c_to_f, import c_to_k directly with from, and show the three results of the expected output.

Expected output:

20 °C in °F: 68.0
20 °C in K: 293.15
Absolute zero: -273.15 °C

Fahrenheit = Celsius × 9 / 5 + 32. Kelvin = Celsius − absolute zero (subtracting a negative adds). With import temperature as temp you call the functions as temp.c_to_f(...); with from temperature import c_to_k you call c_to_k(...) without a prefix.

Level 2 · Intermediate: two modules with the same function name

Statement. Create two modules, euros.py and dollars.py, each with a function format_amount(amount). The euros one returns the amount with two decimals and the symbol after it (19.50 €) and the dollars one, with the symbol before it ($19.50). In program.py import both functions without them overwriting each other and show the same price in both currencies; add a third line that uses euros.format_amount with the prefix.

In addition, euros.py must be launchable directly to test itself: inside if __name__ == "__main__": print format_amount(5) and format_amount(19.5). **Those two lines must not appear when program.py imports the module.**

Expected output of python3 program.py:

Price: 19.50 € / $19.50
No alias: 19.50 €

Expected output of python3 euros.py:

5.00 €
19.50 €

Two from … import format_amount in a row overwrite each other. Change the name with as: from euros import format_amount as in_euros. For the format, an f-string with two decimals: f"{amount:.2f} €".

Level 3 · Challenge: a shop with a package and a subpackage

Statement. Build a shop package with this structure:

program.py
shop/
├── __init__.py
├── prices.py
├── receipt.py
└── discounts/
    ├── __init__.py
    ├── percentage.py
    └── threshold.py
  • prices.py: a constant VAT = 0.21 and with_vat(base), which returns the price with VAT rounded to two decimals.
  • discounts/percentage.py: apply(price, pct), which lowers the price by pct percent (rounded to two decimals).
  • discounts/threshold.py: pct_for_amount(subtotal), which returns 10 if the amount is 100 or more, and 0 otherwise.
  • receipt.py: total(base), which computes the price with VAT, checks which discount applies to that price and returns the final total. It must use the other three functions.

Test the package from program.py, which must be outside shop.

Expected output:

Purchase of 50 €: 60.5 €
Purchase of 100 €: 108.9 €

Inside the package, modules are imported with the full path from the folder above: from shop.prices import with_vat. The discount is decided with the price already with VAT: 50 € of base becomes 60.50 € (it does not reach 100, so no discount) and 100 € becomes 121 € (with 10 %). And remember the two __init__.py files.

What’s wrong here?

Three snippets with one bug each. Before reading the explanation, do two things: predict whether it raises an error and, if it does not, what it prints.

Snippet 1

prices.py

VAT = 0.21


def with_vat(base):
    return round(base * (1 + VAT), 2)


print("Testing with_vat(100):", with_vat(100))

program.py

import prices

print("Total:", prices.with_vat(50))

*Think about it before moving on: what does python3 program.py print? I only want to see the total for 50 €.*

Explanation. There is no error, but the output carries a line nobody asked for:

Testing with_vat(100): 121.0
Total: 60.5

import prices runs prices.py from top to bottom, and the test print is at the top level of the module, so it also runs on import. It is not a bug in the function: it is code that should not have run on import.

Leaving tests or prints loose at the end of a module. Every program that imports it will run them, and in the worst case you will have a test that fails (or asks for data) before your program even starts.

Fix. Put the test under if __name__ == "__main__": so that it only runs when prices.py is launched directly:

if __name__ == "__main__":
    print("Testing with_vat(100):", with_vat(100))
Total: 60.5

At the top level of a module leave only definitions (constants, functions, classes). Everything else, under the if.

Snippet 2

taxes.py

def apply(price):
    return round(price * 1.21, 2)

discounts.py

def apply(price):
    return round(price * 0.90, 2)

program.py

from taxes import apply
from discounts import apply

print("With VAT:", apply(100))
print("With discount:", apply(100))

Does it raise an error? What does it print? I want to see 100 € with VAT and 100 € with a discount.

Explanation. There is no error and no exception, and that is exactly why it is dangerous:

With VAT: 90.0
With discount: 90.0

Both lines use the discount. The second from discounts import apply replaced the name apply brought by the first, and from then on apply points to the discount function for good. The VAT one still exists in the taxes module, but you no longer have a way to call it by that name.

Bringing the same name from two modules with from … import. It gives no warning, and the last one wins. With two functions that take the same parameters you do not even see a TypeError: just wrong numbers.

Fix. Import the whole modules and use the prefix (or give each function an alias):

import taxes
import discounts

print("With VAT:", taxes.apply(100))
print("With discount:", discounts.apply(100))
With VAT: 121.0
With discount: 90.0

If you see two from … import with the same name at the end of your program, it is a warning sign. The prefix exists precisely for this.

Snippet 3

We use the shop package from Level 3.

f3.py

import shop

print(shop.prices.with_vat(100))

Does it raise an error? What does it print? I want the price with VAT of 100 €, which should be 121.

Explanation. It runs, but stops with an attribute error:

Traceback (most recent call last):
  File "f3.py", line 3, in <module>
    print(shop.prices.with_vat(100))
          ^^^^^^^^^^^
AttributeError: module 'shop' has no attribute 'prices'

import shop loads the package (it runs its __init__.py, which is empty), but that does not import its modules. For Python, shop has no attribute prices until someone imports the module shop.prices. It is not that the path is wrong; it is that nobody has imported that module yet.

Expecting import package to let you use package.module. Importing a package does not import its modules.

Fix. Import the specific module, with the full path:

import shop.prices

print(shop.prices.with_vat(100))
121.0

import package.module or from package import module. Both leave the module ready to use; which one to choose is a matter of how you want to write it afterwards.

Solutions

Look at this section only after you have tried the exercise. If your code is different but gives the same output and meets the requirements, it is also a valid solution.

Solution to Level 1

temperature.py

ABSOLUTE_ZERO_C = -273.15


def c_to_f(c):
    """Degrees Celsius to Fahrenheit."""
    return c * 9 / 5 + 32


def c_to_k(c):
    """Degrees Celsius to Kelvin."""
    return c - ABSOLUTE_ZERO_C

program.py

import temperature as temp
from temperature import c_to_k

print("20 °C in °F:", temp.c_to_f(20))
print("20 °C in K:", c_to_k(20))
print("Absolute zero:", temp.ABSOLUTE_ZERO_C, "°C")

Why it is done this way:

  • The module only defines (a constant and two functions) and prints nothing: that way any program can import it with no side effects.
  • import temperature as temp gives the module a short alias; with it we call temp.c_to_f(20) and read temp.ABSOLUTE_ZERO_C. Everything keeps its prefix.
  • from temperature import c_to_k brings just that function, which we use without a prefix. Both forms live together in the same program, and they do not get in each other’s way.
  • c - ABSOLUTE_ZERO_C adds 273.15 because the constant is negative. If you had written c + 273.15 by hand, the result would be the same, but a loose “magic” number is worse than a named constant.
  • Check: 20 × 9 / 5 + 32 = 68, and 20 + 273.15 = 293.15.

Solution to Level 2

euros.py

def format_amount(amount):
    return f"{amount:.2f} €"


if __name__ == "__main__":
    print(format_amount(5))
    print(format_amount(19.5))

dollars.py

def format_amount(amount):
    return f"${amount:.2f}"

program.py

from euros import format_amount as in_euros
from dollars import format_amount as in_dollars
import euros

price = 19.5
print("Price:", in_euros(price), "/", in_dollars(price))
print("No alias:", euros.format_amount(price))

Why it is done this way:

  • Both functions are called format_amount and there is no conflict because they live in different modules. The conflict would only appear when bringing them with from without an alias, which is why we use as.
  • in_euros and in_dollars are labels for two different functions: they say in the program itself what each one does, something a bare format_amount did not say.
  • Besides the aliases, program.py does import euros to use the prefix on the last line. You can import from the same module in several ways at once.
  • The if __name__ == "__main__": block only runs with python3 euros.py (second output). When importing euros from program.py, __name__ is "euros" and the block is skipped (first output).
  • :.2f forces two decimals, hence 5.00 €.

Solution to Level 3

shop/prices.py

VAT = 0.21


def with_vat(base):
    """Price with VAT, rounded to cents."""
    return round(base * (1 + VAT), 2)

shop/discounts/percentage.py

def apply(price, pct):
    """Lowers the price by pct percent."""
    return round(price * (1 - pct / 100), 2)

shop/discounts/threshold.py

def pct_for_amount(subtotal):
    """10 % discount from 100 euros up."""
    return 10 if subtotal >= 100 else 0

shop/receipt.py

from shop.prices import with_vat
from shop.discounts.percentage import apply
from shop.discounts.threshold import pct_for_amount


def total(base):
    price = with_vat(base)
    return apply(price, pct_for_amount(price))

program.py

from shop.receipt import total

print("Purchase of 50 €:", total(50), "€")
print("Purchase of 100 €:", total(100), "€")

Why it is done this way:

  • The two __init__.py files (the one in shop and the one in discounts) are empty, and they are what tells Python that those folders are packages.
  • receipt.py uses three imports with the full path from shop. It is the module that brings together the other pieces, and that is why it is the only one importing from several places.
  • The discount decision is taken with the price with VAT: pct_for_amount(price). If you took it with the base, a purchase with a base of 90 € (108.90 € with VAT) would get no discount even though the receipt exceeds 100 €.
  • 10 if subtotal >= 100 else 0 is a one-line conditional expression; it is equivalent to a normal if with two returns.
  • The numbers add up: 50 € → 60.50 € (no discount); 100 € → 121 € → 10 % discount → 108.90 €.
  • program.py is outside shop and you launch it from that folder; if you put it inside, Python would stop seeing the shop package.

Watch it step by step in Python Tutor

The bug in snippet 2 is perfect to watch live, although Python Tutor only accepts one file. Choose Python 3 and paste this program, which does on a single page the same as the two from … import apply:

def apply_vat(price):
    return round(price * 1.21, 2)


def apply_discount(price):
    return round(price * 0.90, 2)


apply = apply_vat           # like: from taxes import apply
print("With VAT:", apply(100))

apply = apply_discount     # like: from discounts import apply
print("With VAT:", apply(100))   # the name now points to the other function!

Expected output:

With VAT: 121.0
With VAT: 90.0

What to watch, step by step:

  1. After the two definitions, there are two functions in the global variables: apply_vat and apply_discount. There is no name called apply yet.
  2. In apply = apply_vat the name apply appears with an arrow to the same box as apply_vat. This is equivalent to from taxes import apply. The call that follows prints 121.0.
  3. In apply = apply_discount, the arrow of apply jumps to the other box. This is equivalent to the second from … import. And the apply_vat box still exists, but apply no longer points to it.
  4. The second call, apply(100), now runs the discount function: that is why it prints 90.0 even though the message says “with VAT”.
  5. If instead of reassigning you left two different names (apply_vat and apply_discount, or the prefixes taxes. and discounts.), each call would go to its own function.

Cheat sheet: Modules and packages

The essentials in 30 seconds

  • Module = a .py file. The module name is the file name, without the extension.
  • **import m** → m.f(). **from m import f** → f(). **as** gives an alias.
  • Importing runs the file (the first time). Leave only definitions at the top level.
  • **if __name__ == "__main__":** → code that only runs when the file is launched directly.
  • Package = a folder with modules and __init__.py. A dot in the import is a folder.
  • **Two froms with the same name**: the last one wins, with no error.

Syntax

I want to…CodeResult
Use a module with a prefiximport gradesgrades.average(...)
Bring a bare functionfrom grades import averageaverage(...)
Abbreviate a moduleimport grades as grgr.average(...)
Rename a functionfrom grades import average as grades_averagegrades_average(...)
Code that only runs when the file is launchedif __name__ == "__main__":Skipped on import
A module from a packageimport shop.pricesshop.prices.with_vat(...)
A function from a subpackagefrom shop.discounts.threshold import pct_for_amountpct_for_amount(...)
A module without the full pathfrom shop.discounts import thresholdthreshold.pct_for_amount(...)
Turn a folder into a packageAn __init__.py file (it can be empty)Python treats it as a package

Templates for the exercises

A module that can be imported and launched (Level 2):

def format_amount(amount):
    return f"{amount:.2f} €"


if __name__ == "__main__":      # only if you launch this file
    print(format_amount(5))

Two modules with the same function name (Level 2):

from euros import format_amount as in_euros
from dollars import format_amount as in_dollars

A module that uses another one from the same package (Level 3):

from shop.prices import with_vat                       # from the folder above
from shop.discounts.percentage import apply

The mistakes that repeat most

SymptomLikely cause
Lines you did not ask for show up on importLoose code at the top level of the module: put it under if __name__ == "__main__":
NameError: name 'average' is not definedYou did import grades and called average without the grades. prefix
ModuleNotFoundError: No module named 'x'Name or path misspelled, or you run from the wrong folder
ModuleNotFoundError when launching a file inside the packagePython only sees the package from the folder above: launch program.py, not the inner modules
AttributeError: module 'shop' has no attribute 'prices'import shop does not import its modules: use import shop.prices
A result different from the expected one, with no errorTwo from … import with the same name: the second overwrote the first
TypeError: f() takes 2 positional arguments but 3 were givenYou call a function by the name of another one that overwrote yours
Strange errors when importing math, random…Your file has the same name as a standard module

Before handing in, ask yourself

  1. Does my module only define things at the top level, with the tests under if __name__ == "__main__":?
  2. Have I avoided bringing repeated names from two modules with from (or given them aliases)?
  3. Does every folder of my package have its **__init__.py**, and do my import paths start from the folder above?
  4. Do I launch the program from outside the package, not a module inside it?

If you want to review the topic from the basics, go back to the theory of modules and packages.

Keep goingAll Python (en) articles

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

Sigue aprendiendo

One Comment

Leave a Reply

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