Python Docstrings Practice Problems & Exercises
Practice: Docstrings and Documentation
← Back to lessonEasy
Predict the output. Explore how Python stores docstrings in the __doc__ attribute.
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\nTrueHints
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].
Verify the structure of help() output by capturing it and checking for expected sections.
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\nTrueHints
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.
Predict the output. Verify that a correctly formatted Google style docstring contains the expected structural markers.
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\nTrueHints
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.
Explore module docstrings using types.ModuleType and sys.modules to understand how module-level documentation is stored.
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\nTrueHints
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
Inspect a NumPy style docstring and verify its distinctive structural markers.
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\nTrueHints
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.
Inspect a Sphinx/reST style docstring and verify its field list markers.
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\nTrueHints
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.
Run doctests directly from docstrings using doctest.run_docstring_examples and verify they all pass.
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)\nTrueHints
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.
Verify the complementary relationship between type annotations and docstrings by inspecting __annotations__ alongside __doc__.
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:
-
__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. -
The docstring adds the semantic layer:
pricemust be non-negative,ratemust be in[0.0, 1.0]. Neither constraint can be expressed by thefloattype annotation alone. -
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\nTrueHints
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
Inspect a well-formed class docstring and verify it follows Google style class documentation conventions.
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:
-
The class docstring goes on the
classbody itself — not on__init__.BankAccount.__doc__returns the class-level docstring.BankAccount.__init__.__doc__returns the__init__-level docstring. Sphinx'snapoleonextension combines both when generating documentation. -
The
Attributes:section lists the public instance attributes with their descriptions and constraints. Private attributes (prefixed with_) are intentionally omitted. -
Methods are excluded from the class docstring — the string
"deposit"only appears in theExamplesection after theAttributes:section, not in the class body description. -
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\nTrueHints
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.
Write and verify doctests that cover both successful output and expected exceptions, including the Traceback format doctest expects.
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:
-
The first three examples test normal execution — the output is the repr of the return value.
3.0,0.0, and1.4142135623730951are the exact string representations Python prints for those floats. -
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 lineValueError: Cannot take square root of negative number: -1.0. The...matches the actual stack trace lines, which vary by Python version and call depth. -
The message in the
ValueErrormust match exactly — doctest compares string equality. This is why the f-string in the function bodyf"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)\nTrueHints
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 >>>.
Build a basic docstring linter that checks functions for common documentation problems and returns a list of violation names.
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:
-
check_function_docsusesinspect.signature(fn)to get the function's parameter list and return annotation — the same mechanism thathelp()and Sphinx use internally. -
For missing docstrings:
fn.__doc__isNonewhen no docstring exists.or ""convertsNoneto an empty string so.strip()works safely. -
For the args section: parameters named
selfare filtered out (they belong to methods, not functions). Any remaining parameters indicate the function should have anArgs:section. -
For the returns section:
inspect.Parameter.emptyis 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 noReturns:section. -
The
combined_violationslist 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']\nTrueHints
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.
