Skip to content
aviral gupta

// B3.5 · ~40 min · Beginner

Build: a unit converter library

In this build you write a small unit converter library: documented functions for lengths and temperatures, ValueError for bad input, and one convert() with a keyword-only rounding option.

Lesson 5 of 5 in B3 Functions

End of the module

You will be able to

  • Validate arguments and refuse bad ones with raise ValueError and a clear message
  • Give a function keyword-only options with defaults, so calls say what they mean
  • Split a task into small documented functions that call each other
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B3.4: the sort is stable. What does this print?

    units = ["km", "m", "cm"]
    print(sorted(units, key=len))
  2. Predict · Activity 2 of 7

    Predict before you read on: the program prints 30 first. What is the last line Python shows?

    def check_age(age):
        if age < 0:
            raise ValueError("age cannot be negative")
        return age
    
    print(check_age(30))
    print(check_age(-1))
    print("done")
  3. Practice · Activity 3 of 7

    Fill in the keyword so that an unknown unit stops the function with a ValueError.

    def metres_per(unit):
        if unit == "m":
            return 1.0
        ____ ValueError("unknown unit: " + unit)
    ValueError("unknown unit: " + unit)
  4. Practice · Activity 4 of 7

    def convert(value, from_unit, to_unit, *, digits=None): which call does Python refuse?

  5. Practice · Activity 5 of 7

    Put the steps of convert_length(value, from_unit, to_unit) in order.

    1. 1.Divide the metres by how many metres one to_unit is
    2. 2.Multiply value by it to get metres
    3. 3.Look up how many metres one from_unit is
    4. 4.Raise ValueError if value is negative
    5. 5.Return the result
  6. Brain teaser · Activity 6 of 7

    Brain teaser. Converters work with floats. What does this print?

    print(0.1 + 0.2 == 0.3, round(0.1 + 0.2, 2) == 0.3)
  7. Apply · Activity 7 of 7

    Mini-task. Add weights to the library: grams_per(unit) for "g", "kg" and "t" (1 t is 1,000,000 g), and convert_weight(value, from_unit, to_unit, *, digits=2). Raise ValueError for an unknown unit and for a negative weight. Print 1500 g in kg, 2 t in kg, and 1 kg in g with digits=0.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A time converter

This is the pattern the build follows, for durations. seconds_per knows the units and raises ValueError for any other. convert_time validates the value, goes through seconds, and rounds with a keyword-only digits option that defaults to 2. The def line is split over three lines to stay under 79 characters, which PEP 8 allows inside brackets.

main.py

def seconds_per(unit: str) -> int:
    """Return how many seconds one unit is."""
    if unit == "s":
        return 1
    elif unit == "min":
        return 60
    elif unit == "h":
        return 3600
    elif unit == "d":
        return 86400
    raise ValueError("unknown time unit: " + unit)


def convert_time(
    value: float, from_unit: str, to_unit: str, *, digits: int = 2
) -> float:
    """Convert a duration between units, rounded to digits places."""
    if value < 0:
        raise ValueError("a duration cannot be negative")
    seconds = value * seconds_per(from_unit)
    return round(seconds / seconds_per(to_unit), digits)


print(convert_time(90, "min", "h"))
print(convert_time(1, "d", "min"))
print(convert_time(100, "s", "min"))
print(convert_time(100, "s", "min", digits=4))
print(convert_time.__doc__)

Run it with

python main.py

Output

1.5
1440.0
1.67
1.6667
Convert a duration between units, rounded to digits places.
  • 90 minutes are 5400 seconds, and 5400 / 3600 is 1.5 hours.
  • 100 seconds are 1.666… minutes: 1.67 with the default digits=2, 1.6667 with digits=4.
  • convert_time(-5, "s", "min") or convert_time(1, "s", "week") would stop with a ValueError.
  • The docstring is one line, so __doc__ prints exactly that line.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 3

Step 1: lengths

Start the library with lengths. metres_per(unit) returns how many metres one unit is: mm 0.001, cm 0.01, m 1.0, km 1000.0, and raises ValueError("unknown length unit: " + unit) for anything else. convert_length(value, from_unit, to_unit) raises ValueError for a negative value, then converts through metres. The tests compare results rounded to 6 places.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Finish metres_per as an if and elif chain with one return per unit, and put raise ValueError(…) after the chain.

  2. Hint 2

    In convert_length, check if value < 0: and raise first.

  3. Hint 3

    Then return value * metres_per(from_unit) / metres_per(to_unit).

Show a solution

One way to solve it. Yours can look different and still pass the checks.

LENGTH_UNITS = ["mm", "cm", "m", "km"]


def metres_per(unit: str) -> float:
    """Return how many metres one unit is."""
    if unit == "mm":
        return 0.001
    elif unit == "cm":
        return 0.01
    elif unit == "m":
        return 1.0
    elif unit == "km":
        return 1000.0
    raise ValueError("unknown length unit: " + unit)


def convert_length(value: float, from_unit: str, to_unit: str) -> float:
    """Convert a length from one unit to another."""
    if value < 0:
        raise ValueError("a length cannot be negative")
    return value * metres_per(from_unit) / metres_per(to_unit)


print(convert_length(2.5, "km", "m"))
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

LENGTH_UNITS = ["mm", "cm", "m", "km"]


def metres_per(unit: str) -> float:
    """Return how many metres one unit is."""
    if unit == "m":
        return 1.0
    # Add mm, cm and km, and raise ValueError for any other unit.
    return 1.0


def convert_length(value: float, from_unit: str, to_unit: str) -> float:
    """Convert a length from one unit to another."""
    # Raise ValueError for a negative value, then convert via metres.
    return value


print(convert_length(2.5, "km", "m"))

test_main.py

from main import convert_length


def test_km_to_m():
    """2.5 km is 2500 m"""
    got = convert_length(2.5, "km", "m")
    assert round(got, 6) == 2500.0, f"convert_length(2.5, 'km', 'm') returned {got!r}, expected 2500.0"


def test_cm_to_m():
    """250 cm is 2.5 m"""
    got = convert_length(250, "cm", "m")
    assert round(got, 6) == 2.5, f"convert_length(250, 'cm', 'm') returned {got!r}, expected 2.5"


def test_mm_to_cm():
    """5 mm is 0.5 cm"""
    got = convert_length(5, "mm", "cm")
    assert round(got, 6) == 0.5, f"convert_length(5, 'mm', 'cm') returned {got!r}, expected 0.5"


def test_same_unit():
    """7 m is 7 m"""
    got = convert_length(7, "m", "m")
    assert round(got, 6) == 7.0, f"convert_length(7, 'm', 'm') returned {got!r}, expected 7.0"


def test_unknown_unit():
    """An unknown unit raises ValueError"""
    try:
        got = convert_length(3, "m", "miles")
    except ValueError:
        return
    raise AssertionError(f"convert_length(3, 'm', 'miles') returned {got!r} instead of raising ValueError")


def test_negative_length():
    """A negative length raises ValueError"""
    try:
        got = convert_length(-1, "m", "cm")
    except ValueError:
        return
    raise AssertionError(f"convert_length(-1, 'm', 'cm') returned {got!r} instead of raising ValueError")

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 3

Step 2: temperatures

Add temperatures in "C", "F" and "K". Write to_kelvin(value, unit) and from_kelvin(kelvin, unit): K = C + 273.15 and C = (F - 32) * 5 / 9. Both raise ValueError for an unknown unit. convert_temperature(value, from_unit, to_unit) converts to kelvin, raises ValueError below 0 K, and converts from kelvin to the target unit. Step 1 is already in main.py.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    to_kelvin: K returns value, C returns value + 273.15, F returns (value - 32) * 5 / 9 + 273.15, anything else raises.

  2. Hint 2

    from_kelvin reverses it: C is kelvin - 273.15, F is (kelvin - 273.15) * 9 / 5 + 32.

  3. Hint 3

    convert_temperature: kelvin = to_kelvin(value, from_unit); if kelvin < 0: raise ValueError(…); return from_kelvin(kelvin, to_unit).

Show a solution

One way to solve it. Yours can look different and still pass the checks.

LENGTH_UNITS = ["mm", "cm", "m", "km"]


def metres_per(unit: str) -> float:
    """Return how many metres one unit is."""
    if unit == "mm":
        return 0.001
    elif unit == "cm":
        return 0.01
    elif unit == "m":
        return 1.0
    elif unit == "km":
        return 1000.0
    raise ValueError("unknown length unit: " + unit)


def convert_length(value: float, from_unit: str, to_unit: str) -> float:
    """Convert a length from one unit to another."""
    if value < 0:
        raise ValueError("a length cannot be negative")
    return value * metres_per(from_unit) / metres_per(to_unit)


TEMPERATURE_UNITS = ["C", "F", "K"]


def to_kelvin(value: float, unit: str) -> float:
    """Return the temperature in kelvin."""
    if unit == "K":
        return value
    elif unit == "C":
        return value + 273.15
    elif unit == "F":
        return (value - 32) * 5 / 9 + 273.15
    raise ValueError("unknown temperature unit: " + unit)


def from_kelvin(kelvin: float, unit: str) -> float:
    """Return a temperature given in kelvin in another unit."""
    if unit == "K":
        return kelvin
    elif unit == "C":
        return kelvin - 273.15
    elif unit == "F":
        return (kelvin - 273.15) * 9 / 5 + 32
    raise ValueError("unknown temperature unit: " + unit)


def convert_temperature(value: float, from_unit: str, to_unit: str) -> float:
    """Convert a temperature between C, F and K."""
    kelvin = to_kelvin(value, from_unit)
    if kelvin < 0:
        raise ValueError("below absolute zero")
    return from_kelvin(kelvin, to_unit)


print(convert_temperature(100, "C", "F"))
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

LENGTH_UNITS = ["mm", "cm", "m", "km"]


def metres_per(unit: str) -> float:
    """Return how many metres one unit is."""
    if unit == "mm":
        return 0.001
    elif unit == "cm":
        return 0.01
    elif unit == "m":
        return 1.0
    elif unit == "km":
        return 1000.0
    raise ValueError("unknown length unit: " + unit)


def convert_length(value: float, from_unit: str, to_unit: str) -> float:
    """Convert a length from one unit to another."""
    if value < 0:
        raise ValueError("a length cannot be negative")
    return value * metres_per(from_unit) / metres_per(to_unit)


TEMPERATURE_UNITS = ["C", "F", "K"]


def convert_temperature(value: float, from_unit: str, to_unit: str) -> float:
    """Convert a temperature between C, F and K."""
    # Step 2: convert to kelvin first, refuse anything below 0 K,
    # then convert from kelvin to the target unit.
    return value


print(convert_temperature(100, "C", "F"))

test_main.py

from main import convert_temperature


def test_c_to_f():
    """100 C is 212 F"""
    got = convert_temperature(100, "C", "F")
    assert round(got, 6) == 212.0, f"convert_temperature(100, 'C', 'F') returned {got!r}, expected 212.0"


def test_f_to_c():
    """32 F is 0 C"""
    got = convert_temperature(32, "F", "C")
    assert round(got, 6) == 0.0, f"convert_temperature(32, 'F', 'C') returned {got!r}, expected 0.0"


def test_kelvin():
    """0 C is 273.15 K, and 0 K is -273.15 C"""
    got = convert_temperature(0, "C", "K"), convert_temperature(0, "K", "C")
    assert (round(got[0], 6), round(got[1], 6)) == (273.15, -273.15), f"convert_temperature(0, 'C', 'K') and convert_temperature(0, 'K', 'C') returned {got!r}"


def test_minus_forty():
    """-40 C is -40 F"""
    got = convert_temperature(-40, "C", "F")
    assert round(got, 6) == -40.0, f"convert_temperature(-40, 'C', 'F') returned {got!r}, expected -40.0"


def test_below_absolute_zero():
    """-300 C and -1 K raise ValueError"""
    for value, unit in [(-300, "C"), (-1, "K")]:
        try:
            got = convert_temperature(value, unit, "F")
        except ValueError:
            continue
        raise AssertionError(f"convert_temperature({value}, {unit!r}, 'F') returned {got!r} instead of raising ValueError")


def test_unknown_unit():
    """An unknown unit raises ValueError"""
    try:
        got = convert_temperature(20, "C", "X")
    except ValueError:
        return
    raise AssertionError(f"convert_temperature(20, 'C', 'X') returned {got!r} instead of raising ValueError")

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 3 of 3

Step 3: one convert() for everything

Finish the library with convert(value, from_unit, to_unit, *, digits=None). If both units are in LENGTH_UNITS, use convert_length; if both are in TEMPERATURE_UNITS, use convert_temperature; otherwise raise ValueError("cannot convert " + from_unit + " to " + to_unit). When digits is not None, round the result. Annotate digits as int | None, and give convert a docstring.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    from_unit in LENGTH_UNITS is True when the unit is in that list; combine two such tests with and.

  2. Hint 2

    Store the result of convert_length or convert_temperature in a variable, and raise ValueError in the else branch.

  3. Hint 3

    Add *, digits: int | None = None to the parameters, then: if digits is not None: result = round(result, digits).

Show a solution

One way to solve it. Yours can look different and still pass the checks.

LENGTH_UNITS = ["mm", "cm", "m", "km"]


def metres_per(unit: str) -> float:
    """Return how many metres one unit is."""
    if unit == "mm":
        return 0.001
    elif unit == "cm":
        return 0.01
    elif unit == "m":
        return 1.0
    elif unit == "km":
        return 1000.0
    raise ValueError("unknown length unit: " + unit)


def convert_length(value: float, from_unit: str, to_unit: str) -> float:
    """Convert a length from one unit to another."""
    if value < 0:
        raise ValueError("a length cannot be negative")
    return value * metres_per(from_unit) / metres_per(to_unit)


TEMPERATURE_UNITS = ["C", "F", "K"]


def to_kelvin(value: float, unit: str) -> float:
    """Return the temperature in kelvin."""
    if unit == "K":
        return value
    elif unit == "C":
        return value + 273.15
    elif unit == "F":
        return (value - 32) * 5 / 9 + 273.15
    raise ValueError("unknown temperature unit: " + unit)


def from_kelvin(kelvin: float, unit: str) -> float:
    """Return a temperature given in kelvin in another unit."""
    if unit == "K":
        return kelvin
    elif unit == "C":
        return kelvin - 273.15
    elif unit == "F":
        return (kelvin - 273.15) * 9 / 5 + 32
    raise ValueError("unknown temperature unit: " + unit)


def convert_temperature(value: float, from_unit: str, to_unit: str) -> float:
    """Convert a temperature between C, F and K."""
    kelvin = to_kelvin(value, from_unit)
    if kelvin < 0:
        raise ValueError("below absolute zero")
    return from_kelvin(kelvin, to_unit)


def convert(
    value: float, from_unit: str, to_unit: str, *, digits: int | None = None
) -> float:
    """Convert value between two units of the same kind.

    Lengths: mm, cm, m, km. Temperatures: C, F, K. With digits,
    the result is rounded to that many decimal places.
    """
    if from_unit in LENGTH_UNITS and to_unit in LENGTH_UNITS:
        result = convert_length(value, from_unit, to_unit)
    elif from_unit in TEMPERATURE_UNITS and to_unit in TEMPERATURE_UNITS:
        result = convert_temperature(value, from_unit, to_unit)
    else:
        raise ValueError("cannot convert " + from_unit + " to " + to_unit)
    if digits is not None:
        result = round(result, digits)
    return result


print(convert(1, "km", "m"))
print(convert(100, "C", "F", digits=1))
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

LENGTH_UNITS = ["mm", "cm", "m", "km"]


def metres_per(unit: str) -> float:
    """Return how many metres one unit is."""
    if unit == "mm":
        return 0.001
    elif unit == "cm":
        return 0.01
    elif unit == "m":
        return 1.0
    elif unit == "km":
        return 1000.0
    raise ValueError("unknown length unit: " + unit)


def convert_length(value: float, from_unit: str, to_unit: str) -> float:
    """Convert a length from one unit to another."""
    if value < 0:
        raise ValueError("a length cannot be negative")
    return value * metres_per(from_unit) / metres_per(to_unit)


TEMPERATURE_UNITS = ["C", "F", "K"]


def to_kelvin(value: float, unit: str) -> float:
    """Return the temperature in kelvin."""
    if unit == "K":
        return value
    elif unit == "C":
        return value + 273.15
    elif unit == "F":
        return (value - 32) * 5 / 9 + 273.15
    raise ValueError("unknown temperature unit: " + unit)


def from_kelvin(kelvin: float, unit: str) -> float:
    """Return a temperature given in kelvin in another unit."""
    if unit == "K":
        return kelvin
    elif unit == "C":
        return kelvin - 273.15
    elif unit == "F":
        return (kelvin - 273.15) * 9 / 5 + 32
    raise ValueError("unknown temperature unit: " + unit)


def convert_temperature(value: float, from_unit: str, to_unit: str) -> float:
    """Convert a temperature between C, F and K."""
    kelvin = to_kelvin(value, from_unit)
    if kelvin < 0:
        raise ValueError("below absolute zero")
    return from_kelvin(kelvin, to_unit)


def convert(value: float, from_unit: str, to_unit: str) -> float:
    """Convert value between two units of the same kind."""
    # Step 3: pick convert_length or convert_temperature, refuse mixed
    # kinds, and add a keyword-only option digits for rounding.
    return value


print(convert(1, "km", "m"))

test_main.py

from main import convert


def test_length():
    """convert(1, "km", "m") is 1000"""
    got = convert(1, "km", "m")
    assert round(got, 6) == 1000.0, f"convert(1, 'km', 'm') returned {got!r}, expected 1000.0"


def test_temperature():
    """convert(100, "C", "F") is 212"""
    got = convert(100, "C", "F")
    assert round(got, 6) == 212.0, f"convert(100, 'C', 'F') returned {got!r}, expected 212.0"


def test_digits():
    """digits=1 rounds 123.456 cm to 123.5"""
    got = convert(1.23456, "m", "cm", digits=1)
    assert got == 123.5, f"convert(1.23456, 'm', 'cm', digits=1) returned {got!r}, expected 123.5"


def test_mixed_kinds():
    """A length cannot become a temperature"""
    try:
        got = convert(1, "m", "C")
    except ValueError:
        return
    raise AssertionError(f"convert(1, 'm', 'C') returned {got!r} instead of raising ValueError")


def test_digits_keyword_only():
    """digits must be passed by keyword"""
    try:
        got = convert(1.23456, "m", "cm", 1)
    except TypeError:
        return
    raise AssertionError(f"convert(1.23456, 'm', 'cm', 1) returned {got!r}; digits should be keyword-only")


def test_docstring():
    """convert has a docstring"""
    assert convert.__doc__, "convert has no docstring"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

A unit the library does not know

def metres_per(unit: str) -> float:
    """Return how many metres one unit is."""
    if unit == "m":
        return 1.0
    elif unit == "km":
        return 1000.0
    raise ValueError("unknown length unit: " + unit)


def convert_length(value: float, from_unit: str, to_unit: str) -> float:
    """Convert a length from one unit to another."""
    return value * metres_per(from_unit) / metres_per(to_unit)


print(convert_length(3, "m", "miles"))

What Python prints

ValueError: unknown length unit: miles

Why, and the fix

This is the validation working as intended: the traceback runs from the call down to the raise line in metres_per, and the last line carries your message. Fix the call, or add the unit to metres_per. A message that names the bad value makes this quick to find.

Passing a keyword-only option by position

def convert(value: float, factor: float, *, digits: int = 2) -> float:
    """Multiply value by factor and round the result."""
    return round(value * factor, digits)


print(convert(1.23456, 100, 0))

What Python prints

TypeError: convert() takes 2 positional arguments but 3 were given

Why, and the fix

digits is after the *, so it must be named: convert(1.23456, 100, digits=0). That is the point of keyword-only options: a bare 0 in a call says nothing about what it controls.

Raising a string instead of an exception

def metres_per(unit: str) -> float:
    """Return how many metres one unit is."""
    if unit == "m":
        return 1.0
    raise "unknown length unit: " + unit


print(metres_per("ft"))

What Python prints

TypeError: exceptions must derive from BaseException

Why, and the fix

raise needs an exception, not a plain string. Wrap the message in one: raise ValueError("unknown length unit: " + unit). mypy reports this mistake before the program runs.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

raise ValueError refuses bad input

A function should not quietly return nonsense. When an argument has the right type but a value that makes no sense, such as the unit "miles" in a metric converter or -300 °C, raise ValueError("unknown length unit: miles"). raise stops the function at once, like return, but instead of a result the caller gets an error with your message. If nothing handles it, the program stops and prints ValueError: unknown length unit: miles. A later module shows how a caller can catch it.

Keyword-only options

Required inputs come first and by position: the value and the two units. Options that change how the result looks go after a * with a default: def convert(value, from_unit, to_unit, *, digits=None):. Callers then must write digits=1, and convert(5, "m", "cm", 1) is refused, so nobody wonders what the 1 means. None as a default means "not given": if digits is not None: round the result. The annotation int | None says "an int or None".

Small functions, one job each

The library is built from small parts. metres_per(unit) knows the length units and nothing else; convert_length uses it twice, once per unit. to_kelvin and from_kelvin do the same for temperatures, so every conversion goes through one common unit. convert() on top only chooses which part to call. Each function has a docstring and annotations, and each can be tested on its own. Results are floats, so tests compare them rounded: 0.1 + 0.2 is not exactly 0.3.

Sources

Last reviewed September 29, 2026