The short answer is: it depends on who is reading the signature. At runtime, functools.wraps sets __wrapped__, which inspect.signature() follows to display the original function's parameters -- so help(), documentation generators, and IDE runtime inspectors show the correct signature. But static type checkers like mypy and pyright do not follow __wrapped__. They analyze the wrapper's own type annotations, which are typically (*args, **kwargs), and see no specific parameter information. Closing this gap requires ParamSpec, introduced in Python 3.10 via PEP 612.
Two Audiences, Two Mechanisms
When people ask whether functools.wraps preserves the function signature, the answer depends on which tool is reading the signature. There are two distinct audiences for function metadata, and they use completely different mechanisms to retrieve it:
Runtime tools -- inspect.signature(), help(), Sphinx, pdoc, and IDE hover information -- query live Python objects at runtime. They follow the __wrapped__ attribute chain to discover the original function's parameters. functools.wraps sets __wrapped__, so these tools display the correct signature.
Static type checkers -- mypy, pyright, Pylance, pytype -- analyze source code without executing it. They examine the type annotations on the wrapper function's def line. Since the wrapper is defined as def wrapper(*args, **kwargs), the type checker sees a function that accepts anything and knows nothing about the specific parameters. functools.wraps is a runtime operation and has no effect on static analysis.
What functools.wraps Preserves at Runtime
At runtime, functools.wraps copies five attributes and sets __wrapped__. Here is the full picture:
import functools
import inspect
import time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
return result
return wrapper
@timer
def calculate(x: int, y: int, precision: int = 2) -> float:
"""Calculate x divided by y with given precision."""
return round(x / y, precision)
# Runtime introspection -- ALL correct:
print(calculate.__name__) # calculate
print(calculate.__doc__) # Calculate x divided by y with given precision.
print(calculate.__annotations__) # {'x': , 'y': , ...}
print(inspect.signature(calculate)) # (x: int, y: int, precision: int = 2) -> float
print(hasattr(calculate, '__wrapped__')) # True
The runtime signature is fully intact. inspect.signature() follows __wrapped__ to the original calculate function and returns its parameter list, including names, type annotations, and default values. This is what help() and documentation generators display.
You apply @functools.wraps(func) to a wrapper function. Which attribute does inspect.signature() follow to retrieve the original function's parameters?
__annotations__ is one of the five attributes that functools.wraps copies, but inspect.signature() does not use it to resolve the parameter list. It follows the __wrapped__ attribute chain to the original function, then reads that function's parameters directly.# inspect.signature follows __wrapped__, not __annotations__
import functools, inspect
def deco(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@deco
def greet(name: str) -> str:
return f"Hello, {name}"
print(hasattr(greet, '__wrapped__')) # True
print(inspect.signature(greet)) # (name: str) -> str
functools.wraps sets __wrapped__ on the wrapper, pointing back to the original function. When you call inspect.signature(), it follows the __wrapped__ chain and reads the original function's parameter list, including names, type annotations, and default values.# __wrapped__ is the key attribute for inspect.signature
import functools, inspect
def deco(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@deco
def greet(name: str) -> str:
return f"Hello, {name}"
# __wrapped__ points to the original function
print(greet.__wrapped__ is greet.__wrapped__) # True
print(inspect.signature(greet)) # (name: str) -> str
__signature__ is a real attribute that inspect.signature() checks, but functools.wraps does not set it. What wraps sets is __wrapped__, which inspect.signature() follows to reach the original function's parameters. If __signature__ is explicitly set on an object, inspect.signature() will use it, but that is a different mechanism.# functools.wraps sets __wrapped__, not __signature__
import functools, inspect
def deco(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@deco
def greet(name: str) -> str:
return f"Hello, {name}"
print(hasattr(greet, '__wrapped__')) # True
print(hasattr(greet, '__signature__')) # False
What Type Checkers See Without ParamSpec
Now consider what mypy or pyright sees when it analyzes the same code statically. The type checker does not execute the code. It looks at the wrapper's type signature and sees def wrapper(*args, **kwargs) -- a function that accepts anything. Even though @functools.wraps(func) is present, the type checker cannot infer at analysis time that the wrapper should have the same parameter types as the original function. This limitation exists because functools.wraps operates through decorator closures at runtime, not at the source code level where static analysis happens.
# Without ParamSpec, the type checker loses parameter information
import functools
from typing import Callable, TypeVar
R = TypeVar("R")
def timer(func: Callable[..., R]) -> Callable[..., R]:
@functools.wraps(func)
def wrapper(*args, **kwargs) -> R:
return func(*args, **kwargs)
return wrapper
@timer
def calculate(x: int, y: int) -> float:
return x / y
# The type checker allows THIS -- no error reported:
calculate("hello", [1, 2, 3]) # Wrong types, but mypy says OK
The Callable[..., R] annotation tells the type checker that func is a callable returning R, but the ... (ellipsis) means "any parameters are accepted." The type checker has no information about the specific parameter types of the original function, so it cannot flag incorrect argument types when the decorated function is called.
This is the most common misconception about functools.wraps: developers assume that because help() shows the correct signature, the type checker also sees it. These are two different systems. help() uses runtime introspection. Type checkers use static source code analysis. functools.wraps only helps the first one.
The ParamSpec Solution (Python 3.10+)
ParamSpec, introduced in PEP 612, is a type variable that captures the complete parameter specification of a callable. When you use ParamSpec in a decorator's type annotations, the type checker understands that the wrapper has the exact same parameter types as the original function:
import functools
import time
from collections.abc import Callable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def timer(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
return result
return wrapper
@timer
def calculate(x: int, y: int) -> float:
"""Divide x by y."""
return x / y
# Now the type checker CATCHES this:
calculate("hello", [1, 2, 3]) # mypy error: Argument 1 has incompatible type "str"
The key change is replacing Callable[..., R] with Callable[P, R] in both the input and return type. P captures the parameter specification of whatever function is passed in, and the return type Callable[P, R] declares that the wrapper has the same parameter specification. The wrapper's arguments are typed as *args: P.args, **kwargs: P.kwargs to bind the parameter spec to the generic arguments.
After decoration, the type checker knows that calculate accepts (x: int, y: int) and returns float. Passing wrong types triggers an error. IDE autocompletion shows the correct parameter names and types.
You annotate a decorator as Callable[P, R] -> Callable[P, R] using ParamSpec. What happens when mypy analyzes a call with wrong argument types to the decorated function?
ParamSpec, mypy understands exactly what parameter types the wrapper accepts. The Callable[P, R] annotation gives mypy full visibility into the wrapped function's signature.# mypy DOES analyze ParamSpec-typed decorators
from collections.abc import Callable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def logged(func: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return func(*args, **kwargs)
return wrapper
@logged
def add(x: int, y: int) -> int:
return x + y
add("a", "b") # mypy error: Argument 1 has incompatible type "str"
ParamSpec, mypy treats argument type mismatches as full errors, not warnings. The Callable[P, R] return type tells mypy the wrapper has identical parameter types to the original function, so passing wrong types produces the same error as calling the original function directly with wrong types.# ParamSpec causes mypy to report errors, not warnings
from collections.abc import Callable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def logged(func: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return func(*args, **kwargs)
return wrapper
@logged
def add(x: int, y: int) -> int:
return x + y
# mypy output: error: Argument 1 to "add" has incompatible type "str"; expected "int"
add("a", "b")
ParamSpec captures the full parameter specification of the original function. When the decorator returns Callable[P, R], mypy knows the wrapper has the same parameter types as the original. Passing wrong types produces a standard type error, just as it would if you called the unwrapped function with the same arguments.# ParamSpec preserves full parameter types for mypy
from collections.abc import Callable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def logged(func: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return func(*args, **kwargs)
return wrapper
@logged
def add(x: int, y: int) -> int:
return x + y
add(1, 2) # OK -- mypy sees (x: int, y: int) -> int
add("a", "b") # error: Argument 1 has incompatible type "str"; expected "int"
The Python 3.12+ Generic Syntax
Python 3.12 introduced a cleaner syntax for generic functions that eliminates the need to explicitly create ParamSpec and TypeVar objects. The type parameters are declared directly in square brackets after the function name:
import functools
import time
from collections.abc import Callable
def timer[**P, R](func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
return result
return wrapper
The [**P, R] syntax declares P as a ParamSpec (indicated by **) and R as a TypeVar inline. There is no need to import ParamSpec or TypeVar or create them as module-level variables. The behavior is identical to the Python 3.10 version -- just less boilerplate.
If your project supports Python 3.12+, use the [**P, R] syntax for new decorators. If you need to support Python 3.9 or earlier, install typing_extensions and import ParamSpec from there. The runtime behavior is the same in all cases.
Combining @wraps and ParamSpec
For a fully transparent decorator that preserves metadata for both runtime tools and static type checkers, you need both @functools.wraps(func) and ParamSpec type annotations. Here is the complete pattern:
import functools
import inspect
import logging
from collections.abc import Callable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
logger = logging.getLogger(__name__)
def log_calls(func: Callable[P, R]) -> Callable[P, R]:
"""Log every call to the decorated function."""
@functools.wraps(func) # Runtime metadata
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: # Static type info
logger.info("Calling %s", func.__name__)
result = func(*args, **kwargs)
logger.info("%s returned %r", func.__name__, result)
return result
return wrapper
@log_calls
def fetch_user(user_id: int, include_email: bool = False) -> dict:
"""Fetch user data from the database."""
return {"id": user_id, "email": "[email protected]" if include_email else None}
# Runtime: correct
print(fetch_user.__name__) # fetch_user
print(inspect.signature(fetch_user)) # (user_id: int, include_email: bool = False) -> dict
# Static analysis: also correct
fetch_user(42, include_email=True) # OK
fetch_user("not_an_int") # mypy error: expected int
@functools.wraps(func) handles runtime metadata: __name__, __doc__, __annotations__, __wrapped__. The ParamSpec annotations handle static type checking: parameter names, types, and the return type flow through the decorator so that mypy and pyright can validate callers.
Decorators That Change the Signature
When a decorator adds, removes, or modifies parameters, Concatenate from the typing module works with ParamSpec to describe the transformation. For example, a decorator that injects a database connection as the first argument:
import functools
from collections.abc import Callable
from typing import Concatenate, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
class DBConnection:
"""Simulated database connection."""
def query(self, sql: str) -> list:
return [{"id": 1}]
def inject_db(
func: Callable[Concatenate[DBConnection, P], R]
) -> Callable[P, R]:
"""Inject a database connection as the first argument."""
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
db = DBConnection()
return func(db, *args, **kwargs)
return wrapper
@inject_db
def get_users(db: DBConnection, limit: int = 10) -> list:
"""Fetch users from the database."""
return db.query(f"SELECT * FROM users LIMIT {limit}")
# Callers do NOT pass db -- the decorator injects it:
get_users(limit=5) # OK -- type checker knows limit is int
get_users(db=DBConnection()) # mypy error: unexpected keyword argument "db"
Concatenate[DBConnection, P] tells the type checker that the original function takes a DBConnection followed by whatever P captures. The return type Callable[P, R] declares that the wrapper exposes only the remaining parameters -- the db argument is consumed by the decorator. The type checker correctly prevents callers from passing db manually.
A decorator uses Concatenate[Session, P] as the input type and returns Callable[P, R]. The decorated function is defined as def save(session: Session, data: dict) -> bool. What does the caller see after decoration?
Concatenate[Session, P] captures Session as the first parameter and P as the rest. The return type Callable[P, R] exposes only P -- so the session parameter is consumed by the decorator, and callers see only the remaining parameters.import functools
from collections.abc import Callable
from typing import Concatenate, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
class Session:
pass
def inject_session(
func: Callable[Concatenate[Session, P], R]
) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return func(Session(), *args, **kwargs)
return wrapper
@inject_session
def save(session: Session, data: dict) -> bool:
return True
# Callers only see: save(data: dict) -> bool
save({"key": "value"}) # OK
save(Session(), {"key": "v"}) # mypy error: too many arguments
Concatenate[Session, P] is that Session is consumed by the decorator and removed from the caller-facing signature. The return type Callable[P, R] exposes only P, which is (data: dict).# Concatenate removes the consumed parameter from the caller's view
import functools
from collections.abc import Callable
from typing import Concatenate, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
class Session:
pass
def inject_session(
func: Callable[Concatenate[Session, P], R]
) -> Callable[P, R]: # Only P is exposed -- Session is consumed
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return func(Session(), *args, **kwargs)
return wrapper
@inject_session
def save(session: Session, data: dict) -> bool:
return True
# After decoration, the caller sees: save(data: dict) -> bool
save({"key": "value"}) # OK -- session is injected automatically
Callable[..., R] without ParamSpec. With Concatenate[Session, P] and a return type of Callable[P, R], the type checker knows the exact remaining parameter types. The caller sees save(data: dict) -> bool -- fully typed, with session consumed by the decorator.# Concatenate + ParamSpec gives fully typed results, not (*args, **kwargs)
import functools
from collections.abc import Callable
from typing import Concatenate, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
class Session:
pass
def inject_session(
func: Callable[Concatenate[Session, P], R]
) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return func(Session(), *args, **kwargs)
return wrapper
@inject_session
def save(session: Session, data: dict) -> bool:
return True
# mypy sees save(data: dict) -> bool, NOT save(*args, **kwargs)
save({"key": "value"}) # OK
save(123) # mypy error: expected dict
What About __annotations__?
functools.wraps copies the __annotations__ dictionary from the original function to the wrapper at runtime. This matters for frameworks that read annotations at runtime rather than through static analysis.
import functools
from collections.abc import Callable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def my_decorator(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return func(*args, **kwargs)
return wrapper
@my_decorator
def process(data: list, threshold: float = 0.5) -> bool:
"""Check if data meets the threshold."""
return len(data) > threshold
print(process.__annotations__)
# {'data': , 'threshold': , 'return': }
FastAPI, Pydantic, and other frameworks that inspect __annotations__ at runtime benefit from this. The annotations are available on the wrapper because functools.wraps copied them. But this runtime copy does not influence what mypy or pyright sees -- those tools read the source code annotations, not the runtime __annotations__ dictionary.
A production decorator needs to work correctly with inspect.signature(), help(), mypy, and pyright. What is the minimum combination required?
ParamSpec only addresses static type checkers (mypy, pyright) and IDE autocompletion. It has no effect on inspect.signature() or help() -- those are runtime tools that rely on __wrapped__, which only @functools.wraps sets.# ParamSpec WITHOUT @wraps -- runtime metadata is lost
from collections.abc import Callable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def timer(func: Callable[P, R]) -> Callable[P, R]:
# Missing @functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return func(*args, **kwargs)
return wrapper
@timer
def add(x: int, y: int) -> int:
"""Add two numbers."""
return x + y
print(add.__name__) # "wrapper" -- wrong, should be "add"
print(add.__doc__) # None -- docstring lost
@functools.wraps handles the runtime half: __name__, __doc__, __wrapped__ (used by inspect.signature() and help()). ParamSpec handles the static half: type checkers and IDE autocompletion. Neither alone covers all four tools. A production decorator needs both.# The complete production-grade pattern
import functools
from collections.abc import Callable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def timer(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func) # Runtime
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: # Static
return func(*args, **kwargs)
return wrapper
@timer
def add(x: int, y: int) -> int:
"""Add two numbers."""
return x + y
# Runtime: both correct
print(add.__name__) # "add"
print(add.__doc__) # "Add two numbers."
# Static: mypy sees (x: int, y: int) -> int
add(1, 2) # OK
add("a", 2) # mypy error
@functools.wraps handles runtime metadata (inspect.signature() and help()), but static type checkers do not follow __wrapped__. Without ParamSpec, mypy and pyright see the wrapper as (*args, **kwargs) and cannot validate argument types.# @wraps WITHOUT ParamSpec -- type checker loses signature
import functools
from typing import Callable, TypeVar
R = TypeVar("R")
def timer(func: Callable[..., R]) -> Callable[..., R]:
@functools.wraps(func)
def wrapper(*args, **kwargs) -> R:
return func(*args, **kwargs)
return wrapper
@timer
def add(x: int, y: int) -> int:
return x + y
# mypy does NOT catch this -- Callable[..., R] accepts anything
add("wrong", "types") # No error from mypy
Summary: What Preserves What
__wrapped__)__wrapped__)__wrapped__)The division is clear: functools.wraps covers runtime introspection. ParamSpec covers static analysis. Neither alone provides full coverage. Using both together gives you a decorator that is transparent to every tool in the Python ecosystem.
Key Takeaways
- functools.wraps preserves the signature at runtime, not for type checkers. It sets
__wrapped__, whichinspect.signature(),help(), and documentation generators follow to display the correct parameter names, types, and defaults. Static type checkers do not follow__wrapped__. - Static type checkers analyze the wrapper's own annotations. A wrapper defined as
def wrapper(*args, **kwargs)tells the type checker nothing about specific parameters. Even with@functools.wraps, the type checker sees(*args, **kwargs)and cannot validate callers. - ParamSpec (PEP 612, Python 3.10+) bridges the static typing gap. Annotating the decorator as
Callable[P, R] -> Callable[P, R]and the wrapper as*args: P.args, **kwargs: P.kwargstells the type checker that the wrapper has the same parameter types as the original function. - Python 3.12+ simplifies the syntax. The
def func[**P, R]generic syntax declaresParamSpecandTypeVarinline, eliminating the module-level variable boilerplate while providing identical behavior. - Use both @wraps and ParamSpec together.
@functools.wraps(func)handles runtime metadata.ParamSpechandles static type checking. Neither is a substitute for the other. A production-grade decorator should include both. - Concatenate handles decorators that change the signature. When a decorator adds or removes parameters,
Concatenate[ExtraArg, P]tells the type checker how the parameter list transforms, allowing it to validate callers against the modified signature. - functools.wraps copies __annotations__ at runtime. This benefits frameworks like FastAPI and Pydantic that read annotations from live objects. But the runtime copy does not affect static analysis tools, which read source code directly.
The distinction between runtime signature preservation and static type signature preservation is the single concept that clarifies this entire topic. functools.wraps handles the runtime half. ParamSpec handles the static half. Together, they make a decorator completely transparent -- at runtime, to type checkers, and to every IDE and documentation tool in between.
How to Write a Type-Safe Decorator with functools.wraps and ParamSpec
Follow these steps to create a decorator that preserves both runtime metadata and static type information:
- Import the required modules. You need
functoolsfor thewrapsdecorator,Callablefromcollections.abc, andParamSpecandTypeVarfrom thetypingmodule. - Declare ParamSpec and TypeVar variables. Create
P = ParamSpec("P")to capture parameter specifications andR = TypeVar("R")to capture the return type. In Python 3.12+, you can use the inline syntaxdef decorator[**P, R]instead. - Annotate the decorator with ParamSpec. Type the decorator function as taking
Callable[P, R]and returningCallable[P, R]. This tells the type checker that the wrapper preserves the original function's parameter types and return type. - Apply
@functools.wraps(func)to the wrapper. Add@functools.wraps(func)above the inner wrapper function definition. This copies__name__,__doc__,__annotations__,__module__,__qualname__, and sets__wrapped__on the wrapper for runtime introspection. - Type the wrapper arguments with P.args and P.kwargs. Define the wrapper as
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Rto bind the parameter spec to the wrapper's variadic arguments, completing the static type chain.
With all five steps in place, the decorated function is fully transparent to inspect.signature(), help(), mypy, pyright, and IDE autocompletion. See the Combining @wraps and ParamSpec section above for the complete working code.
Frequently Asked Questions
Does functools.wraps preserve the function signature?
It depends on what is reading the signature. At runtime, functools.wraps sets a __wrapped__ attribute that inspect.signature() follows to retrieve the original function's parameter names, types, and defaults. This means help(), documentation generators, and IDE features that use inspect.signature work correctly. However, static type checkers like mypy and pyright do not follow __wrapped__ -- they analyze the wrapper's own type annotations, which are typically (*args, **kwargs). Without ParamSpec, the type checker sees no specific parameter information.
What is ParamSpec and how does it preserve decorator signatures for type checkers?
ParamSpec is a typing construct introduced in Python 3.10 via PEP 612. It acts as a type variable that captures the full parameter specification of a callable. When you annotate a decorator as taking Callable[P, R] and returning Callable[P, R], the type checker understands that the wrapper has the same parameter types as the original function. This makes the decorated function's type signature visible to mypy, pyright, and IDE autocompletion.
Do I still need functools.wraps if I use ParamSpec?
Yes. ParamSpec and functools.wraps solve different problems. ParamSpec preserves the type signature for static analysis tools. functools.wraps preserves runtime metadata -- __name__, __doc__, __module__, __qualname__, __annotations__, and __wrapped__. Both are needed for a fully transparent decorator: ParamSpec for the type checker, @wraps for runtime introspection.
Does functools.wraps copy __annotations__?
Yes. functools.wraps copies the __annotations__ dictionary from the original function to the wrapper. This means that runtime code which reads __annotations__ directly (such as FastAPI's dependency injection system or Pydantic's model validators) will see the correct type annotations. However, this runtime copying does not affect what static type checkers see -- they analyze source code, not runtime attribute values.
What Python version do I need for ParamSpec?
ParamSpec was introduced in Python 3.10 as part of PEP 612. If you are using Python 3.9 or earlier, you can import ParamSpec from the typing_extensions package, which backports newer typing features for older Python versions. Python 3.12 introduced an even cleaner syntax using def func[**P, R] generics that eliminates the need to explicitly create ParamSpec and TypeVar objects.