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/_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
+ )