confarg 0.0.1.dev2__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.
- confarg/__init__.py +440 -0
- confarg/_argparse.py +958 -0
- confarg/_callable.py +593 -0
- confarg/_completion.py +318 -0
- confarg/_defaults.py +15 -0
- confarg/_errors.py +85 -0
- confarg/_files.py +426 -0
- confarg/_merge.py +284 -0
- confarg/_parse_cli.py +507 -0
- confarg/_parse_env.py +279 -0
- confarg/_serialize.py +206 -0
- confarg/_types.py +614 -0
- confarg/dictexpr/__init__.py +34 -0
- confarg/dictexpr/_expressions.py +566 -0
- confarg/typedload/__init__.py +44 -0
- confarg/typedload/_coerce.py +178 -0
- confarg/typedload/_construct.py +685 -0
- confarg-0.0.1.dev2.dist-info/METADATA +9 -0
- confarg-0.0.1.dev2.dist-info/RECORD +20 -0
- confarg-0.0.1.dev2.dist-info/WHEEL +4 -0
confarg/_callable.py
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
|
|
5
|
+
"""Callable resolution and serialization for confarg."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
import importlib
|
|
11
|
+
import inspect
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from confarg._errors import ConfargError, TypeCoercionError
|
|
16
|
+
from confarg._types import (
|
|
17
|
+
_callable_param_types,
|
|
18
|
+
_callable_return_type,
|
|
19
|
+
_is_callable,
|
|
20
|
+
_resolve_type,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _import_dotted(path: str) -> Any:
|
|
25
|
+
"""Import an object by dotted path, trying decreasing module prefixes.
|
|
26
|
+
|
|
27
|
+
Tries importing the longest valid module prefix first, then chains
|
|
28
|
+
getattr for the remaining parts.
|
|
29
|
+
"""
|
|
30
|
+
parts = path.split(".")
|
|
31
|
+
for i in range(len(parts), 0, -1):
|
|
32
|
+
module_path = ".".join(parts[:i])
|
|
33
|
+
try:
|
|
34
|
+
obj = importlib.import_module(module_path)
|
|
35
|
+
except ImportError:
|
|
36
|
+
continue
|
|
37
|
+
try:
|
|
38
|
+
for attr in parts[i:]:
|
|
39
|
+
obj = getattr(obj, attr)
|
|
40
|
+
return obj
|
|
41
|
+
except AttributeError as e:
|
|
42
|
+
raise TypeCoercionError(f"Cannot import {path!r}: {e}") from e
|
|
43
|
+
raise TypeCoercionError(f"Cannot import {path!r}: no importable module found in path")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _detect_owning_class(func: Any) -> type | None:
|
|
47
|
+
"""Return the class that owns func as an instance method, or None.
|
|
48
|
+
|
|
49
|
+
Uses __qualname__ (e.g. 'MyClass.method') and __module__ to find the
|
|
50
|
+
class. Returns None for module-level functions, lambdas, and nested
|
|
51
|
+
scopes that cannot be resolved.
|
|
52
|
+
"""
|
|
53
|
+
qualname = getattr(func, "__qualname__", "")
|
|
54
|
+
if "." not in qualname or "<" in qualname:
|
|
55
|
+
return None
|
|
56
|
+
cls_qualname = qualname.rsplit(".", 1)[0]
|
|
57
|
+
module = sys.modules.get(getattr(func, "__module__", ""))
|
|
58
|
+
if module is None:
|
|
59
|
+
return None
|
|
60
|
+
cls: Any = module
|
|
61
|
+
for part in cls_qualname.split("."):
|
|
62
|
+
cls = getattr(cls, part, None)
|
|
63
|
+
if cls is None:
|
|
64
|
+
return None
|
|
65
|
+
return cls if isinstance(cls, type) else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _maybe_bind_method(func: Any, path: str) -> Any:
|
|
69
|
+
"""If func is an unbound instance method, auto-instantiate its owning class (no args) and return the bound method.
|
|
70
|
+
|
|
71
|
+
Returns func unchanged when it is not an instance method or the class
|
|
72
|
+
requires constructor arguments.
|
|
73
|
+
"""
|
|
74
|
+
if getattr(func, "__name__", None) == "__init__":
|
|
75
|
+
return func
|
|
76
|
+
cls = _detect_owning_class(func)
|
|
77
|
+
if cls is None:
|
|
78
|
+
return func
|
|
79
|
+
try:
|
|
80
|
+
instance = cls()
|
|
81
|
+
except TypeError as e:
|
|
82
|
+
fn_path = f"{func.__module__}.{func.__qualname__}"
|
|
83
|
+
raise TypeCoercionError(
|
|
84
|
+
f"Cannot instantiate {cls.__qualname__!r} with no arguments at '{path}': {e}.\n"
|
|
85
|
+
f"Use the dict form and supply {cls.__qualname__}'s constructor arguments as sibling keys:\n"
|
|
86
|
+
f"{_format_fn_dict_example(fn_path, cls)}"
|
|
87
|
+
) from e
|
|
88
|
+
return getattr(instance, func.__name__)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _format_fn_dict_example(fn_path: str, cls: type) -> str:
|
|
92
|
+
"""Return a YAML-like snippet showing the fn: dict form with required constructor kwargs."""
|
|
93
|
+
try:
|
|
94
|
+
sig = inspect.signature(cls.__init__)
|
|
95
|
+
except (ValueError, TypeError):
|
|
96
|
+
return f" fn: {fn_path}\n # (add constructor arguments here)"
|
|
97
|
+
|
|
98
|
+
params = [
|
|
99
|
+
(name, p)
|
|
100
|
+
for name, p in sig.parameters.items()
|
|
101
|
+
if name != "self"
|
|
102
|
+
and p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
|
103
|
+
and p.default is inspect.Parameter.empty
|
|
104
|
+
]
|
|
105
|
+
optional_params = [
|
|
106
|
+
(name, p)
|
|
107
|
+
for name, p in sig.parameters.items()
|
|
108
|
+
if name != "self"
|
|
109
|
+
and p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
|
110
|
+
and p.default is not inspect.Parameter.empty
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
lines = [f" fn: {fn_path}"]
|
|
114
|
+
for name, p in params:
|
|
115
|
+
ann = p.annotation
|
|
116
|
+
type_hint = f" # {ann.__name__}" if isinstance(ann, type) else ""
|
|
117
|
+
lines.append(f" {name}: <value>{type_hint}")
|
|
118
|
+
for name, p in optional_params:
|
|
119
|
+
ann = p.annotation
|
|
120
|
+
type_hint = f" # {ann.__name__}, optional" if isinstance(ann, type) else " # optional"
|
|
121
|
+
lines.append(f" # {name}: {p.default!r}{type_hint}")
|
|
122
|
+
return "\n".join(lines)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _resolve_callable_spec(spec: Any, tp: Any, path: str, union_tag: str = "class") -> Any:
|
|
126
|
+
"""Resolve a Callable value from a raw spec (string or dict).
|
|
127
|
+
|
|
128
|
+
Bare string:
|
|
129
|
+
- If the import resolves to a class that is a subclass of the Callable
|
|
130
|
+
return type → factory mode: return functools.partial(cls).
|
|
131
|
+
- If the import resolves to a class (no return type match) → instantiate.
|
|
132
|
+
- Otherwise (function) → use as-is.
|
|
133
|
+
|
|
134
|
+
Dict with 'fn' key:
|
|
135
|
+
- The referenced function or method is the callable.
|
|
136
|
+
- If it is an instance method, the owning class is auto-instantiated
|
|
137
|
+
(with sibling kwargs as constructor args, or no args if none are given).
|
|
138
|
+
- 'bind' → functools.partial applied to the resulting callable.
|
|
139
|
+
|
|
140
|
+
Dict with 'class' key where class is a subclass of the Callable return type:
|
|
141
|
+
- Factory mode: return functools.partial(cls, **sibling_kwargs).
|
|
142
|
+
|
|
143
|
+
Dict with 'class' key where class is NOT a subclass of the return type:
|
|
144
|
+
- Callable-object mode: instantiate the class; the instance is the callable.
|
|
145
|
+
- 'bind' → functools.partial applied to the resulting instance.
|
|
146
|
+
|
|
147
|
+
Dict with no 'fn' or 'class' key and a concrete Callable return type:
|
|
148
|
+
- Factory mode: use the return type as the implicit class.
|
|
149
|
+
"""
|
|
150
|
+
if isinstance(spec, str):
|
|
151
|
+
result = _resolve_bare_string(str(spec), path, tp)
|
|
152
|
+
elif isinstance(spec, dict):
|
|
153
|
+
result = _resolve_dict_spec(spec, tp, path, union_tag)
|
|
154
|
+
elif callable(spec):
|
|
155
|
+
result = spec
|
|
156
|
+
else:
|
|
157
|
+
raise TypeCoercionError(
|
|
158
|
+
f"Cannot construct Callable at '{path}': expected str or dict, got {type(spec).__name__} {spec!r}"
|
|
159
|
+
)
|
|
160
|
+
_check_callable_signature(result, tp, path)
|
|
161
|
+
return result
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _resolve_bare_string(path_str: str, path: str, callable_tp: Any = None) -> Any:
|
|
165
|
+
"""Resolve a bare import-path string to a callable.
|
|
166
|
+
|
|
167
|
+
If the object is a class that is a subclass of the Callable return type →
|
|
168
|
+
factory mode: return functools.partial(cls) with no pre-bound kwargs.
|
|
169
|
+
Otherwise, classes are auto-instantiated with no constructor args.
|
|
170
|
+
"""
|
|
171
|
+
obj = _import_dotted(path_str)
|
|
172
|
+
if isinstance(obj, type):
|
|
173
|
+
if _is_factory_class(obj, callable_tp):
|
|
174
|
+
return functools.partial(obj)
|
|
175
|
+
try:
|
|
176
|
+
return obj()
|
|
177
|
+
except TypeError as e:
|
|
178
|
+
raise TypeCoercionError(
|
|
179
|
+
f"Cannot instantiate {path_str!r} with no arguments at '{path}': {e}."
|
|
180
|
+
f" Use the dict form with 'class: {path_str}' to provide"
|
|
181
|
+
" constructor arguments."
|
|
182
|
+
) from e
|
|
183
|
+
return _maybe_bind_method(obj, path)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _resolve_call_kwargs(func: Any, kwargs: dict, path: str, union_tag: str) -> dict:
|
|
187
|
+
"""Coerce and validate kwargs against func's signature using typed construction."""
|
|
188
|
+
from confarg.typedload._construct import construct
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
sig = inspect.signature(func)
|
|
192
|
+
except (ValueError, TypeError):
|
|
193
|
+
return dict(kwargs)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
from typing import get_type_hints
|
|
197
|
+
|
|
198
|
+
hints = get_type_hints(func)
|
|
199
|
+
except Exception:
|
|
200
|
+
hints = {}
|
|
201
|
+
|
|
202
|
+
params = sig.parameters
|
|
203
|
+
has_var_keyword = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values())
|
|
204
|
+
if not has_var_keyword:
|
|
205
|
+
valid = {
|
|
206
|
+
n
|
|
207
|
+
for n, p in params.items()
|
|
208
|
+
if p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
|
209
|
+
}
|
|
210
|
+
invalid = sorted(set(kwargs) - valid)
|
|
211
|
+
if invalid:
|
|
212
|
+
fn_name = getattr(func, "__qualname__", repr(func))
|
|
213
|
+
raise TypeCoercionError(
|
|
214
|
+
f"Unknown kwargs {invalid} for {fn_name!r} at '{path}'. Valid parameters: {sorted(valid)}"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
coerced: dict = {}
|
|
218
|
+
for k, v in kwargs.items():
|
|
219
|
+
ann = hints.get(k, inspect.Parameter.empty)
|
|
220
|
+
if ann is inspect.Parameter.empty:
|
|
221
|
+
coerced[k] = v
|
|
222
|
+
else:
|
|
223
|
+
from confarg._types import _resolve_type
|
|
224
|
+
|
|
225
|
+
resolved_ann = _resolve_type(ann)
|
|
226
|
+
coerced[k] = construct(resolved_ann, v, path=f"{path}.{k}", union_tag=union_tag)
|
|
227
|
+
return coerced
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _resolve_call_spec(fn_path: str, call_kwargs: dict, original_spec: dict, path: str, union_tag: str) -> Any:
|
|
231
|
+
"""Resolve a 'call:' spec: import the function, call it with call_kwargs, use the return value."""
|
|
232
|
+
func = _import_dotted(fn_path)
|
|
233
|
+
coerced = _resolve_call_kwargs(func, call_kwargs, path, union_tag)
|
|
234
|
+
try:
|
|
235
|
+
result = func(**coerced)
|
|
236
|
+
except Exception as e:
|
|
237
|
+
raise TypeCoercionError(f"Failed to call {fn_path!r} at '{path}': {e}") from e
|
|
238
|
+
if not callable(result):
|
|
239
|
+
raise TypeCoercionError(
|
|
240
|
+
f"'call:' at '{path}': {fn_path!r}(**{call_kwargs!r}) returned {type(result).__name__!r},"
|
|
241
|
+
" which is not callable."
|
|
242
|
+
)
|
|
243
|
+
try:
|
|
244
|
+
result.__confarg_spec__ = original_spec
|
|
245
|
+
except (AttributeError, TypeError):
|
|
246
|
+
pass
|
|
247
|
+
return result
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _resolve_dict_spec(spec: dict, callable_tp: Any, path: str, union_tag: str) -> Any:
|
|
251
|
+
"""Resolve a dict callable spec (fn/class/call + sibling kwargs + bind)."""
|
|
252
|
+
has_fn = "fn" in spec
|
|
253
|
+
has_class = "class" in spec
|
|
254
|
+
has_call = "call" in spec
|
|
255
|
+
exclusive = [k for k in ("fn", "class", "call") if k in spec]
|
|
256
|
+
if len(exclusive) > 1:
|
|
257
|
+
raise TypeCoercionError(
|
|
258
|
+
f"Callable dict at '{path}' must not specify more than one of 'fn', 'class', 'call' (got: {exclusive})"
|
|
259
|
+
)
|
|
260
|
+
if not has_fn and not has_class and not has_call:
|
|
261
|
+
# No fn/class key: factory mode if the return type is a concrete class.
|
|
262
|
+
ret = _callable_return_type(callable_tp)
|
|
263
|
+
if ret is not None and isinstance(ret, type) and not getattr(ret, "__abstractmethods__", frozenset()):
|
|
264
|
+
init_kwargs = dict(spec)
|
|
265
|
+
return _resolve_class_spec(
|
|
266
|
+
f"{ret.__module__}.{ret.__qualname__}",
|
|
267
|
+
init_kwargs,
|
|
268
|
+
{},
|
|
269
|
+
spec,
|
|
270
|
+
path,
|
|
271
|
+
union_tag,
|
|
272
|
+
callable_tp,
|
|
273
|
+
)
|
|
274
|
+
raise TypeCoercionError(f"Callable dict at '{path}' must have either a 'fn' or 'class' key")
|
|
275
|
+
bind_raw = spec.get("bind", {})
|
|
276
|
+
if not isinstance(bind_raw, dict):
|
|
277
|
+
raise TypeCoercionError(f"'bind' in Callable dict at '{path}' must be a dict, got {type(bind_raw).__name__}")
|
|
278
|
+
init_kwargs = {k: v for k, v in spec.items() if k not in ("fn", "class", "call", "bind")}
|
|
279
|
+
|
|
280
|
+
if has_call:
|
|
281
|
+
call_kwargs = {**init_kwargs, **bind_raw}
|
|
282
|
+
return _resolve_call_spec(spec["call"], call_kwargs, spec, path, union_tag)
|
|
283
|
+
if has_fn:
|
|
284
|
+
return _resolve_fn_spec(spec["fn"], init_kwargs, bind_raw, path, union_tag)
|
|
285
|
+
return _resolve_class_spec(spec["class"], init_kwargs, bind_raw, spec, path, union_tag, callable_tp)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _coerce_bind_kwargs(callable_obj: Any, bind: dict) -> dict:
|
|
289
|
+
"""Coerce string bind values to the target parameter types via signature inspection.
|
|
290
|
+
|
|
291
|
+
Only coerces to bool/int/float — everything else stays as-is.
|
|
292
|
+
Silently skips parameters with uninspectable signatures (C extensions).
|
|
293
|
+
"""
|
|
294
|
+
if not any(isinstance(v, str) for v in bind.values()):
|
|
295
|
+
return bind
|
|
296
|
+
try:
|
|
297
|
+
sig = inspect.signature(callable_obj)
|
|
298
|
+
except (ValueError, TypeError):
|
|
299
|
+
return bind
|
|
300
|
+
|
|
301
|
+
from typing import get_type_hints
|
|
302
|
+
|
|
303
|
+
from confarg._types import _resolve_type, _unwrap_optional
|
|
304
|
+
from confarg.typedload._coerce import _coerce_leaf
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
hints = get_type_hints(callable_obj)
|
|
308
|
+
except Exception:
|
|
309
|
+
hints = {}
|
|
310
|
+
|
|
311
|
+
result = {}
|
|
312
|
+
for k, v in bind.items():
|
|
313
|
+
if not isinstance(v, str) or k not in sig.parameters:
|
|
314
|
+
result[k] = v
|
|
315
|
+
continue
|
|
316
|
+
ann = hints.get(k, inspect.Parameter.empty)
|
|
317
|
+
if ann is inspect.Parameter.empty:
|
|
318
|
+
result[k] = v
|
|
319
|
+
continue
|
|
320
|
+
tp = _resolve_type(ann)
|
|
321
|
+
tp = _unwrap_optional(tp) or tp # keep original union if multi-variant
|
|
322
|
+
if tp in (bool, int, float):
|
|
323
|
+
try:
|
|
324
|
+
result[k] = _coerce_leaf(tp, v)
|
|
325
|
+
except Exception:
|
|
326
|
+
result[k] = v
|
|
327
|
+
else:
|
|
328
|
+
result[k] = v
|
|
329
|
+
return result
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _check_bind_params(callable_obj: Any, bind: dict, path: str) -> None:
|
|
333
|
+
"""Validate that all bind keys are valid parameter names of callable_obj.
|
|
334
|
+
|
|
335
|
+
Skips validation for callables with **kwargs or uninspectable signatures
|
|
336
|
+
(e.g. C extensions).
|
|
337
|
+
"""
|
|
338
|
+
try:
|
|
339
|
+
sig = inspect.signature(callable_obj)
|
|
340
|
+
except (ValueError, TypeError):
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
params = sig.parameters
|
|
344
|
+
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()):
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
valid = {
|
|
348
|
+
name
|
|
349
|
+
for name, p in params.items()
|
|
350
|
+
if p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
|
351
|
+
}
|
|
352
|
+
invalid = sorted(set(bind) - valid)
|
|
353
|
+
if invalid:
|
|
354
|
+
raise TypeCoercionError(
|
|
355
|
+
f"'bind' at '{path}' contains unknown parameter(s): {invalid}. Valid parameters: {sorted(valid)}"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _resolve_fn_spec(fn_path: str, init_kwargs: dict, bind: dict, path: str, union_tag: str) -> Any:
|
|
360
|
+
"""Resolve a 'fn:' callable spec.
|
|
361
|
+
|
|
362
|
+
If init_kwargs are provided, detect the owning class via __qualname__,
|
|
363
|
+
construct the instance, then retrieve the method. Otherwise use the
|
|
364
|
+
imported object directly.
|
|
365
|
+
"""
|
|
366
|
+
func = _import_dotted(fn_path)
|
|
367
|
+
if init_kwargs and getattr(func, "__name__", None) == "__init__":
|
|
368
|
+
raise TypeCoercionError(
|
|
369
|
+
f"Constructor kwargs {sorted(init_kwargs)} are not valid for '__init__' at '{path}':"
|
|
370
|
+
" '__init__' is treated as a plain function. Use 'bind:' to partially apply arguments."
|
|
371
|
+
)
|
|
372
|
+
if init_kwargs:
|
|
373
|
+
cls = _detect_owning_class(func)
|
|
374
|
+
if cls is None:
|
|
375
|
+
raise TypeCoercionError(
|
|
376
|
+
f"Constructor kwargs {sorted(init_kwargs)} provided for {fn_path!r} at '{path}',"
|
|
377
|
+
" but it does not appear to be an instance method."
|
|
378
|
+
" Use 'bind' to partially apply arguments to a plain function or class."
|
|
379
|
+
)
|
|
380
|
+
instance = _construct_class(cls, init_kwargs, path, union_tag)
|
|
381
|
+
result: Any = getattr(instance, func.__name__)
|
|
382
|
+
else:
|
|
383
|
+
result = _maybe_bind_method(func, path)
|
|
384
|
+
if bind:
|
|
385
|
+
bind = _coerce_bind_kwargs(result, bind)
|
|
386
|
+
_check_bind_params(result, bind, path)
|
|
387
|
+
result = functools.partial(result, **bind)
|
|
388
|
+
return result
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _is_factory_class(cls: type, callable_tp: Any) -> bool:
|
|
392
|
+
"""True if cls should be treated as a factory (partial constructor) rather than instantiated.
|
|
393
|
+
|
|
394
|
+
Factory mode activates when cls is a subclass of the Callable annotation's return type.
|
|
395
|
+
"""
|
|
396
|
+
from confarg._types import _callable_return_type
|
|
397
|
+
|
|
398
|
+
ret = _callable_return_type(callable_tp)
|
|
399
|
+
if ret is None or not isinstance(ret, type) or ret is type(None):
|
|
400
|
+
return False
|
|
401
|
+
try:
|
|
402
|
+
return issubclass(cls, ret)
|
|
403
|
+
except TypeError:
|
|
404
|
+
return False
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _resolve_factory_kwargs(cls: type, kwargs: dict, path: str, union_tag: str) -> dict:
|
|
408
|
+
"""Coerce and validate factory kwargs against cls.__init__ signature."""
|
|
409
|
+
from confarg.typedload._construct import construct
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
sig = inspect.signature(cls.__init__)
|
|
413
|
+
except (ValueError, TypeError):
|
|
414
|
+
return dict(kwargs) # Uninspectable (C extension etc.)
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
from typing import get_type_hints
|
|
418
|
+
|
|
419
|
+
hints = get_type_hints(cls.__init__)
|
|
420
|
+
except Exception:
|
|
421
|
+
hints = {}
|
|
422
|
+
|
|
423
|
+
params = sig.parameters
|
|
424
|
+
has_var_keyword = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values())
|
|
425
|
+
|
|
426
|
+
if not has_var_keyword:
|
|
427
|
+
valid = {
|
|
428
|
+
n
|
|
429
|
+
for n, p in params.items()
|
|
430
|
+
if n != "self" and p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
|
431
|
+
}
|
|
432
|
+
invalid = sorted(set(kwargs) - valid)
|
|
433
|
+
if invalid:
|
|
434
|
+
raise TypeCoercionError(
|
|
435
|
+
f"Unknown constructor kwargs {invalid} for {cls.__qualname__} at '{path}'."
|
|
436
|
+
f" Valid parameters: {sorted(valid)}"
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
coerced: dict = {}
|
|
440
|
+
for k, v in kwargs.items():
|
|
441
|
+
ann = hints.get(k, inspect.Parameter.empty)
|
|
442
|
+
if ann is inspect.Parameter.empty:
|
|
443
|
+
coerced[k] = v
|
|
444
|
+
else:
|
|
445
|
+
from confarg._types import _resolve_type
|
|
446
|
+
|
|
447
|
+
resolved_ann = _resolve_type(ann)
|
|
448
|
+
coerced_v = construct(resolved_ann, v, path=f"{path}.{k}", union_tag=union_tag)
|
|
449
|
+
coerced[k] = coerced_v
|
|
450
|
+
return coerced
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _resolve_class_spec(
|
|
454
|
+
cls_path: str,
|
|
455
|
+
init_kwargs: dict,
|
|
456
|
+
bind: dict,
|
|
457
|
+
original_spec: dict,
|
|
458
|
+
path: str,
|
|
459
|
+
union_tag: str,
|
|
460
|
+
callable_tp: Any = None,
|
|
461
|
+
) -> Any:
|
|
462
|
+
"""Resolve a 'class:' callable spec.
|
|
463
|
+
|
|
464
|
+
Factory mode (cls is subclass of Callable return type):
|
|
465
|
+
return functools.partial(cls, **init_kwargs).
|
|
466
|
+
|
|
467
|
+
Callable-object mode (cls is not a subclass of return type):
|
|
468
|
+
instantiate cls with init_kwargs; the instance must be callable.
|
|
469
|
+
'bind' is then partially applied to the instance.
|
|
470
|
+
"""
|
|
471
|
+
cls = _import_dotted(cls_path)
|
|
472
|
+
if not isinstance(cls, type):
|
|
473
|
+
raise TypeCoercionError(
|
|
474
|
+
f"'class' key at '{path}' must reference a class, got {type(cls).__name__} {cls_path!r}"
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
if _is_factory_class(cls, callable_tp):
|
|
478
|
+
if bind:
|
|
479
|
+
raise TypeCoercionError(
|
|
480
|
+
f"'bind' is not valid in factory mode at '{path}'."
|
|
481
|
+
f" Pass constructor kwargs as sibling keys alongside 'class:'."
|
|
482
|
+
)
|
|
483
|
+
coerced = _resolve_factory_kwargs(cls, init_kwargs, path, union_tag)
|
|
484
|
+
p = functools.partial(cls, **coerced)
|
|
485
|
+
try:
|
|
486
|
+
p.__confarg_spec__ = original_spec
|
|
487
|
+
except (AttributeError, TypeError):
|
|
488
|
+
pass
|
|
489
|
+
return p
|
|
490
|
+
|
|
491
|
+
# Callable-object mode
|
|
492
|
+
instance = _construct_class(cls, init_kwargs, path, union_tag)
|
|
493
|
+
if not callable(instance):
|
|
494
|
+
raise TypeCoercionError(
|
|
495
|
+
f"Instance of {cls_path!r} at '{path}' is not callable."
|
|
496
|
+
" The class must define __call__ to be used as a Callable."
|
|
497
|
+
)
|
|
498
|
+
result: Any
|
|
499
|
+
if bind:
|
|
500
|
+
bind = _coerce_bind_kwargs(instance, bind)
|
|
501
|
+
_check_bind_params(instance, bind, path)
|
|
502
|
+
result = functools.partial(instance, **bind)
|
|
503
|
+
else:
|
|
504
|
+
result = instance
|
|
505
|
+
try:
|
|
506
|
+
result.__confarg_spec__ = original_spec
|
|
507
|
+
except (AttributeError, TypeError):
|
|
508
|
+
pass
|
|
509
|
+
return result
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _construct_class(cls: type, kwargs: dict, path: str, union_tag: str) -> Any:
|
|
513
|
+
"""Construct a class instance using the confarg struct construction pipeline."""
|
|
514
|
+
from confarg.typedload._construct import _construct_struct
|
|
515
|
+
|
|
516
|
+
return _construct_struct(cls, kwargs, path, union_tag)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _check_callable_signature(obj: Any, tp: Any, path: str) -> None:
|
|
520
|
+
"""Validate obj's signature against the Callable[[T1, T2], R] annotation.
|
|
521
|
+
|
|
522
|
+
Checks that the number of required positional/keyword parameters (after
|
|
523
|
+
accounting for already-bound args in a functools.partial) matches the
|
|
524
|
+
declared parameter count. Skips check for bare Callable, Callable[..., R],
|
|
525
|
+
and callables with uninspectable signatures (builtins, C extensions).
|
|
526
|
+
"""
|
|
527
|
+
if not _is_callable(tp):
|
|
528
|
+
return
|
|
529
|
+
param_types = _callable_param_types(tp)
|
|
530
|
+
if param_types is None:
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
sig = inspect.signature(obj)
|
|
535
|
+
except (ValueError, TypeError):
|
|
536
|
+
return
|
|
537
|
+
|
|
538
|
+
has_var_positional = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values())
|
|
539
|
+
if has_var_positional:
|
|
540
|
+
return
|
|
541
|
+
|
|
542
|
+
required = [
|
|
543
|
+
p
|
|
544
|
+
for p in sig.parameters.values()
|
|
545
|
+
if p.default is inspect.Parameter.empty
|
|
546
|
+
and p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
|
547
|
+
]
|
|
548
|
+
if len(required) != len(param_types):
|
|
549
|
+
type_names = [getattr(_resolve_type(t), "__name__", repr(t)) for t in param_types]
|
|
550
|
+
raise TypeCoercionError(
|
|
551
|
+
f"Callable at '{path}': annotation expects {len(param_types)} parameter(s)"
|
|
552
|
+
f" {type_names}, but {obj!r} has {len(required)} required"
|
|
553
|
+
f" parameter(s) {[p.name for p in required]}"
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _serialize_callable(value: Any) -> str | dict:
|
|
558
|
+
"""Serialize a callable value back to its config representation.
|
|
559
|
+
|
|
560
|
+
Factory partial (functools.partial wrapping a class)
|
|
561
|
+
→ {class: "module.qualname", **init_kwargs}
|
|
562
|
+
Function partial → {fn: "module.qualname", bind: {k: v}}
|
|
563
|
+
Class instance with __confarg_spec__ → stored spec dict
|
|
564
|
+
Plain callable → "module.qualname" string
|
|
565
|
+
"""
|
|
566
|
+
spec = getattr(value, "__confarg_spec__", None)
|
|
567
|
+
if spec is not None:
|
|
568
|
+
return spec
|
|
569
|
+
|
|
570
|
+
if isinstance(value, functools.partial):
|
|
571
|
+
func = value.func
|
|
572
|
+
if isinstance(func, type):
|
|
573
|
+
# Factory partial produced by factory mode
|
|
574
|
+
cls_path = f"{func.__module__}.{func.__qualname__}"
|
|
575
|
+
result: dict = {"class": cls_path}
|
|
576
|
+
result.update(value.keywords)
|
|
577
|
+
return result
|
|
578
|
+
fn_path = f"{func.__module__}.{func.__qualname__}"
|
|
579
|
+
result = {"fn": fn_path}
|
|
580
|
+
if value.keywords:
|
|
581
|
+
result["bind"] = dict(value.keywords)
|
|
582
|
+
return result
|
|
583
|
+
|
|
584
|
+
module = getattr(value, "__module__", None)
|
|
585
|
+
qualname = getattr(value, "__qualname__", None)
|
|
586
|
+
if module and qualname:
|
|
587
|
+
return f"{module}.{qualname}"
|
|
588
|
+
|
|
589
|
+
raise ConfargError(
|
|
590
|
+
f"Cannot serialize callable {value!r}: no __module__/__qualname__ available."
|
|
591
|
+
" For class instances, ensure the instance was constructed via confarg"
|
|
592
|
+
" (which stores the spec for round-trip serialization)."
|
|
593
|
+
)
|