Skip to main content

Python Keyword-Only Practice Problems & Exercises

Practice: Keyword-Only and Positional-Only

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

Easy

#1Enforce Keyword-Only FlagsEasy
keyword-only* separatorboolean-flags

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).

Python
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
    pass
Expected Output
Hello, Alice
HELLO, BOB
Hints

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.

#2Positional-Only MathEasy
positional-only/ separator

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.

Python
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
    pass
Expected 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.

#3Identify the Parameter KindEasy
parameter-kindssignature-reading

Given the signature def process(a, b, /, c, d=10, *, e, f=99), classify each parameter as 'positional-only', 'regular', or 'keyword-only'.

Python
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
    pass
Expected 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.

#4Required Keyword-Only ArgumentsEasy
keyword-onlyrequiredTypeError

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.

Python
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
    pass
Expected 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

#5Combine Positional-Only and Keyword-OnlyMedium
/ separator* separatorcombined-signature

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.

Python
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
    pass
Expected 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 argument
Hints

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.

#6Inspect Signatures ProgrammaticallyMedium
inspect.signatureParameter.kindintrospection

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.

Python
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
    pass
Expected 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.

#7Safe Configuration BuilderMedium
api-designkeyword-onlyboolean-flags

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).

Python
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
    pass
Expected 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.

#8Error Message PredictorMedium
TypeErrorerror-messagesenforcement

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.

Python
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
    pass
Expected 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

#9Parameter Kind Validator DecoratorHard
decoratorinspectenforcementadvanced

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.

Python
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
    pass
Expected Output
{'host': 'localhost', 'port': 5432, 'timeout': 30, 'retries': 5}
TypeError: timeout must be int, got str
TypeError: retries must be int, got float
Hints

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.

#10API Migration: Add Positional-Only SafetyHard
api-evolutionpositional-onlybackward-compat

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.

Python
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"""
    pass
Expected 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.

#11Build a Strict Function FactoryHard
factorydynamic-signatureinspectadvanced

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.

Python
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
    pass
Expected 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.

© 2026 EngineersOfAI. All rights reserved.