Python Keyword-Only Practice Problems & Exercises
Practice: Keyword-Only and Positional-Only
← Back to lessonEasy
Write a function greet where name is a regular parameter but loud and greeting are keyword-only (enforced by *). Return "greeting, name" — if loud is True, return the string in uppercase.
Test with greet("Alice") and greet("Bob", loud=True).
def greet(name, *, loud=False, greeting="Hello"):
message = f"{greeting}, {name}"
if loud:
return message.upper()
return message
print(greet("Alice"))
print(greet("Bob", loud=True))Solution
def greet(name, *, loud=False, greeting="Hello"):
message = f"{greeting}, {name}"
if loud:
return message.upper()
return message
The * separator prevents callers from writing greet("Alice", True). They must write greet("Alice", loud=True), which is self-documenting. This is exactly the pattern Python's built-in print() uses for sep, end, file, and flush — all keyword-only so you never accidentally pass a separator as the second print argument.
def greet(name, *, loud=False, greeting="Hello"):
"""Return a greeting string.
If loud is True, return the string in uppercase.
loud and greeting MUST be keyword-only.
"""
# TODO: implement
passExpected Output
Hello, Alice
HELLO, BOBHints
Hint 1: The bare * is already in the signature — everything after it must be passed by name.
Hint 2: Build the message string first, then call .upper() if loud is True.
Write a function add where both a and b are positional-only (enforced by /). Return their sum. Then demonstrate that calling add(a=1, b=2) raises a TypeError.
def add(a, b, /):
return a + b
print(add(3, 4))
try:
add(a=1, b=2)
except TypeError as e:
print(f"TypeError caught: {e}")Solution
def add(a, b, /):
return a + b
Positional-only parameters let you rename internals freely. If you later rename a to left and b to right, no caller breaks because nobody could have written add(a=1, b=2). This is exactly why len(obj, /) uses positional-only — the parameter name obj is an implementation detail, not part of the public API contract.
def add(a, b, /):
"""Return a + b.
a and b must be positional-only.
Calling add(a=1, b=2) should raise TypeError.
"""
# TODO: implement
passExpected Output
7
TypeError caught: add() got some positional-only arguments passed as keyword arguments: 'a, b'Hints
Hint 1: The / is already in the signature — parameters before it are positional-only.
Hint 2: To test the TypeError, use a try/except block.
Given the signature def process(a, b, /, c, d=10, *, e, f=99), classify each parameter as 'positional-only', 'regular', or 'keyword-only'.
def classify_params():
return {
'a': 'positional-only',
'b': 'positional-only',
'c': 'regular',
'd': 'regular',
'e': 'keyword-only',
'f': 'keyword-only',
}
ANSWER_KEY = {
'a': 'positional-only',
'b': 'positional-only',
'c': 'regular',
'd': 'regular',
'e': 'keyword-only',
'f': 'keyword-only',
}
result = classify_params()
wrong = [k for k in ANSWER_KEY if result.get(k) != ANSWER_KEY[k]]
if wrong:
for k in wrong:
print(f"Wrong: {k} -> got {result.get(k)}, expected {ANSWER_KEY[k]}")
else:
print("All correct!")Solution
def classify_params():
return {
'a': 'positional-only', # before /
'b': 'positional-only', # before /
'c': 'regular', # between / and *
'd': 'regular', # between / and * (has default)
'e': 'keyword-only', # after *
'f': 'keyword-only', # after * (has default)
}
The mental model is two fences. The / fence closes the positional-only zone. The * fence opens the keyword-only zone. Everything between the two fences is regular (flexible). Defaults do not affect the parameter kind — d=10 is still regular, and f=99 is still keyword-only. The kind is determined solely by position relative to / and *.
def classify_params():
"""Given this signature:
def process(a, b, /, c, d=10, *, e, f=99)
Return a dict mapping each parameter name
to its kind: 'positional-only', 'regular',
or 'keyword-only'.
"""
# TODO: implement
passExpected Output
All correct!Hints
Hint 1: Parameters before / are positional-only. Between / and * are regular. After * are keyword-only.
Hint 2: a and b are before /, so positional-only. c and d are between / and *, so regular. e and f are after *, so keyword-only.
Write connect where timeout is a required keyword-only parameter (no default) and retries is an optional keyword-only parameter (default 3). Demonstrate that omitting timeout raises a clear TypeError.
def connect(host, port, *, timeout, retries=3):
return {
'host': host,
'port': port,
'timeout': timeout,
'retries': retries,
}
print(connect("localhost", 5432, timeout=30))
try:
connect("localhost", 5432)
except TypeError as e:
print(f"TypeError caught: {e}")Solution
def connect(host, port, *, timeout, retries=3):
return {
'host': host,
'port': port,
'timeout': timeout,
'retries': retries,
}
Required keyword-only parameters are one of the most powerful API design tools in Python. By omitting the default for timeout, you force every caller to explicitly choose a timeout value. This prevents a dangerous default (like no timeout at all) from silently causing production hangs. The TypeError message is specific: missing 1 required keyword-only argument: 'timeout' — it tells the caller exactly what they forgot.
def connect(host, port, *, timeout, retries=3):
"""Simulate a connection.
timeout is a REQUIRED keyword-only argument.
Return a dict with all connection parameters.
"""
# TODO: implement
passExpected Output
{'host': 'localhost', 'port': 5432, 'timeout': 30, 'retries': 3}
TypeError caught: connect() missing 1 required keyword-only argument: 'timeout'Hints
Hint 1: A keyword-only parameter without a default value is required — callers must pass it by name.
Hint 2: Return a dict with the four parameter values. Test the missing-argument case with try/except.
Medium
Implement create_record that uses all three parameter zones: positional-only (id, name), regular (category), and keyword-only (priority, tags). Demonstrate valid calls and both types of TypeError.
def create_record(id, name, /, category="general", *, priority, tags=None):
if tags is None:
tags = []
return {
'id': id,
'name': name,
'category': category,
'priority': priority,
'tags': tags,
}
# Valid: full specification
print(create_record(1, "Fix bug", "engineering", priority="high", tags=["backend", "urgent"]))
# Valid: minimal — uses defaults for category and tags
print(create_record(2, "Write docs", priority="low"))
# Invalid: positional-only passed as keyword
try:
create_record(id=3, name="Oops", priority="medium")
except TypeError as e:
print(f"TypeError caught: positional-only arguments passed as keyword")
# Invalid: missing required keyword-only
try:
create_record(4, "No priority")
except TypeError as e:
print(f"TypeError caught: missing required keyword-only argument")Solution
def create_record(id, name, /, category="general", *, priority, tags=None):
if tags is None:
tags = []
return {
'id': id,
'name': name,
'category': category,
'priority': priority,
'tags': tags,
}
This signature communicates three distinct levels of intent. id and name are positional-only because they are always the first two things you pass — naming them adds no clarity and locks the API to those parameter names. category is regular because callers might want to pass it positionally for brevity or as a keyword for clarity. priority is required keyword-only because it is a critical decision that must always be explicit. The None sentinel for tags avoids the mutable default argument trap.
def create_record(id, name, /, category="general", *, priority, tags=None):
"""Create a record dict.
- id and name: positional-only
- category: regular (positional or keyword)
- priority: required keyword-only
- tags: optional keyword-only (default None -> empty list)
Return the record as a dict.
"""
# TODO: implement
passExpected Output
{'id': 1, 'name': 'Fix bug', 'category': 'engineering', 'priority': 'high', 'tags': ['backend', 'urgent']}
{'id': 2, 'name': 'Write docs', 'category': 'general', 'priority': 'low', 'tags': []}
TypeError caught: positional-only arguments passed as keyword
TypeError caught: missing required keyword-only argumentHints
Hint 1: Use the None sentinel pattern for tags: if tags is None, set it to [] inside the function body.
Hint 2: Test four cases: full call, minimal call, positional-only violation, and missing keyword-only.
Write a function that takes any callable and returns a dictionary mapping each parameter name to a human-readable kind string. Use inspect.signature and the Parameter.kind constants.
Test with sorted and a custom function that uses all five parameter kinds.
import inspect
def describe_signature(func):
kind_map = {
inspect.Parameter.POSITIONAL_ONLY: 'positional-only',
inspect.Parameter.POSITIONAL_OR_KEYWORD: 'regular',
inspect.Parameter.VAR_POSITIONAL: '*args',
inspect.Parameter.KEYWORD_ONLY: 'keyword-only',
inspect.Parameter.VAR_KEYWORD: '**kwargs',
}
sig = inspect.signature(func)
return {
name: kind_map[param.kind]
for name, param in sig.parameters.items()
}
print(f"sorted: {describe_signature(sorted)}")
def custom(x, /, y, *args, z, **kw):
pass
print(f"custom: {describe_signature(custom)}")Solution
import inspect
def describe_signature(func):
kind_map = {
inspect.Parameter.POSITIONAL_ONLY: 'positional-only',
inspect.Parameter.POSITIONAL_OR_KEYWORD: 'regular',
inspect.Parameter.VAR_POSITIONAL: '*args',
inspect.Parameter.KEYWORD_ONLY: 'keyword-only',
inspect.Parameter.VAR_KEYWORD: '**kwargs',
}
sig = inspect.signature(func)
return {
name: kind_map[param.kind]
for name, param in sig.parameters.items()
}
inspect.signature is the programmatic way to read the separator rules. Each parameter has a .kind attribute that is one of five constants. This is how tools like IDEs, linters, and documentation generators understand function signatures. Notice that sorted has iterable as positional-only and key/reverse as keyword-only — exactly matching the (iterable, /, *, key=None, reverse=False) signature you see in the docs.
import inspect
def describe_signature(func):
"""Return a dict mapping parameter names to their kind.
Use inspect.Parameter kind constants and return
human-readable strings:
- POSITIONAL_ONLY -> 'positional-only'
- POSITIONAL_OR_KEYWORD -> 'regular'
- VAR_POSITIONAL -> '*args'
- KEYWORD_ONLY -> 'keyword-only'
- VAR_KEYWORD -> '**kwargs'
"""
# TODO: implement
passExpected Output
sorted: {'iterable': 'positional-only', 'key': 'keyword-only', 'reverse': 'keyword-only'}
custom: {'x': 'positional-only', 'y': 'regular', 'args': '*args', 'z': 'keyword-only', 'kw': '**kwargs'}Hints
Hint 1: Use inspect.signature(func).parameters to get an OrderedDict of Parameter objects.
Hint 2: Each Parameter has a .kind attribute — compare it to inspect.Parameter.POSITIONAL_ONLY, etc.
Design a logger configuration function that uses positional-only for the logger name, regular for the log level, and keyword-only for all boolean flags and file settings. This prevents accidental misconfiguration like configure_logger("myapp", "DEBUG", True, True, None, 10000000, 3).
def configure_logger(
name,
/,
level="INFO",
*,
log_to_file=False,
log_to_console=True,
filename=None,
max_bytes=10_000_000,
backup_count=3,
):
if log_to_file and filename is None:
filename = name + ".log"
return {
'name': name,
'level': level,
'log_to_file': log_to_file,
'log_to_console': log_to_console,
'filename': filename,
'max_bytes': max_bytes,
'backup_count': backup_count,
}
print(configure_logger("myapp", "DEBUG", log_to_file=True))
print(configure_logger("worker"))Solution
def configure_logger(
name,
/,
level="INFO",
*,
log_to_file=False,
log_to_console=True,
filename=None,
max_bytes=10_000_000,
backup_count=3,
):
if log_to_file and filename is None:
filename = name + ".log"
return {
'name': name,
'level': level,
'log_to_file': log_to_file,
'log_to_console': log_to_console,
'filename': filename,
'max_bytes': max_bytes,
'backup_count': backup_count,
}
This is production-grade API design. The positional-only name means you can rename the parameter later without breaking callers. The regular level allows both configure_logger("myapp", "DEBUG") and configure_logger("myapp", level="DEBUG") — reasonable since level is common and unambiguous. All boolean flags and numeric settings are keyword-only, making every call site self-documenting. Compare configure_logger("myapp", "DEBUG", True, True, None, 10000000, 3) (impossible now) with the enforced configure_logger("myapp", "DEBUG", log_to_file=True).
def configure_logger(
name,
/,
level="INFO",
*,
log_to_file=False,
log_to_console=True,
filename=None,
max_bytes=10_000_000,
backup_count=3,
):
"""Build a logger configuration dict.
- name: positional-only (implementation detail)
- level: regular (commonly passed positionally)
- All flags and file settings: keyword-only
If log_to_file is True but filename is None,
default filename to name + '.log'.
Return the configuration as a dict.
"""
# TODO: implement
passExpected Output
{'name': 'myapp', 'level': 'DEBUG', 'log_to_file': True, 'log_to_console': True, 'filename': 'myapp.log', 'max_bytes': 10000000, 'backup_count': 3}
{'name': 'worker', 'level': 'INFO', 'log_to_file': False, 'log_to_console': True, 'filename': None, 'max_bytes': 10000000, 'backup_count': 3}Hints
Hint 1: Check if log_to_file is True and filename is None — if so, set filename to name + ".log".
Hint 2: Return a dict containing all parameter values.
Given def f(a, b, /, c, *, d, e=10), predict the outcome of six different calls. Return 'ok' for valid calls and a short error description for TypeError cases.
def f(a, b, /, c, *, d, e=10):
pass
def predict_errors():
return {
'call_1': 'ok', # f(1, 2, 3, d=4) — all rules satisfied
'call_2': 'ok', # f(1, 2, c=3, d=4) — c is regular, can be keyword
'call_3': 'positional-only as keyword', # f(1, b=2, c=3, d=4) — b is positional-only
'call_4': 'keyword-only passed positionally', # f(1, 2, 3, 4) — d must be keyword
'call_5': 'unexpected keyword argument', # f(1, 2, 3, d=4, e=5, f=6) — f is not a parameter
'call_6': 'missing required keyword-only', # f(1, 2) — d is required
}
ANSWER_KEY = {
'call_1': 'ok',
'call_2': 'ok',
'call_3': 'positional-only as keyword',
'call_4': 'keyword-only passed positionally',
'call_5': 'unexpected keyword argument',
'call_6': 'missing required keyword-only',
}
# Verify predictions match actual behavior
test_calls = [
(lambda: f(1, 2, 3, d=4), 'call_1'),
(lambda: f(1, 2, c=3, d=4), 'call_2'),
(lambda: f(1, b=2, c=3, d=4), 'call_3'),
(lambda: f(1, 2, 3, 4), 'call_4'),
(lambda: f(1, 2, 3, d=4, e=5, f=6), 'call_5'),
(lambda: f(1, 2), 'call_6'),
]
predictions = predict_errors()
all_correct = True
for call_fn, label in test_calls:
try:
call_fn()
actual = 'ok'
except TypeError:
actual = predictions[label] # trust our prediction category
if predictions[label] != ANSWER_KEY[label]:
print(f"Wrong prediction for {label}: got '{predictions[label]}', expected '{ANSWER_KEY[label]}'")
all_correct = False
if all_correct:
print("All predictions correct!")Solution
def predict_errors():
return {
'call_1': 'ok', # f(1, 2, 3, d=4) — valid
'call_2': 'ok', # f(1, 2, c=3, d=4) — c is regular
'call_3': 'positional-only as keyword', # b is before /, cannot be keyword
'call_4': 'keyword-only passed positionally', # d is after *, cannot be positional
'call_5': 'unexpected keyword argument', # f is not in the signature at all
'call_6': 'missing required keyword-only', # d has no default, must be provided
}
Understanding error messages is a diagnostic skill. Each TypeError has a distinct pattern: "positional-only arguments passed as keyword arguments" (call 3), "takes N positional arguments but M were given" (call 4), "unexpected keyword argument" (call 5), and "missing required keyword-only argument" (call 6). When you see these in production logs, you can immediately pinpoint which parameter zone rule was violated.
def predict_errors():
"""For each call below, predict whether it succeeds
or raises TypeError. Return a dict mapping call
labels to 'ok' or the TypeError reason.
Signature: def f(a, b, /, c, *, d, e=10)
Calls:
1. f(1, 2, 3, d=4)
2. f(1, 2, c=3, d=4)
3. f(1, b=2, c=3, d=4)
4. f(1, 2, 3, 4)
5. f(1, 2, 3, d=4, e=5, f=6)
6. f(1, 2)
"""
# TODO: return dict mapping 'call_1' through 'call_6'
# to 'ok' or a short error description
passExpected Output
All predictions correct!Hints
Hint 1: b is positional-only (before /), so b=2 as a keyword is invalid.
Hint 2: d is required keyword-only (after *, no default). Passing it positionally or omitting it is a TypeError.
Hard
Write a decorator validate_keyword_only_types that accepts type specifications and validates only keyword-only arguments at call time. It should raise TypeError with a clear message if a keyword-only argument has the wrong type.
import inspect
from functools import wraps
def validate_keyword_only_types(**type_specs):
def decorator(func):
sig = inspect.signature(func)
# Identify which params are keyword-only
kw_only_params = {
name for name, param in sig.parameters.items()
if param.kind == inspect.Parameter.KEYWORD_ONLY
}
@wraps(func)
def wrapper(*args, **kwargs):
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
for param_name, expected_type in type_specs.items():
if param_name in kw_only_params and param_name in bound.arguments:
value = bound.arguments[param_name]
if not isinstance(value, expected_type):
raise TypeError(
f"{param_name} must be {expected_type.__name__}, "
f"got {type(value).__name__}"
)
return func(*args, **kwargs)
return wrapper
return decorator
@validate_keyword_only_types(timeout=int, retries=int)
def connect(host, port, *, timeout, retries=3):
return {'host': host, 'port': port, 'timeout': timeout, 'retries': retries}
print(connect("localhost", 5432, timeout=30, retries=5))
try:
connect("localhost", 5432, timeout="thirty")
except TypeError as e:
print(f"TypeError: {e}")
try:
connect("localhost", 5432, timeout=30, retries=3.5)
except TypeError as e:
print(f"TypeError: {e}")Solution
import inspect
from functools import wraps
def validate_keyword_only_types(**type_specs):
def decorator(func):
sig = inspect.signature(func)
kw_only_params = {
name for name, param in sig.parameters.items()
if param.kind == inspect.Parameter.KEYWORD_ONLY
}
@wraps(func)
def wrapper(*args, **kwargs):
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
for param_name, expected_type in type_specs.items():
if param_name in kw_only_params and param_name in bound.arguments:
value = bound.arguments[param_name]
if not isinstance(value, expected_type):
raise TypeError(
f"{param_name} must be {expected_type.__name__}, "
f"got {type(value).__name__}"
)
return func(*args, **kwargs)
return wrapper
return decorator
This decorator leverages inspect.signature and sig.bind to understand parameter kinds at runtime. sig.bind(*args, **kwargs) resolves all positional and keyword arguments to their parameter names, and apply_defaults() fills in missing defaults. By checking .kind == KEYWORD_ONLY, we only validate the parameters the decorator was designed for. This pattern is used in production frameworks like FastAPI and Pydantic to enforce type constraints on specific parameter categories.
import inspect
from functools import wraps
def validate_keyword_only_types(**type_specs):
"""Decorator that validates types of keyword-only arguments.
Usage:
@validate_keyword_only_types(timeout=int, retries=int)
def connect(host, port, *, timeout, retries=3):
...
Should raise TypeError if a keyword-only arg has wrong type.
Should only validate keyword-only parameters, skip others.
"""
# TODO: implement
passExpected Output
{'host': 'localhost', 'port': 5432, 'timeout': 30, 'retries': 5}
TypeError: timeout must be int, got str
TypeError: retries must be int, got floatHints
Hint 1: Use inspect.signature to find which parameters are KEYWORD_ONLY.
Hint 2: In the wrapper, check bound.arguments for each keyword-only param listed in type_specs.
Evolve an API through three phases: the original version with all-regular parameters, a redesigned version with proper parameter zones, and a compatibility wrapper. This simulates real-world API evolution.
def search_v1(query, max_results=10, sort_by="relevance", include_metadata=False):
return {
'query': query,
'max_results': max_results,
'sort_by': sort_by,
'include_metadata': include_metadata,
}
def search_v2(query, /, max_results=10, *, sort_by="relevance", include_metadata=False, page=1):
return {
'query': query,
'max_results': max_results,
'sort_by': sort_by,
'include_metadata': include_metadata,
'page': page,
}
def search_compat(*args, **kwargs):
"""Bridges old v1 calling conventions to v2."""
import warnings
# Handle old positional calls like search_v1("python", 5, "date", True)
positional_names = ['query', 'max_results', 'sort_by', 'include_metadata']
resolved = {}
for i, val in enumerate(args):
if i < len(positional_names):
resolved[positional_names[i]] = val
resolved.update(kwargs)
query = resolved.pop('query')
return search_v2(query, **resolved)
# v1 style
print(f"v1: {search_v1('python', 5, 'date')}")
# v2 style
print(f"v2: {search_v2('python', 5, sort_by='date')}")
# Compat bridges old calls to new API
print(f"compat: {search_compat('python', 5, 'date')}")Solution
def search_v2(query, /, max_results=10, *, sort_by="relevance", include_metadata=False, page=1):
return {
'query': query,
'max_results': max_results,
'sort_by': sort_by,
'include_metadata': include_metadata,
'page': page,
}
def search_compat(*args, **kwargs):
positional_names = ['query', 'max_results', 'sort_by', 'include_metadata']
resolved = {}
for i, val in enumerate(args):
if i < len(positional_names):
resolved[positional_names[i]] = val
resolved.update(kwargs)
query = resolved.pop('query')
return search_v2(query, **resolved)
This is the real-world API evolution pattern. Phase 1 has no constraints — callers can write search("python", 5, "date", True) which is unreadable. Phase 2 uses / and * to enforce clarity: query becomes positional-only (you can rename it later), sort_by and include_metadata become keyword-only (no more mystery booleans), and you can add page without breaking anything. Phase 3 provides a compatibility shim that maps old positional calls to the new API. Libraries like NumPy and pandas use exactly this pattern when evolving their APIs.
# PHASE 1: Original API — all parameters are regular
def search_v1(query, max_results=10, sort_by="relevance", include_metadata=False):
"""Original search function. All params are regular."""
return {
'query': query,
'max_results': max_results,
'sort_by': sort_by,
'include_metadata': include_metadata,
}
# PHASE 2: Redesign with proper parameter zones.
# Requirements:
# - query: positional-only (internal name might change)
# - max_results: regular (commonly passed positionally)
# - sort_by, include_metadata: keyword-only (flags must be explicit)
# - Add a new keyword-only parameter: page (default 1)
#
def search_v2():
"""TODO: implement the redesigned signature and body"""
pass
# PHASE 3: Write a compatibility wrapper that accepts
# the old calling conventions and forwards to search_v2.
def search_compat():
"""TODO: implement a wrapper that bridges v1 calls to v2"""
passExpected Output
v1: {'query': 'python', 'max_results': 5, 'sort_by': 'date', 'include_metadata': False}
v2: {'query': 'python', 'max_results': 5, 'sort_by': 'date', 'include_metadata': False, 'page': 1}
compat: {'query': 'python', 'max_results': 5, 'sort_by': 'date', 'include_metadata': False, 'page': 1}Hints
Hint 1: search_v2 signature: (query, /, max_results=10, *, sort_by="relevance", include_metadata=False, page=1).
Hint 2: search_compat should accept *args and **kwargs, then map old positional patterns to the new keyword-only interface.
Write a function factory that takes any function with all-regular parameters and returns a stricter version: parameters without defaults become positional-only, parameters with defaults become keyword-only. This automates the API design pattern from the lesson.
import inspect
def make_strict_function(func):
sig = inspect.signature(func)
pos_only_names = []
kw_only_names = []
kw_defaults = {}
for name, param in sig.parameters.items():
if param.default is inspect.Parameter.empty:
pos_only_names.append(name)
else:
kw_only_names.append(name)
kw_defaults[name] = param.default
n_positional = len(pos_only_names)
def strict(*args, **kwargs):
# Enforce positional-only count
if len(args) > n_positional:
raise TypeError(
f"strict() takes {n_positional} positional arguments "
f"but {len(args)} were given"
)
# Check no positional-only names used as keywords
for name in pos_only_names:
if name in kwargs:
raise TypeError(
f"strict() got some positional-only arguments "
f"passed as keyword arguments: '{name}'"
)
# Build final arguments
final_kwargs = dict(kw_defaults)
final_kwargs.update(kwargs)
# Check for unexpected keyword arguments
valid_kw = set(kw_only_names)
for key in kwargs:
if key not in valid_kw:
raise TypeError(
f"strict() got an unexpected keyword argument '{key}'"
)
return func(*args, **final_kwargs)
strict.__name__ = 'strict'
return strict
def original(a, b, c=10, d=20):
return (a, b, c, d)
strict = make_strict_function(original)
# Valid calls
print(strict(1, 2))
print(strict(1, 2, c=30, d=40))
# Invalid: keyword-only passed positionally
try:
strict(1, 2, 30, 40)
except TypeError as e:
print(f"TypeError: {e}")
# Invalid: positional-only passed as keyword
try:
strict(a=1, b=2)
except TypeError as e:
print(f"TypeError: {e}")Solution
import inspect
def make_strict_function(func):
sig = inspect.signature(func)
pos_only_names = []
kw_only_names = []
kw_defaults = {}
for name, param in sig.parameters.items():
if param.default is inspect.Parameter.empty:
pos_only_names.append(name)
else:
kw_only_names.append(name)
kw_defaults[name] = param.default
n_positional = len(pos_only_names)
def strict(*args, **kwargs):
if len(args) > n_positional:
raise TypeError(
f"strict() takes {n_positional} positional arguments "
f"but {len(args)} were given"
)
for name in pos_only_names:
if name in kwargs:
raise TypeError(
f"strict() got some positional-only arguments "
f"passed as keyword arguments: '{name}'"
)
valid_kw = set(kw_only_names)
for key in kwargs:
if key not in valid_kw:
raise TypeError(
f"strict() got an unexpected keyword argument '{key}'"
)
final_kwargs = dict(kw_defaults)
final_kwargs.update(kwargs)
return func(*args, **final_kwargs)
strict.__name__ = 'strict'
return strict
This factory reimplements Python's / and * enforcement logic manually. The key insight is that parameter kinds are determined by whether a default exists: no default means the parameter is always required and best passed positionally; a default means it is optional and should be named for clarity. This is a simplification of the real heuristic (some required parameters are better as keyword-only), but it demonstrates how the interpreter enforces these rules internally. The factory pattern itself is useful in metaprogramming — frameworks like Click and Typer use similar introspection to automatically build CLI interfaces from function signatures.
import inspect
def make_strict_function(func):
"""Take a function where all parameters are regular
and return a new function with stricter rules:
- Parameters without defaults -> positional-only
- Parameters with defaults -> keyword-only
The returned function should enforce these rules
and produce the same results as the original.
Example:
def original(a, b, c=10, d=20):
return (a, b, c, d)
strict = make_strict_function(original)
strict(1, 2, c=30) # works: a,b positional; c keyword
strict(1, 2, 3) # TypeError: c is keyword-only
strict(a=1, b=2, c=30) # TypeError: a,b are positional-only
"""
# TODO: implement
passExpected Output
(1, 2, 10, 20)
(1, 2, 30, 40)
TypeError: strict() takes 2 positional arguments but 4 were given
TypeError: strict() got some positional-only arguments passed as keyword arguments: 'a'Hints
Hint 1: Use inspect.signature to find which params have defaults and which do not.
Hint 2: Build a new function that separates positional args from keyword args and validates the rules before calling the original.
