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 +0 -0
- semifun/caching/__init__.py +0 -0
- semifun/caching/cached_method.py +56 -0
- semifun/caching/cached_property.py +52 -0
- semifun/caching/dictdefault.py +59 -0
- semifun/cli/__init__.py +0 -0
- semifun/cli/argv.py +28 -0
- semifun/cli/cast.py +101 -0
- semifun/cli/decorator.py +26 -0
- semifun/cli/dispatch.py +145 -0
- semifun/di/__init__.py +0 -0
- semifun/di/async_execution_context.py +154 -0
- semifun/di/injector.py +103 -0
- semifun/di/model.py +57 -0
- semifun/di/registry_integration.py +29 -0
- semifun/di/signature_processing.py +71 -0
- semifun/di/sync_execution_context.py +158 -0
- semifun/plugins/__init__.py +1 -0
- semifun/plugins/index.py +87 -0
- semifun/plugins/model.py +153 -0
- semifun/plugins/registry.py +132 -0
- semifun/plugins/scanner.py +99 -0
- semifun/plugins/testing.py +111 -0
- semifun-0.3.0.dist-info/METADATA +13 -0
- semifun-0.3.0.dist-info/RECORD +28 -0
- semifun-0.3.0.dist-info/WHEEL +4 -0
- semifun-0.3.0.dist-info/licenses/COPYING +2 -0
- semifun-0.3.0.dist-info/licenses/LICENSE +5 -0
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()
|
semifun/cli/__init__.py
ADDED
|
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
|
semifun/cli/decorator.py
ADDED
|
@@ -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
|
semifun/cli/dispatch.py
ADDED
|
@@ -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))
|