Skip to main content

Python Docstrings Practice Problems & Exercises

Practice: Docstrings and Documentation

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

Easy

#1The __doc__ AttributeEasy
__doc__docstringsintrospection

Predict the output. Explore how Python stores docstrings in the __doc__ attribute.

Python
def greet(name: str) -> str:
    """Return a personalised greeting string.

    Args:
        name: The name to greet.

    Returns:
        A greeting string starting with 'Hello'.
    """
    return f"Hello, {name}!"

def no_doc():
    return 42

# Is __doc__ a plain string?
print(isinstance(greet.__doc__, str))

# Functions without docstrings have __doc__ == None
print(no_doc.__doc__ is None)

# The first line of the docstring is the summary
first_line = greet.__doc__.strip().split("\n")[0]
print(first_line == "Return a personalised greeting string.")
Solution
def greet(name: str) -> str:
"""Return a personalised greeting string.

Args:
name: The name to greet.

Returns:
A greeting string starting with 'Hello'.
"""
return f"Hello, {name}!"

def no_doc():
return 42

print(isinstance(greet.__doc__, str))
print(no_doc.__doc__ is None)
first_line = greet.__doc__.strip().split("\n")[0]
print(first_line == "Return a personalised greeting string.")

Output:

True
True
True

How it works: Python automatically sets the __doc__ attribute of every function, class, and module to the first string literal that appears in the body. For greet, that is the triple-quoted string. For no_doc, there is no docstring, so Python sets __doc__ to None by default.

The summary line convention says the first line should be a single sentence that fits on one line. Calling .strip().split("\n")[0] is how pydoc and help() extract the summary to display in module indices and interactive help.

Key insight: __doc__ is just a regular string attribute — you can read it, check it in tests, and even modify it at runtime (though that is an unusual pattern). Tools like Sphinx, mkdocstrings, and help() all work by reading __doc__. This is why docstring formatting conventions matter: every tool that reads __doc__ will render whatever you put there.

Expected Output
True\nTrue\nTrue
Hints

Hint 1: Every function with a docstring stores it in __doc__.

Hint 2: A function without a docstring has __doc__ equal to None.

Hint 3: The first line of the docstring is accessible via __doc__.split("\n")[0].

#2help() Output StructureEasy
help__doc__pydoc

Verify the structure of help() output by capturing it and checking for expected sections.

Python
import io
import sys

def divide(a: float, b: float) -> float:
    """Divide a by b and return the result.

    Args:
        a: The dividend. Can be any real number.
        b: The divisor. Must not be zero.

    Returns:
        The quotient a / b as a float.

    Raises:
        ZeroDivisionError: If b is zero.

    Example:
        >>> divide(10.0, 4.0)
        2.5
    """
    if b == 0:
        raise ZeroDivisionError("divisor b cannot be zero")
    return a / b

# Capture help() output as a string
buffer = io.StringIO()
sys.stdout = buffer
help(divide)
sys.stdout = sys.__stdout__
output = buffer.getvalue()

print("divide" in output)
print("Args" in output)
print("Returns" in output)
print("ZeroDivisionError" in output)
Solution
import io
import sys

def divide(a: float, b: float) -> float:
"""Divide a by b and return the result.

Args:
a: The dividend. Can be any real number.
b: The divisor. Must not be zero.

Returns:
The quotient a / b as a float.

Raises:
ZeroDivisionError: If b is zero.

Example:
>>> divide(10.0, 4.0)
2.5
"""
if b == 0:
raise ZeroDivisionError("divisor b cannot be zero")
return a / b

buffer = io.StringIO()
sys.stdout = buffer
help(divide)
sys.stdout = sys.__stdout__
output = buffer.getvalue()

print("divide" in output)
print("Args" in output)
print("Returns" in output)
print("ZeroDivisionError" in output)

Output:

True
True
True
True

How it works: help() calls pydoc.render_doc() internally, which reads __doc__ and the function's signature (via inspect.signature) and formats them into a terminal-friendly string. The output always includes the function name and its full docstring — which is why every section label you write (Args:, Returns:, Raises:) appears verbatim in the help() output.

Redirecting sys.stdout to a StringIO buffer is the standard trick for capturing terminal output in tests and automation scripts. Remember to always restore sys.stdout = sys.__stdout__ after capturing — a try/finally block is the safer pattern in production code.

Key insight: help() is the most immediate consumer of your docstrings during development. Before your code reaches a documentation site, it passes through dozens of help() calls in IPython sessions, interactive Python shells, and IDE hover tooltips. Writing docstrings with help() output in mind keeps you honest about readability.

Expected Output
True\nTrue\nTrue\nTrue
Hints

Hint 1: help() renders the __doc__ string along with the function signature.

Hint 2: Use the io module to capture help() output as a string.

Hint 3: The function name, Args, Returns, and Raises sections all appear in help() output.

#3Google Style: Spot the ErrorsEasy
google-styledocstring-formatargsreturns

Predict the output. Verify that a correctly formatted Google style docstring contains the expected structural markers.

Python
def clamp(value: float, low: float, high: float) -> float:
    """Clamp value to the range [low, high].

    Args:
        value: The number to clamp.
        low: The minimum allowed value (inclusive).
        high: The maximum allowed value (inclusive).

    Returns:
        value if it is within [low, high], low if value is too small,
        or high if value is too large.

    Raises:
        ValueError: If low is greater than high.

    Example:
        >>> clamp(5.0, 0.0, 10.0)
        5.0
        >>> clamp(-3.0, 0.0, 10.0)
        0.0
    """
    if low > high:
        raise ValueError(f"low ({low}) must not exceed high ({high})")
    return max(low, min(value, high))

doc = clamp.__doc__

# Google style uses "Args:" as the section header (with colon)
print("Args:" in doc)

# Each parameter is documented on its own indented line
print("        value: The number to clamp." in doc)

# Raises section lists exception type followed by colon and description
print("ValueError" in doc and "Raises:" in doc)
Solution
def clamp(value: float, low: float, high: float) -> float:
"""Clamp value to the range [low, high].

Args:
value: The number to clamp.
low: The minimum allowed value (inclusive).
high: The maximum allowed value (inclusive).

Returns:
value if it is within [low, high], low if value is too small,
or high if value is too large.

Raises:
ValueError: If low is greater than high.

Example:
>>> clamp(5.0, 0.0, 10.0)
5.0
>>> clamp(-3.0, 0.0, 10.0)
0.0
"""
if low > high:
raise ValueError(f"low ({low}) must not exceed high ({high})")
return max(low, min(value, high))

doc = clamp.__doc__

print("Args:" in doc)
print(" value: The number to clamp." in doc)
print("ValueError" in doc and "Raises:" in doc)

Output:

True
True
True

How it works: Google style uses colon-terminated section headers (Args:, Returns:, Raises:, Example:) with content indented 4 spaces under each header. Parameters are listed as name: description — the type is omitted when it is already in the function signature annotation.

The 8-space indentation for value: The number to clamp. reflects the actual file indentation: 4 spaces inside the function body, plus 4 more for the parameter list under Args:. This consistent indentation is what Sphinx's napoleon extension and mkdocstrings parse to extract structured information.

Key insight: Google style was designed to be readable as plain text — not just after rendering. When you read a Google style docstring raw in a code review, it scans naturally without needing a docs generator. This is its main advantage over Sphinx reST style, where :param name: markers clutter the raw text.

Expected Output
True\nTrue\nTrue
Hints

Hint 1: In Google style, Args: and Returns: are section headers followed by indented content.

Hint 2: Each parameter line is: name: description (no type annotation in parentheses when annotations are on the signature).

Hint 3: The summary line must fit on one line and end without a period if it reads as a command.

#4Module Docstring IntrospectionEasy
module-docstring__doc__types.ModuleType

Explore module docstrings using types.ModuleType and sys.modules to understand how module-level documentation is stored.

Python
import sys
import types

# Simulate a module with a docstring by creating a module object
mod = types.ModuleType("payments")
mod.__doc__ = """Payment processing utilities.

Provides functions for charging cards and processing refunds.
All monetary amounts are in cents (smallest currency unit).

Typical usage:
    from payments import charge_card
    result = charge_card(amount_cents=4999, currency='usd')
"""

# A module's docstring is a plain string
print(isinstance(mod.__doc__, str))

# The first line is the one-sentence summary
summary = mod.__doc__.strip().split("\n")[0]
print(summary == "Payment processing utilities.")

# The docstring contains the usage example
print("charge_card" in mod.__doc__)
Solution
import sys
import types

mod = types.ModuleType("payments")
mod.__doc__ = """Payment processing utilities.

Provides functions for charging cards and processing refunds.
All monetary amounts are in cents (smallest currency unit).

Typical usage:
from payments import charge_card
result = charge_card(amount_cents=4999, currency='usd')
"""

print(isinstance(mod.__doc__, str))
summary = mod.__doc__.strip().split("\n")[0]
print(summary == "Payment processing utilities.")
print("charge_card" in mod.__doc__)

Output:

True
True
True

How it works: A module docstring is placed at the very top of a .py file — before any imports. Python stores it in module.__doc__ just like function and class docstrings. Accessing it via mod.__doc__ or sys.modules["payments"].__doc__ gives the same string.

types.ModuleType is the runtime class of every Python module. Creating one manually is useful in testing and dynamic code generation, but in normal code you write the docstring at the top of the file and Python handles the rest.

Key insight: The structure of a module docstring mirrors a function docstring: one-sentence summary, extended description, usage example, and dependency/constraint notes. The key difference is audience — a module docstring targets developers deciding whether to import this module, while a function docstring targets developers deciding how to call the function.

Expected Output
True\nTrue\nTrue
Hints

Hint 1: A module docstring is the first string literal at the top of the file, before any imports.

Hint 2: You can access a module docstring via module.__doc__ or sys.modules[__name__].__doc__.

Hint 3: types.ModuleType is the type of all module objects.


Medium

#5NumPy Style: Parameters SectionMedium
numpy-styleParametersReturnssection-underline

Inspect a NumPy style docstring and verify its distinctive structural markers.

Python
def normalize(data: list, method: str = "minmax") -> list:
    """Normalize a list of numeric values.

    Parameters
    ----------
    data : list of float
        Input values to normalize. Must contain at least two distinct values.
    method : {'minmax', 'zscore'}, optional
        Normalization strategy. 'minmax' scales to [0, 1]. 'zscore' scales
        to mean=0, std=1. Default is 'minmax'.

    Returns
    -------
    list of float
        Normalized values of the same length as data.

    Raises
    ------
    ValueError
        If data has fewer than 2 distinct values or method is not recognized.

    See Also
    --------
    standardize : Alias for normalize with method='zscore'.

    Examples
    --------
    >>> normalize([0, 5, 10], method='minmax')
    [0.0, 0.5, 1.0]
    """
    if method not in ("minmax", "zscore"):
        raise ValueError(f"Unknown method: {method!r}")
    if len(set(data)) < 2:
        raise ValueError("data must contain at least 2 distinct values")
    if method == "minmax":
        lo, hi = min(data), max(data)
        return [(x - lo) / (hi - lo) for x in data]
    mean = sum(data) / len(data)
    std = (sum((x - mean) ** 2 for x in data) / len(data)) ** 0.5
    return [(x - mean) / std for x in data]

doc = normalize.__doc__

# NumPy style uses "----------" (dashes) as section underlines
print("----------" in doc)

# Parameters section uses "name : type" format
print("data : list of float" in doc)

# Returns section shows the type on its own line
print("list of float" in doc.split("Returns")[1])

# See Also section links related functions
print("See Also" in doc)
Solution
def normalize(data: list, method: str = "minmax") -> list:
"""Normalize a list of numeric values.

Parameters
----------
data : list of float
Input values to normalize. Must contain at least two distinct values.
method : {'minmax', 'zscore'}, optional
Normalization strategy. 'minmax' scales to [0, 1]. 'zscore' scales
to mean=0, std=1. Default is 'minmax'.

Returns
-------
list of float
Normalized values of the same length as data.

Raises
------
ValueError
If data has fewer than 2 distinct values or method is not recognized.

See Also
--------
standardize : Alias for normalize with method='zscore'.

Examples
--------
>>> normalize([0, 5, 10], method='minmax')
[0.0, 0.5, 1.0]
"""
if method not in ("minmax", "zscore"):
raise ValueError(f"Unknown method: {method!r}")
if len(set(data)) < 2:
raise ValueError("data must contain at least 2 distinct values")
if method == "minmax":
lo, hi = min(data), max(data)
return [(x - lo) / (hi - lo) for x in data]
mean = sum(data) / len(data)
std = (sum((x - mean) ** 2 for x in data) / len(data)) ** 0.5
return [(x - mean) / std for x in data]

doc = normalize.__doc__

print("----------" in doc)
print("data : list of float" in doc)
print("list of float" in doc.split("Returns")[1])
print("See Also" in doc)

Output:

True
True
True
True

How it works: NumPy style uses a distinctive two-line format for section headers: the title on one line, followed by a line of dashes the same length as the title. This makes sections easy to scan visually in raw source code.

Parameters use name : type format — the type is written in the docstring even when a type annotation exists, because NumPy style predates PEP 484 annotations and remains in use throughout the scientific Python ecosystem. The See Also section is a NumPy-specific feature that links related functions — invaluable in large libraries like NumPy and SciPy where functions form families.

Key insight: Choose NumPy style when your project is in the scientific Python ecosystem (NumPy, SciPy, pandas, scikit-learn) or when you have functions with many parameters that benefit from the more spacious layout. Choose Google style for most other projects — it is more compact and reads naturally as plain text. Never mix the two styles within a single codebase.

Expected Output
True\nTrue\nTrue\nTrue
Hints

Hint 1: NumPy style uses section titles underlined with dashes: "Parameters\n----------".

Hint 2: Each parameter is listed as "name : type" on its own line, with the description indented below.

Hint 3: The Returns section uses "Returns\n-------" and lists the type on its own line.

#6Sphinx/reST Style: Field ListsMedium
sphinxrestructuredtext:param::type::returns::rtype:

Inspect a Sphinx/reST style docstring and verify its field list markers.

Python
def parse_date(date_str: str, fmt: str = "%Y-%m-%d"):
    """Parse a date string into a date tuple.

    Converts a formatted date string into a named tuple with year, month,
    and day components. Useful when the ``datetime`` module is too heavy.

    :param date_str: The date string to parse. Must match ``fmt`` exactly.
    :type date_str: str
    :param fmt: strftime-compatible format string. Default is ``%Y-%m-%d``.
    :type fmt: str
    :returns: A named tuple with fields year, month, day (all int).
    :rtype: DateTuple
    :raises ValueError: If ``date_str`` does not match ``fmt``.

    Usage::

        result = parse_date("2024-06-15")
        print(result.year)   # 2024
        print(result.month)  # 6
    """
    from collections import namedtuple
    from datetime import datetime
    DateTuple = namedtuple("DateTuple", ["year", "month", "day"])
    dt = datetime.strptime(date_str, fmt)
    return DateTuple(dt.year, dt.month, dt.day)

doc = parse_date.__doc__

# reST style uses :param name: markers
print(":param date_str:" in doc)

# Type information uses :type name: markers
print(":type fmt:" in doc)

# Return type uses :rtype: marker
print(":rtype:" in doc)

# Raises uses :raises ExcType: marker
print(":raises ValueError:" in doc)
Solution
def parse_date(date_str: str, fmt: str = "%Y-%m-%d"):
"""Parse a date string into a date tuple.

Converts a formatted date string into a named tuple with year, month,
and day components. Useful when the ``datetime`` module is too heavy.

:param date_str: The date string to parse. Must match ``fmt`` exactly.
:type date_str: str
:param fmt: strftime-compatible format string. Default is ``%Y-%m-%d``.
:type fmt: str
:returns: A named tuple with fields year, month, day (all int).
:rtype: DateTuple
:raises ValueError: If ``date_str`` does not match ``fmt``.

Usage::

result = parse_date("2024-06-15")
print(result.year) # 2024
print(result.month) # 6
"""
from collections import namedtuple
from datetime import datetime
DateTuple = namedtuple("DateTuple", ["year", "month", "day"])
dt = datetime.strptime(date_str, fmt)
return DateTuple(dt.year, dt.month, dt.day)

doc = parse_date.__doc__

print(":param date_str:" in doc)
print(":type fmt:" in doc)
print(":rtype:" in doc)
print(":raises ValueError:" in doc)

Output:

True
True
True
True

How it works: Sphinx reST style uses inline field list markers — each item starts with :keyword: or :keyword name:. Unlike Google and NumPy styles, there are no explicit section headers; all parameter fields, return fields, and exception fields are listed inline in the body.

The :: at the end of Usage:: is a reST convention: it marks the start of a literal code block. The double backticks around fmt and %Y-%m-%d (``fmt``) render as inline code in Sphinx-generated HTML.

Key insight: reST style is verbose in raw form — :param date_str: and :type date_str: on two separate lines for one parameter is more typing than Google style's single date_str: description line. Use reST style only when contributing to a project that already uses it, or when using Sphinx without the napoleon extension. For new projects, Google style requires less boilerplate and reads better in raw code reviews.

Expected Output
True\nTrue\nTrue\nTrue
Hints

Hint 1: Sphinx reST style uses field list markers: :param name:, :type name:, :returns:, :rtype:, :raises ExcType:.

Hint 2: Each field starts with a colon, a keyword, optional name, another colon, and then the description.

Hint 3: Unlike Google/NumPy style, all parameters share the same section without a section header.

#7doctest: Executable Examples in DocstringsMedium
doctestdoctest.testmodexamplesELLIPSIS

Run doctests directly from docstrings using doctest.run_docstring_examples and verify they all pass.

Python
import doctest

def celsius_to_fahrenheit(celsius: float) -> float:
    """Convert a temperature from Celsius to Fahrenheit.

    Args:
        celsius: Temperature in Celsius.

    Returns:
        Temperature in Fahrenheit.

    Example:
        >>> celsius_to_fahrenheit(0)
        32.0
        >>> celsius_to_fahrenheit(100)
        212.0
        >>> celsius_to_fahrenheit(-40)
        -40.0
    """
    return celsius * 9 / 5 + 32

# Run the doctests in this function
results = doctest.testmod(
    m=None,   # None means test the current module context
    verbose=False,
    extraglobs={"celsius_to_fahrenheit": celsius_to_fahrenheit},
)

# All 3 examples should pass (attempted=3, failed=0)
print(results)
print(results.failed == 0)
Solution
import doctest

def celsius_to_fahrenheit(celsius: float) -> float:
"""Convert a temperature from Celsius to Fahrenheit.

Args:
celsius: Temperature in Celsius.

Returns:
Temperature in Fahrenheit.

Example:
>>> celsius_to_fahrenheit(0)
32.0
>>> celsius_to_fahrenheit(100)
212.0
>>> celsius_to_fahrenheit(-40)
-40.0
"""
return celsius * 9 / 5 + 32

results = doctest.testmod(
m=None,
verbose=False,
extraglobs={"celsius_to_fahrenheit": celsius_to_fahrenheit},
)

print(results)
print(results.failed == 0)

Output:

TestResults(failed=0, attempted=3)
True

How it works: doctest scans docstrings for lines starting with >>>, treats them as Python expressions, executes them, and compares the output against the next line(s). Each >>> expression and its expected output forms one test case. Here, all three temperature conversions match the expected outputs exactly, so failed=0 and attempted=3.

doctest.testmod() with m=None scans the calling module's namespace. Passing extraglobs injects names into the doctest namespace — necessary when running from a script rather than a module file.

Key insight: Doctests serve a dual purpose: they are both examples (readable by humans) and tests (executable by machines). The discipline of keeping examples correct — runnable with python -m doctest mymodule.py — ensures your documentation never goes stale. This is especially powerful for utility functions and algorithms where the expected output is deterministic. Avoid doctests for tests involving randomness, external services, or outputs that depend on the environment.

Expected Output
TestResults(failed=0, attempted=3)\nTrue
Hints

Hint 1: doctest.testmod() finds and runs all >>> examples in all docstrings in the current module.

Hint 2: doctest.run_docstring_examples() runs tests from a single function.

Hint 3: Lines starting with >>> are the input; lines below (without >>>) are the expected output.

#8Type Hints vs Docstrings: What Each One CoversMedium
type-hintsannotationsdocstringscomplementary

Verify the complementary relationship between type annotations and docstrings by inspecting __annotations__ alongside __doc__.

Python
def apply_discount(price: float, rate: float) -> float:
    """Apply a percentage discount to a price.

    Args:
        price: Original price in USD. Must be non-negative.
        rate: Discount rate as a decimal in [0.0, 1.0]. 0.10 means 10% off.

    Returns:
        Discounted price. Always non-negative.

    Raises:
        ValueError: If price is negative or rate is outside [0.0, 1.0].

    Example:
        >>> apply_discount(100.0, 0.10)
        90.0
        >>> apply_discount(50.0, 0.0)
        50.0
    """
    if price < 0:
        raise ValueError(f"price must be non-negative, got {price}")
    if not 0.0 <= rate <= 1.0:
        raise ValueError(f"rate must be in [0.0, 1.0], got {rate}")
    return price * (1 - rate)

# Type information lives in __annotations__
print(apply_discount.__annotations__ == {"price": float, "rate": float, "return": float})

# The docstring adds constraints that types cannot express
print("non-negative" in apply_discount.__doc__)
print("[0.0, 1.0]" in apply_discount.__doc__)

# The docstring does NOT repeat the type (float) in the parameter description
param_section = apply_discount.__doc__.split("Args:")[1].split("Returns:")[0]
print("(float)" not in param_section)
Solution
def apply_discount(price: float, rate: float) -> float:
"""Apply a percentage discount to a price.

Args:
price: Original price in USD. Must be non-negative.
rate: Discount rate as a decimal in [0.0, 1.0]. 0.10 means 10% off.

Returns:
Discounted price. Always non-negative.

Raises:
ValueError: If price is negative or rate is outside [0.0, 1.0].

Example:
>>> apply_discount(100.0, 0.10)
90.0
>>> apply_discount(50.0, 0.0)
50.0
"""
if price < 0:
raise ValueError(f"price must be non-negative, got {price}")
if not 0.0 <= rate <= 1.0:
raise ValueError(f"rate must be in [0.0, 1.0], got {rate}")
return price * (1 - rate)

print(apply_discount.__annotations__ == {"price": float, "rate": float, "return": float})
print("non-negative" in apply_discount.__doc__)
print("[0.0, 1.0]" in apply_discount.__doc__)
param_section = apply_discount.__doc__.split("Args:")[1].split("Returns:")[0]
print("(float)" not in param_section)

Output:

True
True
True
True

How it works:

  1. __annotations__ is a dict mapping parameter names (and "return") to their annotated types. Type checkers (mypy, Pyright) read this dict. It is the machine-readable layer.

  2. The docstring adds the semantic layer: price must be non-negative, rate must be in [0.0, 1.0]. Neither constraint can be expressed by the float type annotation alone.

  3. The parameter descriptions do not include (float) in parentheses — that would be redundant with the annotation. The Google style convention when annotations are present: write only the constraint/semantic description, not the type.

Key insight: A well-designed function uses both layers: annotations enforce types mechanically, docstrings explain what the values mean. A function that accepts a float called rate has no machine-checkable constraint to keep it in [0.0, 1.0] — that is exactly what the docstring is for. Think of annotations as "what type" and docstrings as "what value".

Expected Output
True\nTrue\nTrue\nTrue
Hints

Hint 1: Type annotations are stored in __annotations__ and tell the type checker what types are expected.

Hint 2: Docstrings add semantic information: units, constraints, ranges, and edge cases that types cannot express.

Hint 3: When annotations are present, the docstring should NOT repeat the type — only add constraints.


Hard

#9Class Docstring with Attributes SectionHard
class-docstringAttributesgoogle-style__init__

Inspect a well-formed class docstring and verify it follows Google style class documentation conventions.

Python
from datetime import datetime

class BankAccount:
    """Represents a single customer bank account.

    Manages deposits, withdrawals, and balance tracking for one account.
    Not thread-safe — use one instance per transaction context.

    Attributes:
        account_id: Unique account identifier string (e.g., 'ACC-001').
        owner: Full name of the account owner.
        balance: Current balance in cents (integer). Always non-negative.
        opened_at: UTC datetime when the account was created.

    Example:
        >>> acc = BankAccount("ACC-001", "Alice Smith", initial_balance_cents=10000)
        >>> acc.deposit(5000)
        >>> acc.balance
        15000
    """

    def __init__(self, account_id: str, owner: str, initial_balance_cents: int = 0):
        """Initialize a BankAccount.

        Args:
            account_id: Unique identifier for this account.
            owner: Full legal name of the account holder.
            initial_balance_cents: Starting balance in cents. Defaults to 0.

        Raises:
            ValueError: If initial_balance_cents is negative.
        """
        if initial_balance_cents < 0:
            raise ValueError("Initial balance cannot be negative")
        self.account_id = account_id
        self.owner = owner
        self.balance = initial_balance_cents
        self.opened_at = datetime.utcnow()

    def deposit(self, amount_cents: int) -> None:
        """Add funds to the account balance.

        Args:
            amount_cents: Amount to deposit in cents. Must be positive.

        Raises:
            ValueError: If amount_cents is not positive.
        """
        if amount_cents <= 0:
            raise ValueError("Deposit amount must be positive")
        self.balance += amount_cents

doc = BankAccount.__doc__

# Class docstring documents the class purpose
print("bank account" in doc.lower())

# Attributes section lists public instance attributes
print("Attributes:" in doc)
print("balance: Current balance in cents" in doc)

# Methods are NOT documented here — they have their own docstrings
print("deposit" not in doc.split("Attributes:")[0])
Solution
from datetime import datetime

class BankAccount:
"""Represents a single customer bank account.

Manages deposits, withdrawals, and balance tracking for one account.
Not thread-safe — use one instance per transaction context.

Attributes:
account_id: Unique account identifier string (e.g., 'ACC-001').
owner: Full name of the account owner.
balance: Current balance in cents (integer). Always non-negative.
opened_at: UTC datetime when the account was created.

Example:
>>> acc = BankAccount("ACC-001", "Alice Smith", initial_balance_cents=10000)
>>> acc.deposit(5000)
>>> acc.balance
15000
"""

def __init__(self, account_id: str, owner: str, initial_balance_cents: int = 0):
"""Initialize a BankAccount.

Args:
account_id: Unique identifier for this account.
owner: Full legal name of the account holder.
initial_balance_cents: Starting balance in cents. Defaults to 0.

Raises:
ValueError: If initial_balance_cents is negative.
"""
if initial_balance_cents < 0:
raise ValueError("Initial balance cannot be negative")
self.account_id = account_id
self.owner = owner
self.balance = initial_balance_cents
self.opened_at = datetime.utcnow()

def deposit(self, amount_cents: int) -> None:
"""Add funds to the account balance.

Args:
amount_cents: Amount to deposit in cents. Must be positive.

Raises:
ValueError: If amount_cents is not positive.
"""
if amount_cents <= 0:
raise ValueError("Deposit amount must be positive")
self.balance += amount_cents

doc = BankAccount.__doc__

print("bank account" in doc.lower())
print("Attributes:" in doc)
print("balance: Current balance in cents" in doc)
print("deposit" not in doc.split("Attributes:")[0])

Output:

True
True
True
True

How it works:

  1. The class docstring goes on the class body itself — not on __init__. BankAccount.__doc__ returns the class-level docstring. BankAccount.__init__.__doc__ returns the __init__-level docstring. Sphinx's napoleon extension combines both when generating documentation.

  2. The Attributes: section lists the public instance attributes with their descriptions and constraints. Private attributes (prefixed with _) are intentionally omitted.

  3. Methods are excluded from the class docstring — the string "deposit" only appears in the Example section after the Attributes: section, not in the class body description.

  4. Each class needs an Example: showing the constructor call and a typical usage pattern — this is the most common documentation that new users of your class will read.

Key insight: The class docstring answers "what does this class represent and what state does it track?" while each method docstring answers "how do I use this specific operation?". Mixing them — by describing methods in the class docstring — creates two documents that drift out of sync over time. Keep them separate and let the documentation generator assemble the full picture.

Expected Output
True\nTrue\nTrue\nTrue
Hints

Hint 1: Class docstrings go on the class body, not on __init__.

Hint 2: The Attributes section documents public instance attributes, not private ones.

Hint 3: Methods are NOT documented in the class docstring — each method has its own docstring.

Hint 4: The Example section shows how to construct and use an instance.

#10doctest with Exceptions and Multiline OutputHard
doctestTracebackELLIPSISmultiline

Write and verify doctests that cover both successful output and expected exceptions, including the Traceback format doctest expects.

Python
import doctest

def safe_sqrt(x: float) -> float:
    """Return the square root of x.

    Args:
        x: A non-negative number.

    Returns:
        The square root of x.

    Raises:
        ValueError: If x is negative.

    Examples:
        >>> safe_sqrt(9.0)
        3.0
        >>> safe_sqrt(0.0)
        0.0
        >>> safe_sqrt(2.0)
        1.4142135623730951
        >>> safe_sqrt(-1.0)
        Traceback (most recent call last):
            ...
        ValueError: Cannot take square root of negative number: -1.0
    """
    if x < 0:
        raise ValueError(f"Cannot take square root of negative number: {x}")
    return x ** 0.5

results = doctest.testmod(
    m=None,
    verbose=False,
    extraglobs={"safe_sqrt": safe_sqrt},
)

print(results)
print(results.failed == 0)
Solution
import doctest

def safe_sqrt(x: float) -> float:
"""Return the square root of x.

Args:
x: A non-negative number.

Returns:
The square root of x.

Raises:
ValueError: If x is negative.

Examples:
>>> safe_sqrt(9.0)
3.0
>>> safe_sqrt(0.0)
0.0
>>> safe_sqrt(2.0)
1.4142135623730951
>>> safe_sqrt(-1.0)
Traceback (most recent call last):
...
ValueError: Cannot take square root of negative number: -1.0
"""
if x < 0:
raise ValueError(f"Cannot take square root of negative number: {x}")
return x ** 0.5

results = doctest.testmod(
m=None,
verbose=False,
extraglobs={"safe_sqrt": safe_sqrt},
)

print(results)
print(results.failed == 0)

Output:

TestResults(failed=0, attempted=4)
True

How it works:

  1. The first three examples test normal execution — the output is the repr of the return value. 3.0, 0.0, and 1.4142135623730951 are the exact string representations Python prints for those floats.

  2. The exception test uses the standard doctest pattern: start with Traceback (most recent call last):, then ... (which doctest treats as a wildcard for any number of lines), then the exact exception line ValueError: Cannot take square root of negative number: -1.0. The ... matches the actual stack trace lines, which vary by Python version and call depth.

  3. The message in the ValueError must match exactly — doctest compares string equality. This is why the f-string in the function body f"Cannot take square root of negative number: {x}" produces exactly -1.0 (float repr) to match the doctest.

Key insight: Doctest exception testing is strict about the exception class name and message but permissive about the traceback body (via ...). This design makes doctests portable across Python versions while still verifying that the right exception type and message are raised. For floating-point outputs, watch for precision differences — 1.4142135623730951 has a fixed repr in CPython but may differ on other implementations. Use round() or doctest.ELLIPSIS flag when testing floats that vary by platform.

Expected Output
TestResults(failed=0, attempted=4)\nTrue
Hints

Hint 1: To test that a function raises an exception, include the full Traceback header and the exception line.

Hint 2: Use "..." (three dots) for the middle of a traceback — doctest ignores intervening lines.

Hint 3: Multiline output in doctests works by listing all expected lines consecutively after the >>>.

#11Build a Documentation LinterHard
__doc__inspectdocstring-enforcementlinting

Build a basic docstring linter that checks functions for common documentation problems and returns a list of violation names.

Python
import inspect
import types

def check_function_docs(fn) -> list:
    """Check a function for common docstring violations.

    Returns a list of violation strings from:
      - 'missing_docstring': __doc__ is None or empty
      - 'no_args_section': has parameters but no 'Args:' in docstring
      - 'no_returns_section': has non-None return annotation but no 'Returns:' in docstring
    """
    violations = []
    doc = fn.__doc__ or ""

    if not doc.strip():
        violations.append("missing_docstring")
        return violations  # No point checking further

    sig = inspect.signature(fn)
    has_params = any(
        p.name != "self"
        for p in sig.parameters.values()
    )
    if has_params and "Args:" not in doc:
        violations.append("no_args_section")

    return_annotation = sig.return_annotation
    has_return = (
        return_annotation is not inspect.Parameter.empty
        and return_annotation is not type(None)
    )
    if has_return and "Returns:" not in doc:
        violations.append("no_returns_section")

    return violations

# --- Test functions ---

def well_documented(x: int, y: int) -> int:
    """Return the sum of x and y.

    Args:
        x: First integer.
        y: Second integer.

    Returns:
        The sum x + y.
    """
    return x + y

def missing_docstring(x: int) -> int:
    return x * 2

def no_args_section(x: int, y: int) -> int:
    """Multiply two numbers together."""
    return x * y

def no_returns_section(x: int, y: int) -> int:
    """Multiply two numbers together.

    Args:
        x: First number.
        y: Second number.
    """
    return x * y

# Well-documented function has no violations
print(check_function_docs(well_documented) == [])

# Missing docstring is detected
print("missing_docstring" in check_function_docs(missing_docstring))

# Multiple violations can be detected together
combined_violations = (
    check_function_docs(no_args_section)
    + check_function_docs(no_returns_section)
    + check_function_docs(missing_docstring)
)
print(sorted(combined_violations))

# Linter returns empty list for fully compliant functions
print(check_function_docs(well_documented) == [])
Solution
import inspect
import types

def check_function_docs(fn) -> list:
"""Check a function for common docstring violations.

Returns a list of violation strings from:
- 'missing_docstring': __doc__ is None or empty
- 'no_args_section': has parameters but no 'Args:' in docstring
- 'no_returns_section': has non-None return annotation but no 'Returns:' in docstring
"""
violations = []
doc = fn.__doc__ or ""

if not doc.strip():
violations.append("missing_docstring")
return violations

sig = inspect.signature(fn)
has_params = any(
p.name != "self"
for p in sig.parameters.values()
)
if has_params and "Args:" not in doc:
violations.append("no_args_section")

return_annotation = sig.return_annotation
has_return = (
return_annotation is not inspect.Parameter.empty
and return_annotation is not type(None)
)
if has_return and "Returns:" not in doc:
violations.append("no_returns_section")

return violations

def well_documented(x: int, y: int) -> int:
"""Return the sum of x and y.

Args:
x: First integer.
y: Second integer.

Returns:
The sum x + y.
"""
return x + y

def missing_docstring(x: int) -> int:
return x * 2

def no_args_section(x: int, y: int) -> int:
"""Multiply two numbers together."""
return x * y

def no_returns_section(x: int, y: int) -> int:
"""Multiply two numbers together.

Args:
x: First number.
y: Second number.
"""
return x * y

print(check_function_docs(well_documented) == [])
print("missing_docstring" in check_function_docs(missing_docstring))

combined_violations = (
check_function_docs(no_args_section)
+ check_function_docs(no_returns_section)
+ check_function_docs(missing_docstring)
)
print(sorted(combined_violations))

print(check_function_docs(well_documented) == [])

Output:

True
True
['missing_docstring', 'no_args_section', 'no_returns_section']
True

How it works:

  1. check_function_docs uses inspect.signature(fn) to get the function's parameter list and return annotation — the same mechanism that help() and Sphinx use internally.

  2. For missing docstrings: fn.__doc__ is None when no docstring exists. or "" converts None to an empty string so .strip() works safely.

  3. For the args section: parameters named self are filtered out (they belong to methods, not functions). Any remaining parameters indicate the function should have an Args: section.

  4. For the returns section: inspect.Parameter.empty is the sentinel value that means "no annotation". type(None) corresponds to -> None. We only flag functions that have a meaningful return annotation (-> int, -> str, etc.) but no Returns: section.

  5. The combined_violations list collects one violation from each non-compliant function. sorted() produces a deterministic order for the assertion.

Key insight: This is exactly how production docstring linters like pydoclint and darglint work — they parse the function signature via inspect, then parse the docstring structure, and cross-check them. The real tools handle more edge cases (optional parameters, *args, **kwargs, generators, yield statements) but the core logic is the same: compare what the signature says against what the docstring documents. Integrating a linter like this into CI ensures documentation stays synchronized with code as the codebase evolves.

Expected Output
True\nTrue\n['missing_docstring', 'no_args_section', 'no_returns_section']\nTrue
Hints

Hint 1: Use inspect.getmembers() to iterate over all functions in a module.

Hint 2: Check __doc__ for None or empty string to detect missing docstrings.

Hint 3: Look for "Args:" in the docstring to check for a parameters section.

Hint 4: A function with non-None return annotation that lacks "Returns:" in the docstring is under-documented.

© 2026 EngineersOfAI. All rights reserved.