FreeCampus Python

Designing Clear Parameters and Calls

Predict how Python binds positional, keyword, default, keyword-only, variadic, and unpacked arguments while designing interfaces that remain readable across repeated calls.
python-foundations functions-call-behavior parameters interfaces
Open in Colab
  • Level: Python Foundations · Unit 5
  • Estimated time: 4–5.5 hours
  • You will learn: Read function signatures, predict argument binding and call errors, use safe defaults and keyword-only configuration, and unpack or forward arguments deliberately.
  • Practice in: Google Colab, JupyterLab, or a local editor

1. Make configurable calls readable

This call is legal but difficult to interpret:

def mission_message(name, distance, precision, unit, urgent):
    prefix = "URGENT: " if urgent else ""
    return f"{prefix}{name}: {distance:.{precision}f} {unit}"


message = mission_message("Moon Pass", 12.345, 1, "km", True)

Which 1 controls precision? What does True mean? A clearer interface separates essential data from configuration:

def mission_message(
    name,
    distance,
    *,
    precision=1,
    unit="km",
    urgent=False,
):
    prefix = "URGENT: " if urgent else ""
    return f"{prefix}{name}: {distance:.{precision}f} {unit}"


message = mission_message("Moon Pass", 12.345, urgent=True)
assert message == "URGENT: Moon Pass: 12.3 km"

name and distance may be supplied by position. Parameters after * are keyword-only, so their meaning appears at every call.

Questions this lesson will answer

  • When does Python evaluate arguments, and how are they bound?
  • Which call shapes raise TypeError before the body starts?
  • Why can a mutable default remember earlier calls?
  • When do *args, **kwargs, *sequence, and **mapping help?
  • What do docstrings and annotations promise—and what do they not enforce?

2. Python evaluates arguments before the body begins

def announce(value):
    print("body received", value)
    return value


def prepare():
    print("argument prepared")
    return "map"


result = announce(prepare())

The output order is:

argument prepared
body received map

Python evaluates the argument expression prepare() first. Only after it returns "map" can Python bind local parameter value and begin announce.

Multiple argument expressions are evaluated left to right:

def mark(label):
    print(label)
    return label


def pair(left, right):
    return left, right


result = pair(mark("left"), mark("right"))
assert result == ("left", "right")

Do not put surprising state changes inside arguments. The evaluation order is predictable, but explicit intermediate names are often clearer.

3. Positional arguments bind by order

def describe_stop(name, distance):
    return f"{name} is {distance} km away"


assert describe_stop("Ridge", 7) == "Ridge is 7 km away"

The first argument binds to name; the second binds to distance. Reversing them does not produce a binding error—it produces a nonsensical message:

assert describe_stop(7, "Ridge") == "7 is Ridge km away"

Python binds by position, not intended meaning. Useful parameter names, annotations, and keyword calls help humans detect the mistake.

4. Keyword arguments bind by parameter name

assert describe_stop(distance=7, name="Ridge") == "Ridge is 7 km away"

Keyword arguments can appear in a different order because their labels determine binding. Positional arguments must come before ordinary keyword arguments in a call:

assert describe_stop("Ridge", distance=7) == "Ridge is 7 km away"

A value cannot bind the same parameter twice:

def describe_stop(name, distance):
    return f"{name} is {distance} km away"


# describe_stop("Ridge", name="Cave", distance=7)

If uncommented, Python raises TypeError because positional "Ridge" already bound name, then name="Cave" tries again. Binding fails before the body runs.

Checkpoint: argument binding

5. Call-shape mistakes fail before body execution

For this definition:

def route_label(name, distance):
    return f"{name}: {distance} km"

These commented calls each have a different binding problem:

# route_label("Ridge")
# route_label("Ridge", 7, "north")
# route_label("Ridge", miles=7)

If run separately, they raise TypeError for:

  1. a missing required distance argument;
  2. too many positional arguments; and
  3. an unexpected miles keyword.

Read the function name and parameter names in the message. Unit 8 develops a full traceback method; here the immediate fact is that no valid local parameter frame could be constructed.

6. Defaults make an input optional for the caller

def route_label(name, distance, unit="km"):
    return f"{name}: {distance} {unit}"


assert route_label("Ridge", 7) == "Ridge: 7 km"
assert route_label("Ridge", 7, "miles") == "Ridge: 7 miles"
assert route_label("Ridge", 7, unit="m") == "Ridge: 7 m"

The default is used only when the call supplies no value for unit. Required ordinary parameters must appear before defaulted ones in the definition.

Defaults are part of the public contract. Changing unit="km" to "miles" changes existing calls that omitted that argument even though their code has not changed.

7. A mutable default remembers earlier calls

Default expressions run once when Python executes the definition:

def record_stop(stop, log=[]):
    log.append(stop)
    return log


first = record_stop("dock")
second = record_stop("ridge")

assert first == ["dock", "ridge"]
assert second == ["dock", "ridge"]
assert first is second

Both calls use the same default list. This may surprise callers expecting fresh per-call storage.

Use None as a sentinel and create the list inside each call:

def record_stop(stop, log=None):
    if log is None:
        log = []
    log.append(stop)
    return log


first = record_stop("dock")
second = record_stop("ridge")

assert first == ["dock"]
assert second == ["ridge"]
assert first is not second

An explicitly supplied list is still deliberately changed:

shared_log = []
result = record_stop("dock", shared_log)
assert shared_log == ["dock"]
assert result is shared_log

Unit 6 develops identity and mutation in depth. The interface lesson’s rule is: never use a mutable default as accidental per-call state.

8. Keyword-only parameters make configuration explicit

def format_distance(distance, *, precision=1, unit="km"):
    return f"{distance:.{precision}f} {unit}"


assert format_distance(12.345) == "12.3 km"
assert format_distance(12.345, precision=2) == "12.35 km"

A call such as format_distance(12.345, 2) raises TypeError; precision must be named. This is useful when optional values share types or when the call should read like configuration.

Avoid “Boolean soup”:

def report(name, *, uppercase=False, include_count=True):
    pass

report("ridge", uppercase=True, include_count=False) communicates far more than report("ridge", True, False).

Checkpoint: defaults and keyword-only inputs

9. Read positional-only markers in documentation

Some built-ins display / in their signature. A small user-defined example is:

def percentage(part, whole, /, *, precision=1):
    value = part / whole * 100
    return round(value, precision)


assert percentage(1, 4) == 25.0
assert percentage(1, 3, precision=2) == 33.33

Parameters before / are positional-only. percentage(part=1, whole=4) would raise TypeError. Parameters after * are keyword-only. The ordinary parameters between those markers, if any, may be supplied either way.

You do not need to make every interface positional-only. Learn to read the marker because it appears in Python’s documentation and permits API designers to keep some parameter names from becoming caller commitments.

10. *args collects extra positional arguments

def total_distance(*distances):
    return sum(distances)


assert total_distance() == 0
assert total_distance(4, 7, 3) == 14

Inside the function, distances is a tuple. This interface is honest when any number of same-role values makes sense. It would be worse for a fixed record such as (name, distance, unit), whose roles deserve explicit names.

Ordinary parameters can come first:

def route_message(name, *distances):
    return f"{name}: {sum(distances)} km"


assert route_message("ridge", 4, 7) == "ridge: 11 km"

11. **kwargs collects extra keyword arguments

def format_tags(**tags):
    parts = []
    for key, value in tags.items():
        parts.append(f"{key}={value}")
    return ", ".join(parts)


assert format_tags(zone="north", status="open") == "zone=north, status=open"

Inside, tags is a dictionary. Use this only when a genuinely open set of named options is part of the contract or when forwarding another interface. Do not replace three known parameters with **kwargs; that hides spelling errors and removes useful signature guidance.

12. Stars at a call unpack existing collections

A star in a call has a different role from a star in a definition:

def coordinate_label(row, column):
    return f"({row}, {column})"


coordinate = (3, 7)
assert coordinate_label(*coordinate) == "(3, 7)"

*coordinate supplies its items as positional arguments.

Double-star supplies mapping entries as keyword arguments:

settings = {"precision": 2, "unit": "miles"}
assert format_distance(12.345, **settings) == "12.35 miles"

Keys must match accepted keyword names. A duplicate still fails:

# format_distance(12.345, precision=1, **settings)

Here both the explicit keyword and mapping try to bind precision.

13. Forward arguments without changing their call shape

A small forwarding wrapper can accept and pass through another function’s call:

def call_and_label(function, *args, **kwargs):
    result = function(*args, **kwargs)
    return f"result={result}"


assert call_and_label(format_distance, 12.345, precision=2) == "result=12.35 km"

Lesson 8 uses this shape in decorators. Forwarding is a legitimate variadic use because the wrapper supports the wrapped callable’s argument interface rather than pretending its own domain has unknown fields.

Checkpoint: variadic and unpacked calls

14. Docstrings and annotations communicate; they do not coerce

def repeat_message(message: str, times: int = 1) -> str:
    """Return message repeated times times."""
    return message * times


assert repeat_message("ha", 3) == "hahaha"

The docstring explains purpose. Annotations describe intended value roles to readers and tools. Python still permits a call such as repeat_message("ha", "3") to reach the body; annotations do not convert the string or automatically reject it. That operation then raises TypeError.

Keep the lesson boundary clear:

  • Unit 5 uses annotations to make a signature legible.
  • Unit 14 develops annotation design and static checking.
  • Unit 15 develops docstring conventions and API documentation.

You can inspect an interface:

from inspect import signature

assert str(signature(repeat_message)) == "(message: str, times: int = 1) -> str"

15. Build a mission-message interface

Complete this scaffold:

def build_mission_message(
    name: str,
    distance: float,
    *,
    precision: int = 1,
    unit: str = "km",
    urgent: bool = False,
    notes=None,
) -> str:
    """Return one formatted mission message."""
    pass

Contract:

  1. name and distance are the essential positional-or-keyword data.
  2. All configuration is keyword-only.
  3. notes=None creates fresh per-call notes storage; an explicitly supplied list is read but not changed.
  4. Ignore empty note strings and join non-empty notes with "; " after the base message.
  5. Prefix "URGENT: " only when requested.
  6. Format distance with the requested precision and unit.
  7. Return the string without printing.

Run:

assert build_mission_message("Moon Pass", 12.345) == "Moon Pass: 12.3 km"
assert build_mission_message(
    "Moon Pass", 12.345, precision=2, unit="miles", urgent=True
) == "URGENT: Moon Pass: 12.35 miles"
assert build_mission_message(
    "Ridge", 7, notes=["windy", "", "bring rope"]
) == "Ridge: 7.0 km | windy; bring rope"
assert build_mission_message("Dock", 0, notes=[]) == "Dock: 0.0 km"

notes = ["night route"]
result = build_mission_message("Cave", 3, notes=notes)
assert notes == ["night route"]
assert result.endswith(" | night route")

Then call it with:

mission_data = ("Garden", 5.5)
mission_options = {"precision": 0, "urgent": True}

Use both unpacking operators and prove the result is "URGENT: Garden: 6 km".

Hint: separate binding from message assembly

Treat notes is None as no notes for this call. Build a list of non-empty note strings without changing the supplied collection. Assemble the base string, optional urgent prefix, and optional notes suffix in named stages.

Show one complete solution after attempting the lab
def build_mission_message(
    name: str,
    distance: float,
    *,
    precision: int = 1,
    unit: str = "km",
    urgent: bool = False,
    notes=None,
) -> str:
    """Return one formatted mission message."""
    if notes is None:
        notes = []

    kept_notes = []
    for note in notes:
        if note:
            kept_notes.append(note)

    prefix = "URGENT: " if urgent else ""
    message = f"{prefix}{name}: {distance:.{precision}f} {unit}"
    if kept_notes:
        message += " | " + "; ".join(kept_notes)
    return message


mission_data = ("Garden", 5.5)
mission_options = {"precision": 0, "urgent": True}
unpacked_message = build_mission_message(*mission_data, **mission_options)

The function does not mutate an explicit notes list. The None sentinel still makes the absence of notes distinct from a shared mutable default.

16. Explain the interface

  1. When are argument expressions evaluated relative to the body?
  2. Which calls bind by order, and which bind by parameter name?
  3. Why is a mutable default shared, and how does None change the timing?
  4. How do * and ** differ in definitions and calls?
  5. What do annotations communicate without enforcing at runtime?

Key points

TipKey points
  • Python evaluates arguments, binds parameters, and only then begins the body.
  • Positional values bind by order; keyword values bind by name; duplicate, missing, extra, or unexpected bindings raise TypeError.
  • Defaults are evaluated at definition time. Use a sentinel for fresh mutable per-call state.
  • Keyword-only parameters make configuration visible; positional-only markers commonly appear in built-in documentation.
  • *args and **kwargs collect variable arguments in definitions; *iterable and **mapping expand arguments at calls.
  • Docstrings and annotations communicate the interface but do not perform runtime conversion or validation.

References

Back to top