Python Comments vs Docstrings Practice Problems & Exercises
Practice: Comments vs Docstrings
← Back to lessonEasy
Access the __doc__ attribute on Python's built-in str.split method. Print the first line of its docstring. Then print whether len.__doc__ contains the word "length".
# Access the docstring of str.split and print the first line
first_line = str.split.__doc__.split('\n')[0]
print(first_line)
# Check if len's docstring mentions "length"
print("length" in len.__doc__)Solution
first_line = str.split.__doc__.split('\n')[0]
print(first_line)
print("length" in len.__doc__)
Why it works: Every function and method in Python carries its docstring as the __doc__ attribute. This is not just decoration — it is executable metadata stored at runtime. You can programmatically inspect, search, and process docstrings. This is the foundation of help(), IDE tooltips, and documentation generators like Sphinx.
Expected Output
Return a list of the words in the string, using sep as the delimiter string.\nTrueHints
Hint 1: Every built-in function has a `__doc__` attribute that returns its docstring as a string.
Hint 2: Access it with dot notation: `some_function.__doc__`.
Write classify_documentation(code_snippet) that identifies whether a code snippet contains a comment, a docstring, or both.
print(classify_documentation("# TODO: fix this later"))
print(classify_documentation('def f():\n """Does stuff."""\n pass'))
print(classify_documentation('def f():\n """Does stuff."""\n # helper\n pass'))
print(classify_documentation("x = 10 # magic number"))
Solution
def classify_documentation(code_snippet: str) -> str:
has_comment = any(
line.lstrip().startswith('#')
for line in code_snippet.split('\n')
)
has_docstring = '"""' in code_snippet or "'''" in code_snippet
if has_comment and has_docstring:
return "both"
elif has_docstring:
return "docstring"
else:
return "comment"
Key distinction: Comments (#) are stripped by the parser and never exist at runtime. Docstrings (triple-quoted strings as the first statement of a module, class, or function) become the __doc__ attribute and persist at runtime. This fundamental difference determines when to use which: comments explain why to future developers reading source; docstrings describe what to users who may never see the source.
def classify_documentation(code_snippet: str) -> str:
"""Given a code snippet string, return 'comment', 'docstring', or 'both'.
Rules:
- If it contains a line starting with # -> has comment
- If it contains triple quotes (triple-double or triple-single) -> has docstring
- Return 'both' if both are present
"""
passExpected Output
comment\ndocstring\nboth\ncommentHints
Hint 1: Check for lines that start with `#` (after stripping whitespace) for comments.
Hint 2: Check for `"""` or `'''` for docstrings.
Predict what happens when you call help() on a function with a docstring versus one without. Then write code to demonstrate the difference programmatically.
def greet(name):
"""Say hello to someone."""
return f"Hello, {name}!"
def mystery(x):
return x * 2
# Print the docstring that help() would show for greet
print(greet.__doc__)
# Print the docstring for mystery
print(mystery.__doc__)
# Show how to handle the None case
print(mystery.__doc__ if mystery.__doc__ else "No docstring found.")Solution
def greet(name):
"""Say hello to someone."""
return f"Hello, {name}!"
def mystery(x):
return x * 2
print(greet.__doc__)
print(mystery.__doc__)
print(mystery.__doc__ if mystery.__doc__ else "No docstring found.")
Output:
greet(name)
Say hello to someone.
None
No docstring found.
Why this matters: help() internally reads __doc__. When there is no docstring, __doc__ is None — not an empty string, not missing, but explicitly None. This is important because you cannot call .strip() or .split() on None. Always check before processing: if func.__doc__: is the safe pattern.
Expected Output
greet(name)\n Say hello to someone.\nNone\nNo docstring found.Hints
Hint 1: `help()` reads the `__doc__` attribute. No docstring means `help()` shows minimal output.
Hint 2: A function without a docstring has `__doc__` set to `None`.
Medium
Write a complete Google-style docstring for calculate_bmi. It must include Args, Returns, Raises, and Examples sections. Then verify your docstring is accessible at runtime.
result = calculate_bmi(70, 1.75)
print(result)
# Verify docstring quality
doc = calculate_bmi.__doc__
print("Args:" in doc)
print("Returns:" in doc)
print("Raises:" in doc)
print("weight_kg" in doc)
Solution
def calculate_bmi(weight_kg: float, height_m: float) -> dict:
"""Calculate Body Mass Index and return BMI with category.
Computes BMI using the standard formula weight / height^2 and
classifies the result into WHO-defined categories.
Args:
weight_kg (float): Body weight in kilograms. Must be positive.
height_m (float): Height in meters. Must be positive.
Returns:
dict: A dictionary with two keys:
- 'bmi' (float): The calculated BMI rounded to 1 decimal.
- 'category' (str): One of 'underweight', 'normal',
'overweight', or 'obese'.
Raises:
ValueError: If weight_kg or height_m is not positive.
Examples:
>>> calculate_bmi(70, 1.75)
{'bmi': 22.9, 'category': 'normal'}
>>> calculate_bmi(90, 1.80)
{'bmi': 27.8, 'category': 'overweight'}
"""
if height_m <= 0:
raise ValueError("Height must be positive.")
if weight_kg <= 0:
raise ValueError("Weight must be positive.")
bmi = weight_kg / (height_m ** 2)
if bmi < 18.5:
category = "underweight"
elif bmi < 25.0:
category = "normal"
elif bmi < 30.0:
category = "overweight"
else:
category = "obese"
return {"bmi": round(bmi, 1), "category": category}
result = calculate_bmi(70, 1.75)
print(result)
doc = calculate_bmi.__doc__
print("Args:" in doc)
print("Returns:" in doc)
print("Raises:" in doc)
print("weight_kg" in doc)
Google-style conventions:
- First line: one-sentence summary (imperative mood: "Calculate", not "Calculates").
- Args: each param on its own line,
name (type): description. - Returns: describe the shape and meaning of the return value.
- Raises: list each exception and when it is raised.
- Examples: use
>>>doctest format so tools likepytest --doctest-modulescan run them automatically.
def calculate_bmi(weight_kg: float, height_m: float) -> dict:
"""WRITE YOUR GOOGLE-STYLE DOCSTRING HERE.
Args:
Returns:
Raises:
Examples:
"""
if height_m <= 0:
raise ValueError("Height must be positive.")
if weight_kg <= 0:
raise ValueError("Weight must be positive.")
bmi = weight_kg / (height_m ** 2)
if bmi < 18.5:
category = "underweight"
elif bmi < 25.0:
category = "normal"
elif bmi < 30.0:
category = "overweight"
else:
category = "obese"
return {"bmi": round(bmi, 1), "category": category}Expected Output
{'bmi': 22.9, 'category': 'normal'}\nTrue\nTrue\nTrue\nTrueHints
Hint 1: Google style uses `Args:`, `Returns:`, `Raises:`, and `Examples:` sections.
Hint 2: Each arg is documented as `name (type): description` indented under `Args:`.
Hint 3: Your docstring should mention all parameters, the return type, and the ValueError.
Implement parse_params(docstring) that extracts parameter names and descriptions from a Google-style docstring's Args: section.
doc1 = """Calculate BMI.
Args:
weight_kg (float): Body weight in kilograms.
height_m (float): Height in meters.
Returns:
dict: BMI result.
"""
doc2 = """Process items.
Args:
items (list): List of items to process.
reverse (bool): If True, process in reverse order.
"""
print(parse_params(doc1))
print(parse_params(doc2))
Solution
import re
def parse_params(docstring: str) -> dict:
params = {}
lines = docstring.split('\n')
in_args = False
for line in lines:
stripped = line.strip()
if stripped == 'Args:':
in_args = True
continue
if in_args:
# Stop at next section or empty non-indented line
if stripped and not line.startswith(' ') and not line.startswith('\t'):
break
if stripped.endswith(':') and not stripped.startswith(' '):
break
# Match param line: name (type): description
match = re.match(r'\s+(\w+)\s*\([^)]*\):\s*(.*)', line)
if match:
params[match.group(1)] = match.group(2).strip()
return params
doc1 = """Calculate BMI.
Args:
weight_kg (float): Body weight in kilograms.
height_m (float): Height in meters.
Returns:
dict: BMI result.
"""
doc2 = """Process items.
Args:
items (list): List of items to process.
reverse (bool): If True, process in reverse order.
"""
print(parse_params(doc1))
print(parse_params(doc2))
How it works: The parser is a simple state machine: once it sees Args:, it enters "argument parsing" mode and matches each indented line against the pattern name (type): description. It stops when it hits a non-indented line or another section header. This is essentially what Sphinx's Napoleon extension does internally to convert Google-style docstrings into reStructuredText.
def parse_params(docstring: str) -> dict:
"""Parse parameter descriptions from a Google-style docstring.
Returns a dict mapping parameter names to their descriptions.
Only parses the 'Args:' section.
Example:
Input docstring with:
Args:
name (str): The user's name.
age (int): The user's age in years.
Returns: {'name': 'The user\'s name.', 'age': 'The user\'s age in years.'}
"""
passExpected Output
{'weight_kg': 'Body weight in kilograms.', 'height_m': 'Height in meters.'}\n{'items': 'List of items to process.', 'reverse': 'If True, process in reverse order.'}Hints
Hint 1: Find the line containing `Args:`, then parse subsequent indented lines.
Hint 2: Each param line looks like: ` name (type): description`.
Hint 3: Stop parsing when you hit a line that is not indented or is another section header like `Returns:`.
Prove that docstrings are accessible at runtime while comments are not. Demonstrate this by writing two functions — one documented with a comment, one with a docstring — and showing what Python retains.
import dis
def with_docstring():
"""This function has a docstring."""
pass
def with_comment():
# This function has a comment.
pass
# Prove docstring is runtime-accessible
print(f"Docstring: {with_docstring.__doc__}")
# Prove comment is NOT runtime-accessible
has_comment_at_runtime = with_comment.__doc__ is not None
print(f"Comment exists at runtime: {has_comment_at_runtime}")
# Docstring is a real Python object
print(f"Docstring is a str: {isinstance(with_docstring.__doc__, str)}")
# Docstrings survive compilation — they are stored in the code object
print(f"Docstring survives: {with_docstring.__code__.co_consts[0] == with_docstring.__doc__}")Solution
import dis
def with_docstring():
"""This function has a docstring."""
pass
def with_comment():
# This function has a comment.
pass
print(f"Docstring: {with_docstring.__doc__}")
has_comment_at_runtime = with_comment.__doc__ is not None
print(f"Comment exists at runtime: {has_comment_at_runtime}")
print(f"Docstring is a str: {isinstance(with_docstring.__doc__, str)}")
print(f"Docstring survives: {with_docstring.__code__.co_consts[0] == with_docstring.__doc__}")
The fundamental difference:
- Comments are stripped during lexical analysis (tokenization). They never make it into bytecode. They exist only in
.pysource files. - Docstrings are compiled into the code object's
co_conststuple and assigned to the function's__doc__attribute. They are realstrobjects that consume memory and can be inspected, tested, and processed at runtime.
This is why docstrings power help(), Sphinx, doctest, and IDE intellisense — they are executable metadata, not decorative text.
Expected Output
Docstring: This function has a docstring.\nComment exists at runtime: False\nDocstring is a str: True\nDocstring survives: TrueHints
Hint 1: Comments are stripped by the parser and never appear in `__doc__` or any runtime attribute.
Hint 2: Docstrings become the `__doc__` attribute — they are real string objects at runtime.
Hint 3: Use `inspect.getsource()` to show that comments only exist in source code, not in compiled bytecode.
Write validate_docstrings(cls) that inspects a class and reports which public methods have docstrings and which do not.
class DataProcessor:
"""A sample data processor."""
def save(self, path):
"""Save data to the given path."""
pass
def load(self, path):
"""Load data from the given path."""
pass
def process(self, data):
pass
def validate(self, data):
pass
result = validate_docstrings(DataProcessor)
print(f"Missing: {result['missing']}")
print(f"Present: {result['present']}")
print(f"Coverage: {result['coverage'] * 100:.1f}%")
Solution
import inspect
def validate_docstrings(cls) -> dict:
missing = []
present = []
for name, method in inspect.getmembers(cls, predicate=inspect.isfunction):
# Skip private/dunder methods
if name.startswith('_'):
continue
if method.__doc__:
present.append(name)
else:
missing.append(name)
total = len(missing) + len(present)
coverage = len(present) / total if total > 0 else 1.0
return {
'missing': missing,
'present': present,
'coverage': coverage,
}
class DataProcessor:
"""A sample data processor."""
def save(self, path):
"""Save data to the given path."""
pass
def load(self, path):
"""Load data from the given path."""
pass
def process(self, data):
pass
def validate(self, data):
pass
result = validate_docstrings(DataProcessor)
print(f"Missing: {result['missing']}")
print(f"Present: {result['present']}")
print(f"Coverage: {result['coverage'] * 100:.1f}%")
Why this matters in production: Documentation coverage is a real metric enforced by tools like interrogate, pydocstyle, and Sphinx's coverage builder. Many teams set a minimum threshold (e.g., 80%) in CI. The pattern here — programmatically inspecting classes via inspect — is exactly how those tools work under the hood.
import inspect
def validate_docstrings(cls) -> dict:
"""Check that all public methods of a class have docstrings.
Returns a dict with:
- 'missing': list of method names without docstrings
- 'present': list of method names with docstrings
- 'coverage': float (0.0 to 1.0) representing percentage with docstrings
"""
passExpected Output
Missing: ['process', 'validate']\nPresent: ['save', 'load']\nCoverage: 50.0%Hints
Hint 1: Use `inspect.getmembers(cls, predicate=inspect.isfunction)` to get all methods.
Hint 2: Filter out private methods (those starting with `_`) unless you want to check them too.
Hint 3: A method has a docstring if its `__doc__` attribute is not `None`.
Hard
Build a parser that converts both Google-style and NumPy-style docstrings into structured dictionaries.
google_doc = """Calculate the weighted average of values.
Computes a weighted mean, falling back to simple mean
if no weights are provided.
Args:
values (list): List of numeric values.
weights (list, optional): Optional list of weights.
Returns:
float: The weighted average.
Raises:
ValueError: If values is empty.
ValueError: If weights length doesn't match values.
"""
numpy_doc = """Compute the distance between two points.
Uses the Euclidean distance formula.
Parameters
----------
point_a : tuple
First point as (x, y) tuple.
point_b : tuple
Second point as (x, y) tuple.
Returns
-------
float
Euclidean distance between the points.
"""
result1 = docstring_to_dict(google_doc, style="google")
print(f"Summary: {result1['summary']}")
print(f"Params: {len(result1['params'])}")
for name, info in result1['params'].items():
print(f" {name}: {info['description']}")
print(f"Returns: {result1['returns']['type']} - {result1['returns']['description']}")
print(f"Raises: {result1['raises'][0]['exception']}")
print("---")
result2 = docstring_to_dict(numpy_doc, style="numpy")
print(f"Summary: {result2['summary']}")
print(f"Params: {len(result2['params'])}")
for name, info in result2['params'].items():
print(f" {name}: {info['description']}")
print(f"Returns: {result2['returns']['type']} - {result2['returns']['description']}")
Solution
import re
def docstring_to_dict(docstring: str, style: str = "google") -> dict:
lines = docstring.strip().split('\n')
result = {
'summary': '',
'description': '',
'params': {},
'returns': None,
'raises': [],
}
# Extract summary (first non-empty line)
result['summary'] = lines[0].strip()
if style == "google":
return _parse_google(lines, result)
else:
return _parse_numpy(lines, result)
def _parse_google(lines, result):
section = None
desc_lines = []
current_param = None
# Find where description ends and sections begin
i = 1
while i < len(lines):
stripped = lines[i].strip()
if stripped in ('Args:', 'Returns:', 'Raises:', 'Examples:'):
break
desc_lines.append(stripped)
i += 1
result['description'] = ' '.join(l for l in desc_lines if l).strip()
while i < len(lines):
stripped = lines[i].strip()
if stripped == 'Args:':
section = 'args'
i += 1
continue
elif stripped == 'Returns:':
section = 'returns'
i += 1
continue
elif stripped == 'Raises:':
section = 'raises'
i += 1
continue
elif stripped.endswith(':') and not lines[i].startswith(' '):
section = None
i += 1
continue
if section == 'args':
match = re.match(r'\s+(\w+)\s*\(([^)]*)\):\s*(.*)', lines[i])
if match:
name, typ, desc = match.group(1), match.group(2), match.group(3)
result['params'][name] = {'type': typ.strip(), 'description': desc.strip()}
current_param = name
elif section == 'returns':
match = re.match(r'\s+(\w+):\s*(.*)', lines[i])
if match:
result['returns'] = {
'type': match.group(1).strip(),
'description': match.group(2).strip(),
}
elif section == 'raises':
match = re.match(r'\s+(\w+):\s*(.*)', lines[i])
if match:
result['raises'].append({
'exception': match.group(1).strip(),
'description': match.group(2).strip(),
})
i += 1
return result
def _parse_numpy(lines, result):
desc_lines = []
section = None
i = 1
current_param_name = None
current_param_type = None
# Collect description until first section header
while i < len(lines):
if i + 1 < len(lines) and re.match(r'^-+$', lines[i + 1].strip()):
break
desc_lines.append(lines[i].strip())
i += 1
result['description'] = ' '.join(l for l in desc_lines if l).strip()
while i < len(lines):
stripped = lines[i].strip()
# Check for section header (word followed by dashed underline)
if i + 1 < len(lines) and re.match(r'^-+$', lines[i + 1].strip()):
if 'parameter' in stripped.lower():
section = 'params'
elif 'return' in stripped.lower():
section = 'returns'
elif 'raise' in stripped.lower():
section = 'raises'
i += 2 # Skip header and underline
continue
if section == 'params':
# Param definition line: "name : type"
match = re.match(r'^(\w+)\s*:\s*(.*)', stripped)
if match and not lines[i].startswith(' '):
current_param_name = match.group(1)
current_param_type = match.group(2).strip()
result['params'][current_param_name] = {
'type': current_param_type,
'description': '',
}
elif current_param_name and stripped:
# Description line (indented)
existing = result['params'][current_param_name]['description']
if existing:
result['params'][current_param_name]['description'] += ' ' + stripped
else:
result['params'][current_param_name]['description'] = stripped
elif section == 'returns':
if not lines[i].startswith(' ') and stripped:
current_return_type = stripped
result['returns'] = {'type': current_return_type, 'description': ''}
elif result['returns'] and stripped:
existing = result['returns']['description']
if existing:
result['returns']['description'] += ' ' + stripped
else:
result['returns']['description'] = stripped
elif section == 'raises':
match = re.match(r'^(\w+)', stripped)
if match and not lines[i].startswith(' '):
result['raises'].append({
'exception': match.group(1),
'description': '',
})
elif result['raises'] and stripped:
result['raises'][-1]['description'] += stripped
i += 1
return result
google_doc = """Calculate the weighted average of values.
Computes a weighted mean, falling back to simple mean
if no weights are provided.
Args:
values (list): List of numeric values.
weights (list, optional): Optional list of weights.
Returns:
float: The weighted average.
Raises:
ValueError: If values is empty.
ValueError: If weights length doesn't match values.
"""
numpy_doc = """Compute the distance between two points.
Uses the Euclidean distance formula.
Parameters
----------
point_a : tuple
First point as (x, y) tuple.
point_b : tuple
Second point as (x, y) tuple.
Returns
-------
float
Euclidean distance between the points.
"""
result1 = docstring_to_dict(google_doc, style="google")
print(f"Summary: {result1['summary']}")
print(f"Params: {len(result1['params'])}")
for name, info in result1['params'].items():
print(f" {name}: {info['description']}")
print(f"Returns: {result1['returns']['type']} - {result1['returns']['description']}")
print(f"Raises: {result1['raises'][0]['exception']}")
print("---")
result2 = docstring_to_dict(numpy_doc, style="numpy")
print(f"Summary: {result2['summary']}")
print(f"Params: {len(result2['params'])}")
for name, info in result2['params'].items():
print(f" {name}: {info['description']}")
print(f"Returns: {result2['returns']['type']} - {result2['returns']['description']}")
Architecture insight: This is essentially a simplified version of what Napoleon (Sphinx extension) and griffe (mkdocstrings parser) do. Real parsers also handle multi-line descriptions, nested types like dict[str, list[int]], continuation indentation, and cross-references. The state-machine approach (tracking which section you are in) is the standard pattern for docstring parsers.
import re
def docstring_to_dict(docstring: str, style: str = "google") -> dict:
"""Parse a docstring into a structured dictionary.
Supports both Google and NumPy styles.
Returns a dict with keys:
- 'summary': first line of the docstring
- 'description': extended description (if any)
- 'params': dict mapping param name -> {'type': ..., 'description': ...}
- 'returns': {'type': ..., 'description': ...} or None
- 'raises': list of {'exception': ..., 'description': ...}
"""
passExpected Output
Summary: Calculate the weighted average of values.\nParams: 2\n values: List of numeric values.\n weights: Optional list of weights.\nReturns: float - The weighted average.\nRaises: ValueError\n---\nSummary: Compute the distance between two points.\nParams: 2\n point_a: First point as (x, y) tuple.\n point_b: Second point as (x, y) tuple.\nReturns: float - Euclidean distance between the points.Hints
Hint 1: For Google style: sections are `Args:`, `Returns:`, `Raises:` followed by indented content.
Hint 2: For NumPy style: sections are `Parameters`, `Returns`, `Raises` followed by a dashed underline `----------`.
Hint 3: Parse the summary as everything before the first blank line. The description is everything between summary and the first section.
Create a @auto_docstring decorator that generates Google-style docstrings from type hints. If the function already has a one-line docstring, use it as the summary; otherwise, use the function name in title case.
@auto_docstring
def calculate(x: int, y: int) -> float:
return x / y
@auto_docstring
def transform(data: list, strategy: str = "default", verbose: bool = False) -> dict:
"""Transform data using the given strategy."""
return {}
print("calculate:")
print(calculate.__doc__)
print("---")
print("transform:")
print(transform.__doc__)
Solution
import inspect
import functools
def auto_docstring(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
sig = inspect.signature(func)
hints = func.__annotations__
# Use existing docstring as summary, or generate from name
existing = func.__doc__
if existing:
summary = existing.strip().split('\n')[0]
else:
summary = func.__name__.replace('_', ' ').capitalize() + '.'
# Build Args section
args_lines = []
for name, param in sig.parameters.items():
type_name = hints.get(name, type(None)).__name__ if name in hints else 'Any'
args_lines.append(f" {name} ({type_name}): No description provided.")
# Build Returns section
returns_line = None
if 'return' in hints:
ret_type = hints['return'].__name__
returns_line = f" {ret_type}: No description provided."
# Assemble docstring
parts = [f" {summary}", ""]
if args_lines:
parts.append(" Args:")
for line in args_lines:
parts.append(f" {line}")
parts.append("")
if returns_line:
parts.append(" Returns:")
parts.append(f" {returns_line}")
wrapper.__doc__ = '\n'.join(parts)
return wrapper
@auto_docstring
def calculate(x: int, y: int) -> float:
return x / y
@auto_docstring
def transform(data: list, strategy: str = "default", verbose: bool = False) -> dict:
"""Transform data using the given strategy."""
return {}
print("calculate:")
print(calculate.__doc__)
print("---")
print("transform:")
print(transform.__doc__)
How it works: The decorator uses inspect.signature() to extract parameter names and __annotations__ to get type hints. It constructs a Google-style docstring and assigns it to the wrapper's __doc__. functools.wraps copies over the original __name__, __module__, and other attributes.
Production parallel: This is how tools like pydantic's BaseModel, FastAPI's route decorators, and click's CLI decorators auto-generate documentation from type hints. The pattern of "reflect on annotations at definition time and produce metadata" is central to modern Python metaprogramming.
import inspect
import functools
def auto_docstring(func):
"""Decorator that auto-generates a Google-style docstring
from the function's type hints and parameter names.
If the function already has a docstring, prepend the
auto-generated parameter info to it.
"""
passExpected Output
calculate:\n Calculate.\n\n Args:\n x (int): No description provided.\n y (int): No description provided.\n\n Returns:\n float: No description provided.\n---\ntransform:\n Transform data using the given strategy.\n\n Args:\n data (list): No description provided.\n strategy (str): No description provided.\n verbose (bool): No description provided.\n\n Returns:\n dict: No description provided.Hints
Hint 1: Use `inspect.signature(func)` to get parameter names and annotations.
Hint 2: Use `func.__annotations__` or `sig.return_annotation` for the return type.
Hint 3: Use `functools.wraps(func)` to preserve the original function metadata, then override `__doc__`.
Build a comprehensive documentation coverage checker that analyzes a module and reports exactly which public items are documented and which are not. Test it against a sample module you create dynamically.
import types
import inspect
def check_doc_coverage(module) -> dict:
result = {
'module_doc': module.__doc__ is not None and len(module.__doc__.strip()) > 0,
'functions': {},
'classes': {},
'total_documented': 0,
'total_items': 0,
'coverage_pct': 0.0,
}
documented = 0
total = 0
# Count module docstring
total += 1
if result['module_doc']:
documented += 1
# Check public functions
for name, obj in inspect.getmembers(module, inspect.isfunction):
if name.startswith('_'):
continue
has_doc = obj.__doc__ is not None and len(obj.__doc__.strip()) > 0
result['functions'][name] = has_doc
total += 1
if has_doc:
documented += 1
# Check public classes and their methods
for name, cls in inspect.getmembers(module, inspect.isclass):
if name.startswith('_'):
continue
cls_has_doc = cls.__doc__ is not None and len(cls.__doc__.strip()) > 0
methods = {}
total += 1
if cls_has_doc:
documented += 1
for mname, method in inspect.getmembers(cls, inspect.isfunction):
if mname.startswith('_'):
continue
m_has_doc = method.__doc__ is not None and len(method.__doc__.strip()) > 0
methods[mname] = m_has_doc
total += 1
if m_has_doc:
documented += 1
result['classes'][name] = {
'has_doc': cls_has_doc,
'methods': methods,
}
result['total_documented'] = documented
result['total_items'] = total
result['coverage_pct'] = (documented / total * 100) if total > 0 else 100.0
return result
# Create a sample module dynamically
sample = types.ModuleType("sample_module")
sample.__doc__ = "A sample module for testing coverage."
def public_func():
"""A well-documented function."""
pass
def another_func():
pass
class MyClass:
"""A documented class."""
def public_method(self):
"""A documented method."""
pass
def undocumented_method(self):
pass
class BareMethods:
def do_something(self):
pass
# Attach to module
sample.public_func = public_func
sample.another_func = another_func
sample.MyClass = MyClass
sample.BareMethods = BareMethods
# Run coverage check
report = check_doc_coverage(sample)
print(f"Module docstring: {report['module_doc']}")
print(f"\nFunctions:")
for name, has_doc in report['functions'].items():
print(f" {name}: {has_doc}")
print(f"\nClasses:")
for cname, info in report['classes'].items():
print(f" {cname}:")
print(f" class doc: {info['has_doc']}")
for mname, has_doc in info['methods'].items():
print(f" {mname}: {has_doc}")
d = report['total_documented']
t = report['total_items']
print(f"\nCoverage: {d}/{t} = {report['coverage_pct']:.1f}%")Solution
The solution is embedded in the starter code above. Here is the core logic explained:
import inspect
import types
def check_doc_coverage(module) -> dict:
result = {
'module_doc': module.__doc__ is not None and len(module.__doc__.strip()) > 0,
'functions': {},
'classes': {},
'total_documented': 0,
'total_items': 0,
'coverage_pct': 0.0,
}
documented = 0
total = 0
# Module docstring counts as one item
total += 1
if result['module_doc']:
documented += 1
# Public functions (skip _private)
for name, obj in inspect.getmembers(module, inspect.isfunction):
if name.startswith('_'):
continue
has_doc = obj.__doc__ is not None and len(obj.__doc__.strip()) > 0
result['functions'][name] = has_doc
total += 1
if has_doc:
documented += 1
# Public classes + their public methods
for name, cls in inspect.getmembers(module, inspect.isclass):
if name.startswith('_'):
continue
cls_has_doc = cls.__doc__ is not None and len(cls.__doc__.strip()) > 0
methods = {}
total += 1
if cls_has_doc:
documented += 1
for mname, method in inspect.getmembers(cls, inspect.isfunction):
if mname.startswith('_'):
continue
m_has_doc = method.__doc__ is not None and len(method.__doc__.strip()) > 0
methods[mname] = m_has_doc
total += 1
if m_has_doc:
documented += 1
result['classes'][name] = {'has_doc': cls_has_doc, 'methods': methods}
result['total_documented'] = documented
result['total_items'] = total
result['coverage_pct'] = (documented / total * 100) if total > 0 else 100.0
return result
Production tools that do this:
interrogate: CLI tool that checks docstring coverage and can enforce a minimum threshold in CI (interrogate --fail-under 80).pydocstyle: Checks docstring style compliance (PEP 257, Google, NumPy).- Sphinx
coverage: The Sphinx documentation builder has acoverageextension that reports undocumented API items.
Key insight: The inspect module is Python's reflection API. getmembers() with predicates like isfunction and isclass gives you the same view of a module that dir() does, but with filtering. This is the foundation of every documentation tool, linter, and IDE plugin that analyzes Python code structure.
import inspect
import types
def check_doc_coverage(module) -> dict:
"""Analyze documentation coverage of a module.
Checks all public functions, classes, and methods for docstrings.
Returns a dict with:
- 'module_doc': bool (does the module have a docstring?)
- 'functions': dict mapping name -> bool (has docstring)
- 'classes': dict mapping class_name -> {
'has_doc': bool,
'methods': dict mapping method_name -> bool
}
- 'total_documented': int
- 'total_items': int
- 'coverage_pct': float
"""
passExpected Output
Module docstring: True\n\nFunctions:\n public_func: True\n another_func: False\n\nClasses:\n MyClass:\n class doc: True\n public_method: True\n undocumented_method: False\n BareMethods:\n class doc: False\n do_something: False\n\nCoverage: 4/8 = 50.0%Hints
Hint 1: Use `inspect.getmembers()` with predicates like `inspect.isfunction` and `inspect.isclass`.
Hint 2: For classes, iterate their methods with `inspect.getmembers(cls, predicate=inspect.isfunction)`.
Hint 3: Filter out names starting with `_` to focus on public API.
Hint 4: Count the module docstring, each function, each class, and each method as separate items.
