semifun 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
semifun/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,56 @@
1
+ import inspect
2
+ import functools
3
+ from semifun.caching.dictdefault import dictdefault
4
+
5
+
6
+ def cached_method(fn):
7
+ """Decorator that caches method results by hashed arguments.
8
+
9
+ Example::
10
+
11
+ @dataclass(frozen=True)
12
+ class MyService:
13
+ cache_codec: Inject[TmsgpackCodec]
14
+ ...
15
+
16
+ @cached_method
17
+ def get_config(self, env: str) -> Config: ...
18
+
19
+ @cached_method
20
+ async def fetch_user(self, user_id: int) -> User: ...
21
+ """
22
+ sig = inspect.signature(fn)
23
+ params = list(sig.parameters.keys())
24
+ assert params and params[0] == 'self'
25
+ cache_attr = f'_method_cache_{fn.__name__}'
26
+ is_async = inspect.iscoroutinefunction(fn)
27
+ dd = dictdefault.a if is_async else dictdefault
28
+
29
+ # Build a signature without 'self' for normalization
30
+ norm_sig = sig.replace(parameters=[sig.parameters[p] for p in params[1:]])
31
+
32
+ def _get_cache(self):
33
+ try:
34
+ return getattr(self, cache_attr)
35
+ except AttributeError:
36
+ cache = {}
37
+ object.__setattr__(self, cache_attr, cache)
38
+ return cache
39
+
40
+ def _make_key(self, args, kwargs):
41
+ bound = norm_sig.bind(*args, **kwargs)
42
+ bound.apply_defaults()
43
+ return self.cache_codec.hash_to_bytes(bound.args + tuple(bound.kwargs.items()))
44
+
45
+ if is_async:
46
+ @functools.wraps(fn)
47
+ async def wrapper(self, *args, **kwargs):
48
+ key = _make_key(self, args, kwargs)
49
+ return await dd(_get_cache(self), key, lambda: fn(self, *args, **kwargs))
50
+ return wrapper
51
+ else:
52
+ @functools.wraps(fn)
53
+ def wrapper(self, *args, **kwargs):
54
+ key = _make_key(self, args, kwargs)
55
+ return dd(_get_cache(self), key, lambda: fn(self, *args, **kwargs))
56
+ return wrapper
@@ -0,0 +1,52 @@
1
+ # We do not use functools.cached_property because it contains messy
2
+ # and unnecessary code for threaded programs. In addition, our
3
+ # `@cached_property` decorator can be used correctly with async methods.
4
+
5
+ import inspect, asyncio
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, TypeVar, Generic, Callable, Any, overload
8
+
9
+ T = TypeVar('T')
10
+
11
+ class cached_property(Generic[T]):
12
+ fn: Callable[[Any], T]
13
+ _name: str
14
+
15
+ def __init__(self, fn: Callable[[Any], T]):
16
+ import types
17
+ fn2 = fn if isinstance(fn, types.FunctionType) else getattr(fn, '__call__', fn) # type: ignore[arg-type]
18
+ if inspect.iscoroutinefunction(fn2):
19
+ self.fn = lambda self: create_task_loop_check(fn(self), name=None, context=None) # type: ignore[assignment,return-value]
20
+ else:
21
+ self.fn = fn
22
+ self._name = fn.__name__
23
+ self.__doc__ = fn.__doc__
24
+
25
+ @overload
26
+ def __get__(self, instance: None, cls: type) -> cached_property[T]: ...
27
+ @overload
28
+ def __get__(self, instance: object, cls: type) -> T: ...
29
+
30
+ def __get__(self, instance: object | None, cls: type) -> T | cached_property[T]:
31
+ if instance is None:
32
+ return self
33
+ value = self.fn(instance)
34
+ instance.__dict__[self._name] = value
35
+ return value
36
+
37
+ def create_task_loop_check(coro: Any, name: str | None, context: Any) -> LoopCheck:
38
+ task = asyncio.create_task(coro, name=name, context=context)
39
+ task._loop_check_parent_task = asyncio.current_task() # type: ignore[attr-defined]
40
+ return LoopCheck(task=task)
41
+
42
+ @dataclass(frozen=True)
43
+ class LoopCheck:
44
+ task: asyncio.Task[Any]
45
+
46
+ def __await__(self):
47
+ testing = asyncio.current_task()
48
+ while testing:
49
+ if testing is self.task:
50
+ raise ValueError(f'Deadlock: {self.task}')
51
+ testing = getattr(testing, '_loop_check_parent_task', None)
52
+ return self.task.__await__()
@@ -0,0 +1,59 @@
1
+ """dictdefault — a caching accessor for normal dicts.
2
+
3
+ The name is a play on ``defaultdict``: instead of using a special dict type,
4
+ you use a normal dict and a caching accessor function.
5
+
6
+ Variants:
7
+
8
+ - ``dictdefault`` — sync, key not passed to the function.
9
+ - ``dictdefault.k`` — sync, key passed as first argument.
10
+ - ``dictdefault.a`` — async, key not passed.
11
+ - ``dictdefault.ak`` — async, key passed as first argument.
12
+
13
+ The async variants ensure the computation runs only once, even when
14
+ additional requests arrive while the first computation is still running.
15
+ All callers receive the same awaitable task object.
16
+
17
+ All variants accept ``**kwargs`` which are forwarded to the function.
18
+ They are considered only the first time — when the computation is performed.
19
+
20
+ Example::
21
+
22
+ cache1 = {}; cache2 = {} # sync and async use distinct caching formats
23
+ config = dictdefault(cache1, 'db', load_config) # → load_config()
24
+ user = dictdefault.k(cache1, 'alice', fetch_user) # → fetch_user('alice')
25
+ session = await dictdefault.a(cache2, 'main', async_create_session)
26
+ # → await async_create_session()
27
+ page = await dictdefault.ak(cache2, url, async_fetch_page)
28
+ # → await async_fetch_page(url)
29
+ """
30
+
31
+ def _mk_dictdefault():
32
+ def _dd(with_key):
33
+ def dictdefault(_d, _key, _fn, **kwargs):
34
+ if _key not in _d:
35
+ _d[_key] = _fn(_key, **kwargs) if with_key else _fn(**kwargs)
36
+ return _d[_key]
37
+ return dictdefault
38
+
39
+ def _dda(with_key):
40
+ from semifun.caching.cached_property import create_task_loop_check
41
+ async def dictdefault(_d, _key, _fn, **kwargs):
42
+ if _key not in _d:
43
+ _d[_key] = (
44
+ create_task_loop_check(
45
+ _fn(_key, **kwargs) if with_key else _fn(**kwargs),
46
+ name=None, context=None,
47
+ )
48
+ )
49
+ return await _d[_key]
50
+ return dictdefault
51
+
52
+ dictdefault = _dd(False)
53
+ dictdefault.k = _dd(True)
54
+ dictdefault.a = _dda(False)
55
+ dictdefault.ak = _dda(True)
56
+
57
+ return dictdefault
58
+
59
+ dictdefault = _mk_dictdefault()
File without changes
semifun/cli/argv.py ADDED
@@ -0,0 +1,28 @@
1
+ """Parse CLI argv into positional args and keyword args.
2
+
3
+ Pure string processing — no knowledge of the target function.
4
+ """
5
+
6
+
7
+ def split_argv(argv: list[str]) -> tuple[list[str], dict[str, str]]:
8
+ """Split argv tokens into positional args and keyword args.
9
+
10
+ Tokens containing '=' (split at the first '=') become keyword args.
11
+ All other tokens are positional args, in their original order.
12
+
13
+ Example:
14
+ split_argv(['hello', 'time=now', 'world', 'age=10'])
15
+ → (['hello', 'world'], {'time': 'now', 'age': '10'})
16
+
17
+ Returns:
18
+ (args, kwargs) — both contain raw strings, no type casting.
19
+ """
20
+ args: list[str] = []
21
+ kwargs: dict[str, str] = {}
22
+ for token in argv:
23
+ if '=' in token:
24
+ key, value = token.split('=', 1)
25
+ kwargs[key] = value
26
+ else:
27
+ args.append(token)
28
+ return args, kwargs
semifun/cli/cast.py ADDED
@@ -0,0 +1,101 @@
1
+ """Type-cast CLI string arguments based on function signature annotations.
2
+
3
+ Self-contained — uses only the standard library's inspect module.
4
+ Handles regular parameters, *args with element-level annotations,
5
+ and **kwargs with element-level annotations.
6
+ """
7
+
8
+ import inspect
9
+ from typing import Any
10
+
11
+
12
+ def cast_args(
13
+ fn: Any,
14
+ args: list[str],
15
+ kwargs: dict[str, str],
16
+ ) -> tuple[tuple[Any, ...], dict[str, Any]]:
17
+ """Cast string args and kwargs to the types declared in fn's signature.
18
+
19
+ Inspects fn's signature and casts each value:
20
+ - Regular positional/keyword params: cast by their annotation
21
+ - *args (VAR_POSITIONAL): cast each element by the *args annotation
22
+ - **kwargs (VAR_KEYWORD): cast each element by the **kwargs annotation
23
+
24
+ Only int, float, and bool are cast. All other types (or missing
25
+ annotations) pass the string through unchanged.
26
+
27
+ Args:
28
+ fn: The target function whose signature provides type information.
29
+ args: Positional arguments as strings.
30
+ kwargs: Keyword arguments as strings.
31
+
32
+ Returns:
33
+ (cast_args_tuple, cast_kwargs_dict) ready for fn(*args, **kwargs).
34
+ """
35
+ sig = inspect.signature(fn)
36
+ params = list(sig.parameters.values())
37
+
38
+ cast_positional: list[Any] = []
39
+ cast_kwargs: dict[str, Any] = {}
40
+
41
+ # Separate params by kind
42
+ positional_params: list[inspect.Parameter] = []
43
+ var_positional: inspect.Parameter | None = None
44
+ keyword_params: dict[str, inspect.Parameter] = {}
45
+ var_keyword: inspect.Parameter | None = None
46
+
47
+ for p in params:
48
+ if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD):
49
+ positional_params.append(p)
50
+ elif p.kind == p.VAR_POSITIONAL:
51
+ var_positional = p
52
+ elif p.kind == p.KEYWORD_ONLY:
53
+ keyword_params[p.name] = p
54
+ elif p.kind == p.VAR_KEYWORD:
55
+ var_keyword = p
56
+
57
+ # Cast positional args
58
+ for i, value in enumerate(args):
59
+ if i < len(positional_params):
60
+ annotation = positional_params[i].annotation
61
+ elif var_positional is not None:
62
+ annotation = var_positional.annotation
63
+ else:
64
+ annotation = inspect.Parameter.empty
65
+ cast_positional.append(_cast_value(annotation, value))
66
+
67
+ # Cast keyword args
68
+ for key, value in kwargs.items():
69
+ if key in keyword_params:
70
+ annotation = keyword_params[key].annotation
71
+ elif key in {p.name for p in positional_params}:
72
+ # Named arg matching a positional param
73
+ param = next(p for p in positional_params if p.name == key)
74
+ annotation = param.annotation
75
+ elif var_keyword is not None:
76
+ annotation = var_keyword.annotation
77
+ else:
78
+ annotation = inspect.Parameter.empty
79
+ cast_kwargs[key] = _cast_value(annotation, value)
80
+
81
+ return tuple(cast_positional), cast_kwargs
82
+
83
+
84
+ def _cast_value(annotation: Any, value: str) -> Any:
85
+ """Cast a single string value to the annotated type.
86
+
87
+ Only int, float, and bool are cast. Everything else passes through.
88
+ """
89
+ if annotation is inspect.Parameter.empty:
90
+ return value
91
+ if annotation is int:
92
+ return int(value)
93
+ if annotation is float:
94
+ return float(value)
95
+ if annotation is bool:
96
+ if value in ('0', 'false', 'False', 'no'):
97
+ return False
98
+ if value in ('1', 'true', 'True', 'yes'):
99
+ return True
100
+ raise ValueError(f"Cannot cast {value!r} to bool. Use 0/1/true/false/yes/no.")
101
+ return value
@@ -0,0 +1,26 @@
1
+ """Decorator for sync functions that own the async event loop.
2
+
3
+ Some functions (e.g., HTTP server launchers) are sync but start their own
4
+ async event loop internally. These cannot be called from within an existing
5
+ event loop — they must use the sync DI API instead of async.
6
+
7
+ This is a supported but non-standard pattern. It is an anti-pattern for
8
+ composability: such functions never compose cleanly with other async code.
9
+ """
10
+
11
+
12
+ def sync_function_owns_async_loop(fn):
13
+ """Mark a sync function as owning the async event loop.
14
+
15
+ `sync_cli_dispatch_engine` detects this attribute and uses the sync DI
16
+ API (sync_call_with_args). `cli_dispatch_engine`, the standard engine,
17
+ ignores it: such a command cannot be awaited, which is why it needs the
18
+ engine that owns the loop.
19
+
20
+ Usage:
21
+ @sync_function_owns_async_loop
22
+ def start_server(host: str = 'localhost', port: int = 8080):
23
+ uvicorn.run(app, host=host, port=int(port))
24
+ """
25
+ fn.sync_function_owns_async_loop = True
26
+ return fn
@@ -0,0 +1,145 @@
1
+ """CLI dispatch: discover a function by name, call it with DI and type-cast args.
2
+
3
+ This module composes the pieces (argv splitting, type casting, DI invocation)
4
+ into a complete CLI dispatcher.
5
+
6
+ Two engines are exposed:
7
+
8
+ * `cli_dispatch_engine` — async, awaits in the caller's loop. The standard.
9
+ * `sync_cli_dispatch_engine` — sync, for commands that must own the loop.
10
+ """
11
+
12
+ import asyncio
13
+ import inspect
14
+ import sys
15
+ import textwrap
16
+
17
+ from semifun.plugins.registry import get_cached_feature_map
18
+
19
+ from semifun.di.registry_integration import get_injector
20
+
21
+ from .argv import split_argv
22
+ from .cast import cast_args
23
+
24
+
25
+ async def cli_dispatch_engine(
26
+ cli_feature_type: str,
27
+ injector_feature_type: str,
28
+ argv: list[str],
29
+ seed_data: dict,
30
+ ):
31
+ """Discover and run a CLI command with DI and type-cast arguments.
32
+
33
+ Runs inside the caller's event loop — it does not create one. This is
34
+ the standard engine; see `sync_cli_dispatch_engine` for the other path.
35
+
36
+ Args:
37
+ cli_feature_type: Feature type for CLI commands (e.g., 'cli').
38
+ injector_feature_type: Feature type for DI injectors (e.g., 'cli_inject').
39
+ argv: Command-line arguments, without the program name.
40
+ seed_data: seed_data dict for DI; `{}` when there is none.
41
+ """
42
+ resolved = _resolve(cli_feature_type, argv)
43
+ if resolved is None:
44
+ return
45
+ fn, cast_positional, cast_kwargs = resolved
46
+
47
+ di = get_injector(injector_feature_type).with_seed_data(seed_data)
48
+
49
+ result = await di.async_call_with_args(
50
+ fn=fn,
51
+ args=cast_positional,
52
+ kwargs=cast_kwargs,
53
+ )
54
+
55
+ if result is not None:
56
+ print(result)
57
+
58
+
59
+ def sync_cli_dispatch_engine(
60
+ cli_feature_type: str,
61
+ injector_feature_type: str,
62
+ argv: list[str],
63
+ seed_data: dict,
64
+ ):
65
+ """Same as `cli_dispatch_engine`, but owns the event loop.
66
+
67
+ Use this only when a command function is itself sync and
68
+ starts its own loop (marked `@sync_function_owns_async_loop`); such a
69
+ command cannot be awaited and so forces the dispatcher to be outermost.
70
+ Prefer `cli_dispatch_engine`.
71
+ """
72
+ resolved = _resolve(cli_feature_type, argv)
73
+ if resolved is None:
74
+ return
75
+ fn, cast_positional, cast_kwargs = resolved
76
+
77
+ di = get_injector(injector_feature_type).with_seed_data(seed_data)
78
+
79
+ if getattr(fn, 'sync_function_owns_async_loop', False):
80
+ result = di.sync_call_with_args(
81
+ fn=fn,
82
+ args=cast_positional,
83
+ kwargs=cast_kwargs,
84
+ )
85
+ else:
86
+ result = asyncio.run(di.async_call_with_args(
87
+ fn=fn,
88
+ args=cast_positional,
89
+ kwargs=cast_kwargs,
90
+ ))
91
+
92
+ if result is not None:
93
+ print(result)
94
+
95
+
96
+ def _resolve(cli_feature_type: str, argv: list[str]):
97
+ """Find the command and prepare its arguments — everything before the call.
98
+
99
+ Returns (fn, args, kwargs), or None when help was printed instead.
100
+ Exits with status 1 on an unknown command.
101
+ """
102
+ cli_map = get_cached_feature_map(feature_type=cli_feature_type)
103
+
104
+ if not argv or argv[0] == '--help':
105
+ _print_help(cli_map)
106
+ return None
107
+
108
+ command_name = argv[0]
109
+ command_argv = argv[1:]
110
+
111
+ fn = cli_map(feature=command_name, default=None)
112
+
113
+ if fn is None:
114
+ print(f"Unknown command: {command_name}")
115
+ print()
116
+ _print_help(cli_map)
117
+ sys.exit(1)
118
+
119
+ if command_argv == ['--help']:
120
+ _print_command_help(command_name, fn)
121
+ return None
122
+
123
+ str_args, str_kwargs = split_argv(command_argv)
124
+ cast_positional, cast_kwargs = cast_args(fn, str_args, str_kwargs)
125
+ return fn, cast_positional, cast_kwargs
126
+
127
+
128
+ def _print_command_help(name: str, fn):
129
+ """Print detailed help for a single command."""
130
+ sig = inspect.signature(fn)
131
+ doc = inspect.cleandoc(fn.__doc__ or "(no description)")
132
+ indented = textwrap.indent(doc, " ")
133
+ print(f"{name}{sig}")
134
+ print(indented)
135
+
136
+
137
+ def _print_help(cli_map):
138
+ """Print help for all discovered CLI commands."""
139
+ if not cli_map.feature_names:
140
+ print("No commands available.")
141
+ return
142
+ print("Available commands:\n")
143
+ for name, fn in cli_map.feature_names_and_objects:
144
+ _print_command_help(name, fn)
145
+ print()
semifun/di/__init__.py ADDED
File without changes
@@ -0,0 +1,154 @@
1
+ """Per-call async execution context for dependency injection.
2
+
3
+ A new `_AsyncExecutionContext` is created for each public async DI API call.
4
+ It holds the per-call state (resolution cache, cleanup stack, cycle-detection
5
+ lock) and delegates signature lookups to the parent `DependencyInjector`.
6
+
7
+ This module is internal — the public API lives on `DependencyInjector`.
8
+
9
+ **THIS IS THE SOURCE OF TRUTH FOR THE DI EXECUTION LOGIC.** Any changes here
10
+ must be propagated to `sync_execution_context.py` following the rules in
11
+ `SYNC-CONVERSION.md`.
12
+ """
13
+
14
+ from dataclasses import dataclass, field
15
+ from inspect import isasyncgen, isawaitable, isgenerator
16
+ from typing import Any, Callable, TYPE_CHECKING
17
+
18
+ from .model import InjectArg
19
+ from .signature_processing import cache_key_for
20
+
21
+ if TYPE_CHECKING:
22
+ from .injector import DependencyInjector
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class _AsyncExecutionContext:
27
+ """One async DI execution. Created fresh per public API call, disposed at end.
28
+
29
+ Holds:
30
+ - `cache`: Type → resolved value, seeded from seed_data
31
+ - `cleanup_stack`: generators awaiting their final next/anext for cleanup
32
+ - `lock`: cache keys of functions currently being invoked (cycle detection)
33
+ - `injector`: reference to the parent DependencyInjector
34
+ """
35
+
36
+ injector: "DependencyInjector"
37
+ cache: dict[type, Any] = field(init=False)
38
+ cleanup_stack: list[Any] = field(default_factory=list, init=False)
39
+ lock: set[Any] = field(default_factory=set, init=False)
40
+
41
+ def __post_init__(self) -> None:
42
+ # Shallow copy of seed_data, plus auto-seed DependencyInjector for re-entrant DI access.
43
+ object.__setattr__(self, 'cache', {**self.injector.seed_data, type(self.injector): self.injector})
44
+
45
+ # --- Resolution of one injected argument ---
46
+
47
+ async def _resolve_inject_arg(self, arg: InjectArg) -> Any:
48
+ """Resolve one InjectArg to a value.
49
+
50
+ Order:
51
+ 1. Cache hit → return cached value.
52
+ 2. No cache and no injector → raise.
53
+ 3. Otherwise call the injector recursively, isinstance-check, cache, return.
54
+ """
55
+ if arg.type in self.cache:
56
+ return self.cache[arg.type]
57
+ if arg.injector_fn is None:
58
+ raise LookupError(
59
+ f"Cannot resolve injection for parameter {arg.name!r}: "
60
+ f"type {arg.type.__name__!r} is not in seed_data and no injector "
61
+ f"is registered for that type name."
62
+ )
63
+ value = await self._invoke_call_with_args(arg.injector_fn, args=(), kwargs={})
64
+ if not isinstance(value, arg.type):
65
+ raise TypeError(
66
+ f"Injector for {arg.type.__name__!r} returned a value of type "
67
+ f"{type(value).__name__!r} which is not an instance of "
68
+ f"{arg.type.__name__!r}."
69
+ )
70
+ self.cache[arg.type] = value
71
+ return value
72
+
73
+ # --- Invoking functions with DI ---
74
+
75
+ async def _invoke_call_with_args(
76
+ self,
77
+ fn: Callable[..., Any],
78
+ args: tuple,
79
+ kwargs: dict[str, Any],
80
+ ) -> Any:
81
+ """Call `fn` with passthrough args/kwargs plus resolved Inject[T] args.
82
+
83
+ Python's call mechanism does the passthrough/keyword matching, default
84
+ filling, and conflict detection (e.g. caller kwargs cannot collide with
85
+ injected names — Python raises 'multiple values for keyword argument').
86
+ """
87
+ key = cache_key_for(fn)
88
+ if key in self.lock:
89
+ raise RecursionError(
90
+ f"Circular dependency detected: {_fn_name(fn)} is already being resolved."
91
+ )
92
+ self.lock.add(key)
93
+ try:
94
+ sig = self.injector.cached_call_with_args_signature(fn)
95
+ inject_kwargs: dict[str, Any] = {}
96
+ for arg in sig.injected_args:
97
+ inject_kwargs[arg.name] = await self._resolve_inject_arg(arg)
98
+ return await self._handle_result(fn(*args, **kwargs, **inject_kwargs))
99
+ finally:
100
+ self.lock.discard(key)
101
+
102
+ # --- Handling fn's return value (generator / async-generator / awaitable / value) ---
103
+
104
+ async def _handle_result(self, result: Any) -> Any:
105
+ """Process whatever the function returned.
106
+
107
+ - sync generator → take first yield, push to cleanup stack
108
+ - async generator → await first yield, push to cleanup stack
109
+ - awaitable (coroutine) → await it
110
+ - plain value → return as-is
111
+ """
112
+ if isgenerator(result):
113
+ gen = result
114
+ value = next(gen)
115
+ self.cleanup_stack.append(gen)
116
+ return value
117
+ if isasyncgen(result):
118
+ gen = result
119
+ value = await gen.__anext__()
120
+ self.cleanup_stack.append(gen)
121
+ return value
122
+ if isawaitable(result):
123
+ return await result
124
+ return result
125
+
126
+ # --- Cleanup at end of DI execution ---
127
+
128
+ async def _cleanup(self) -> None:
129
+ """Drain the cleanup stack in reverse order.
130
+
131
+ Each generator gets one more next/anext; we expect StopIteration /
132
+ StopAsyncIteration. Other exceptions are chained and re-raised after
133
+ all cleanups have run.
134
+ """
135
+ exception: BaseException | None = None
136
+ for gen in reversed(self.cleanup_stack):
137
+ try:
138
+ if isasyncgen(gen):
139
+ await gen.__anext__()
140
+ else:
141
+ next(gen)
142
+ except (StopIteration, StopAsyncIteration):
143
+ pass
144
+ except BaseException as new_exc:
145
+ if exception is not None:
146
+ new_exc.__context__ = exception
147
+ exception = new_exc
148
+ self.cleanup_stack.clear()
149
+ if exception is not None:
150
+ raise exception
151
+
152
+
153
+ def _fn_name(fn: Callable[..., Any]) -> str:
154
+ return getattr(fn, "__name__", repr(fn))