Both answers posted so far touch type hints, but do not cover the issues in full. Let's start with them.
Type hints
You support decorating with both @profile
and @profile(...)
. However, your function is hinted to return just Callable
. Type parameters in python are always defaulted to Any
, so this is equivalent to Callable[..., Any]
- a callable that takes any args and kwargs and returns the most permissive type - Any
. It's bad for users:
@profile
def my_func(x: int) -> None:
return None
my_func(foo="bar") # No typechecker error!
Let's fix that. First I will show py3.8 native compatible method - it does not give full type safety, but makes your interface correct for an external observer. Note Tuple
fixes - see @J_H answer.
from typing import Any, Callable, List, Optional, Union, Tuple, TypeVar, overload
_C = TypeVar("_C", bound=Callable[..., Any])
@overload
def profile(function: _C, /) -> _C: ...
@overload
def profile(
n_rows: int = 50,
sort_by: Union[str, List[str], Tuple[str, ...]] = "cumulative",
output: str = "stdout",
filename: Optional[str] = None,
) -> Callable[[_C], _C]: ...
def profile(
n_rows: int | Callable[..., Any] = 50,
sort_by: Union[str, List[str], Tuple[str, ...]] = "cumulative",
output: str = "stdout",
filename: Optional[str] = None,
) -> Callable[..., Any]:
... # Your function body goes here
What's going on? We declare a function that can either be called with one callable argument or with multiple non-callable arguments. overload
ed definitions are (almost) ignored at runtime, they have no effect on behaviour - your function's signature is still the one not decorated with overload
. On the other hand, typecheckers will now know that @profile
returns function unchanged, and @profile(...)
also does (because Callable[[_C], _C]
is what is applied to the decorated function - a callable that returns its only argument unchanged). Great. Now the first snippet in this answer will cause a typechecker error.
You can see this solution working in playground to understand these typing details better.
But we have too much freedom! Our implementation is still too permissive inside: type checker won't flag many errors if they are there. Your version limitation comes now into play. If you need to be compatible with older versions (which is a good thing for libraries), you can add a lightweight dependency - typing_extensions
. This package is maintained by mypy
and python
core team and backports new typing
features to make them available on older pythons. Once you increase minimal supported versions, some imports should be moved to regular typing
(but you can leave them as is, typing_extensions
is guaranteed to be backwards-compatible). pyupgrade
and ruff
's UP*
rules will catch that too.
I will show the "final" (to the best of my knowledge) solution with most typechecking you can get below and try to explain it a bit.
# non-typing import unchanged
from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Optional, Union, Tuple, TypeVar, cast, overload
from typing_extensions import ParamSpec
_C = TypeVar("_C", bound=Callable[..., Any])
_R = TypeVar("_R")
_P = ParamSpec("_P")
@overload
def profile(function: _C, /) -> _C: ...
@overload
def profile(
n_rows: int = 50,
sort_by: Union[str, List[str], Tuple[str, ...]] = "cumulative",
output: str = "stdout",
filename: Optional[str] = None,
) -> Callable[[_C], _C]: ...
def profile(
n_rows: int | Callable[_P, _R] = 50,
sort_by: Union[str, List[str], Tuple[str, ...]] = "cumulative",
output: str = "stdout",
filename: Optional[str] = None,
) -> Callable[_P, _R] | Callable[[Callable[_P, _R]], Callable[_P, _R]]:
# Your docstring here
valid_outputs = {"stdout", "file", "log"}
def decorator(func: Callable[_P, _R]) -> Callable[_P, _R]:
if asyncio.iscoroutinefunction(func):
# Now it's an async callable. Reuse `_R`, but inside `Awaitable`
# now we can await it's return type
afunc = cast("Callable[_P, Awaitable[_R]]", func)
@wraps(afunc)
async def async_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
with cProfile.Profile() as pr:
# Directly await the async function here
result = await afunc(*args, **kwargs)
process_profiling_results(pr)
return result
# async functions are interpreted as returning `Awaitable[DeclaredReturnType]`,
# we cannot get rid of this onion layer
return async_wrapper # type: ignore[return-value]
@wraps(func)
def sync_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
with cProfile.Profile() as pr:
result = func(*args, **kwargs)
process_profiling_results(pr)
return result
return sync_wrapper
def process_profiling_results(pr: cProfile.Profile) -> None:
s = io.StringIO()
# Fallback to 'cumulative' if sort_by is invalid
if isinstance(sort_by, (list, tuple)):
sort_by_valid = [s for s in sort_by if s in VALID_SORTS]
else:
sort_by_valid = [sort_by] if sort_by in VALID_SORTS else ["cumulative"]
ps = pstats.Stats(pr, stream=s).sort_stats(*sort_by_valid)
assert isinstance(n_rows, int) # Due to our trickery with the first argument
ps.print_stats(n_rows)
# Handle output
if output not in valid_outputs:
raise ValueError(
f"Invalid output option '{output}'. Valid options are {valid_outputs}."
)
if output == "stdout":
print(s.getvalue())
elif output == "file":
if not filename:
raise ValueError("Filename must be provided when output is 'file'.")
try:
with open(filename, "w+", encoding="utf-8") as f:
f.write(s.getvalue())
except IOError as e:
raise IOError(f"Error writing to file {filename}: {e}") from e
elif output == "log":
logger = logging.getLogger(__name__)
logger.info(s.getvalue())
else:
raise ValueError("'output' must be one of 'stdout', 'file' or 'log'.")
# Enable the decorator to be used without parentheses if no arguments are provided
if callable(n_rows):
temp_func = n_rows
n_rows = 50
return decorator(temp_func)
return decorator
Now (again, playground) this would've caught, for example, forgetting to pass *args
down to the callable. Is it worth the tradeoff? Not sure, typing sync- and async-enabled decorators is always a terrible mess, and I usually prefer just having two different decorators (then both can be easily type annotated). However, the main goal is still achieved: external observers have their function's signatures unchanged.
Now, other minor stuff (I'm ignoring everything already mentioned).
list | tuple versus any sequence or iterable
Typing aside, accepting only a list or tuple is not really user-friendly. There are other sequences and iterables. The only thing you do with this argument is unpacking/iterating, so Iterable
would be the most broad type possible and require only minor modifications from you. On second thought I think Iterable
is a wrong choice, because Iterable
does not guarantee fixed/known iteration order, and order matters in this case. Sequence
is a random-access iterable with integer indices, so it's pretty much ordered. Let's replace your list-or-tuple with a sequence:
...
from typing import Sequence
# Correct the overload too, omitted here for brewity
def profile(
n_rows: int | Callable[_P, _R] = 50,
sort_by: Union[str, Sequence[str]] = "cumulative",
output: str = "stdout",
filename: Optional[str] = None,
) -> Callable[_P, _R] | Callable[[Callable[_P, _R]], Callable[_P, _R]]:
...
def process_profiling_results(pr: cProfile.Profile) -> None:
if isinstance(sort_by, str):
sort_by_valid = [sort_by] if sort_by in VALID_SORTS else ["cumulative"]
else:
sort_by_valid = [s for s in sort_by if s in VALID_SORTS]
Note that str
is a subtype of Sequence[str]
so we can't isinstance(sort_by, Sequence)
- just swap the branches. Union[str, Sequence[str]]
is strictly the same as just Sequence[str]
, but I'd rather keep the union for human readers.
Do not fail silently
Your sort_by
fallback may be convenient for somebody, but a debugging disaster for everybody else. It's better to raise an exception if you encounter an invalid sort key - this way a typo will be easy to catch.
Set comprehension
VALID_SORTS = set(
val
for name, attribute in SortKey.__dict__.items()
if not name.startswith("__") and isinstance(attribute, tuple)
for val in attribute
)
is... interesting. Why is there a generator wrapped with set()
function? Python has set comprehension literals.
VALID_SORTS = {
val
for name, attribute in SortKey.__dict__.items()
if not name.startswith("__") and isinstance(attribute, tuple)
for val in attribute
}
Enum members retrieval (already covered, sorry)
pstats.SortKey
is a enum.Enum
. Please don't do what you're doing with it. You can iterate over enum directly to get its members. Furthermore, your current solution results in empty VALID_SORTS
for me on py3.11. And on py3.8. Looks like this doesn't work on any python, actually. You probably just need list(map(str, SortKey))
or [str(key) for key in SortKey]
, whichever reads better for you - this will collect all lowercase key versions.
But now... you don't need VALID_SORTS
at all. To validate, SortKey(key)
will raise a ValueError
if key is invalid.
Now you're at the point where you have replaced an enum with its stringified members. Why? Same "stringly typed" issue. I'd recommend to ditch that and accept SortKey | Sequence[SortKey]
. You can even reexport SortKey
in your package to prevent using several imports - that's perfectly fine for an utility package, so that it's just from your_package import SortKey, profile
for your users.
callable
coming from in theif callable(n_rows)
check? \$\endgroup\$callable(n_rows)
allows the decorator to be used both with and without parentheses. It's a built in python function that returns True if the object appears callable. \$\endgroup\$