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/_types.py ADDED
@@ -0,0 +1,614 @@
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
+ """Type introspection utilities for confarg."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import dataclasses
10
+ import enum
11
+ import inspect
12
+ import types
13
+ from collections.abc import (
14
+ Callable as CallableABC,
15
+ )
16
+ from collections.abc import (
17
+ Collection as CollectionABC,
18
+ )
19
+ from collections.abc import (
20
+ Iterable as IterableABC,
21
+ )
22
+ from collections.abc import (
23
+ Mapping as MappingABC,
24
+ )
25
+ from collections.abc import (
26
+ MutableMapping as MutableMappingABC,
27
+ )
28
+ from collections.abc import (
29
+ MutableSequence as MutableSequenceABC,
30
+ )
31
+ from collections.abc import (
32
+ MutableSet as MutableSetABC,
33
+ )
34
+ from collections.abc import (
35
+ Sequence as SequenceABC,
36
+ )
37
+ from collections.abc import (
38
+ Set as SetABC,
39
+ )
40
+ from pathlib import Path, PurePath
41
+ from typing import Any, Literal, Union, get_args, get_origin, get_type_hints
42
+
43
+ type TagPolicy = Literal["auto", "always"]
44
+
45
+ _MISSING = object()
46
+
47
+
48
+ class _StrToken(str):
49
+ """String value from CLI args or env vars — eligible for coercion to target type."""
50
+
51
+ __slots__ = ()
52
+
53
+
54
+ def _resolve_type(tp: Any) -> Any:
55
+ """Unwrap TypeAliasType and Annotated wrappers.
56
+
57
+ Args:
58
+ tp: The type to resolve.
59
+
60
+ Returns:
61
+ The unwrapped underlying type.
62
+ """
63
+ while type(tp).__name__ == "TypeAliasType":
64
+ tp = tp.__value__
65
+ from typing import Annotated
66
+
67
+ if get_origin(tp) is Annotated:
68
+ tp = get_args(tp)[0]
69
+ return tp
70
+
71
+
72
+ def _is_struct_like(tp: Any) -> bool:
73
+ """True if tp is a struct (dataclass or plain class), or a union with at least one struct variant."""
74
+ tp = _resolve_type(tp)
75
+ if _is_struct(tp):
76
+ return True
77
+ if _is_union(tp):
78
+ return any(_is_struct(_resolve_type(v)) for v in _union_args_no_none(tp))
79
+ return False
80
+
81
+
82
+ def _is_dc(tp: Any) -> bool:
83
+ """Check whether a type is a dataclass class (not an instance).
84
+
85
+ Args:
86
+ tp: The type to check.
87
+
88
+ Returns:
89
+ True if tp is a dataclass type.
90
+ """
91
+ tp = _resolve_type(tp)
92
+ return dataclasses.is_dataclass(tp) and isinstance(tp, type)
93
+
94
+
95
+ def _dc_fields(tp: Any) -> dict[str, Any]:
96
+ """Return field names mapped to their resolved types for a dataclass.
97
+
98
+ Args:
99
+ tp: A dataclass type.
100
+
101
+ Returns:
102
+ A dict mapping field names to their resolved type annotations.
103
+ """
104
+ hints = get_type_hints(tp)
105
+ return {f.name: _resolve_type(hints[f.name]) for f in dataclasses.fields(tp)}
106
+
107
+
108
+ def _dc_defaults(tp: Any) -> dict[str, Any]:
109
+ """Return field names mapped to their default values.
110
+
111
+ Args:
112
+ tp: A dataclass type.
113
+
114
+ Returns:
115
+ A dict mapping field names to default values for fields that have them.
116
+ """
117
+ out: dict[str, Any] = {}
118
+ for f in dataclasses.fields(tp):
119
+ if f.default is not dataclasses.MISSING:
120
+ out[f.name] = f.default
121
+ elif f.default_factory is not dataclasses.MISSING:
122
+ out[f.name] = f.default_factory()
123
+ return out
124
+
125
+
126
+ def _is_none_type(tp: Any) -> bool:
127
+ """Check whether a type is NoneType.
128
+
129
+ Args:
130
+ tp: The type to check.
131
+
132
+ Returns:
133
+ True if tp is NoneType.
134
+ """
135
+ return _resolve_type(tp) is type(None)
136
+
137
+
138
+ def _is_union(tp: Any) -> bool:
139
+ """Check whether a type is a Union (including X | Y syntax).
140
+
141
+ Args:
142
+ tp: The type to check.
143
+
144
+ Returns:
145
+ True if tp is a Union type.
146
+ """
147
+ o = get_origin(_resolve_type(tp))
148
+ return o is Union or o is types.UnionType
149
+
150
+
151
+ def _union_args(tp: Any) -> list[Any]:
152
+ """Return all type arguments of a Union type.
153
+
154
+ Args:
155
+ tp: A Union type.
156
+
157
+ Returns:
158
+ A list of the Union's type arguments.
159
+ """
160
+ return list(get_args(_resolve_type(tp)))
161
+
162
+
163
+ def _union_args_no_none(tp: Any) -> list[Any]:
164
+ """Return Union type arguments excluding NoneType.
165
+
166
+ Args:
167
+ tp: A Union type.
168
+
169
+ Returns:
170
+ A list of the Union's type arguments with NoneType filtered out.
171
+ """
172
+ return [a for a in get_args(_resolve_type(tp)) if a is not type(None)]
173
+
174
+
175
+ def _allows_none(tp: Any) -> bool:
176
+ """Check whether a type accepts None values.
177
+
178
+ Args:
179
+ tp: The type to check.
180
+
181
+ Returns:
182
+ True if tp is NoneType or an Optional/Union that includes NoneType.
183
+ """
184
+ tp = _resolve_type(tp)
185
+ if _is_none_type(tp):
186
+ return True
187
+ if _is_union(tp):
188
+ return type(None) in _union_args(tp)
189
+ return False
190
+
191
+
192
+ def _unwrap_optional(tp: Any) -> Any | None:
193
+ """Unwrap Optional[T] / Union[T, None] to T.
194
+
195
+ Returns:
196
+ - T (resolved) if tp is Optional[T] — exactly one non-None union variant.
197
+ - tp (resolved) if tp is not a union at all.
198
+ - None if tp is a multi-variant union (two or more non-None variants).
199
+ This sentinel is Python None, never NoneType; it signals the caller
200
+ must handle the multi-variant case separately.
201
+ """
202
+ tp = _resolve_type(tp)
203
+ if not _is_union(tp):
204
+ return tp
205
+ non_none = _union_args_no_none(tp)
206
+ if len(non_none) == 1:
207
+ return _resolve_type(non_none[0])
208
+ return None # multi-variant — caller handles
209
+
210
+
211
+ def _is_bool(tp: Any) -> bool:
212
+ """Check whether a type is bool.
213
+
214
+ Args:
215
+ tp: The type to check.
216
+
217
+ Returns:
218
+ True if tp is bool.
219
+ """
220
+ return _resolve_type(tp) is bool
221
+
222
+
223
+ def _origin(tp: Any) -> Any:
224
+ """Return the generic origin of a type.
225
+
226
+ Args:
227
+ tp: The type to inspect.
228
+
229
+ Returns:
230
+ The origin type (e.g. list for list[int]), or None.
231
+ """
232
+ return get_origin(_resolve_type(tp))
233
+
234
+
235
+ _LIST_ORIGINS = frozenset({list, SequenceABC, MutableSequenceABC, IterableABC, CollectionABC})
236
+ _SET_ORIGINS = frozenset({set, SetABC, MutableSetABC})
237
+ _DICT_ORIGINS = frozenset({dict, MappingABC, MutableMappingABC})
238
+
239
+
240
+ def _is_list(tp: Any) -> bool:
241
+ """Check whether a type is list[...] or an abstract sequence type (Sequence, MutableSequence, Iterable, Collection).
242
+
243
+ Args:
244
+ tp: The type to check.
245
+
246
+ Returns:
247
+ True if tp is a list or sequence type.
248
+ """
249
+ return _origin(tp) in _LIST_ORIGINS
250
+
251
+
252
+ def _is_set(tp: Any) -> bool:
253
+ """Check whether a type is set[...] or an abstract set type (AbstractSet, MutableSet).
254
+
255
+ Args:
256
+ tp: The type to check.
257
+
258
+ Returns:
259
+ True if tp is a set type.
260
+ """
261
+ return _origin(tp) in _SET_ORIGINS
262
+
263
+
264
+ def _is_frozenset(tp: Any) -> bool:
265
+ """Check whether a type is frozenset[...].
266
+
267
+ Args:
268
+ tp: The type to check.
269
+
270
+ Returns:
271
+ True if tp is a frozenset type.
272
+ """
273
+ return _origin(tp) is frozenset
274
+
275
+
276
+ def _is_tuple(tp: Any) -> bool:
277
+ """Check whether a type is tuple[...].
278
+
279
+ Args:
280
+ tp: The type to check.
281
+
282
+ Returns:
283
+ True if tp is a tuple type.
284
+ """
285
+ return _origin(tp) is tuple
286
+
287
+
288
+ def _is_dict(tp: Any) -> bool:
289
+ """Check whether a type is dict[...] or an abstract mapping type (Mapping, MutableMapping).
290
+
291
+ Args:
292
+ tp: The type to check.
293
+
294
+ Returns:
295
+ True if tp is a dict or mapping type.
296
+ """
297
+ return _origin(tp) in _DICT_ORIGINS
298
+
299
+
300
+ def _is_collection(tp: Any) -> bool:
301
+ """Check whether a type is a list, set, frozenset, or tuple.
302
+
303
+ Args:
304
+ tp: The type to check.
305
+
306
+ Returns:
307
+ True if tp is a list, set, frozenset, or tuple type.
308
+ """
309
+ return _is_list(tp) or _is_set(tp) or _is_frozenset(tp) or _is_tuple(tp)
310
+
311
+
312
+ def _is_varlen_collection(tp: Any) -> bool:
313
+ """Check whether a type is a variable-length collection.
314
+
315
+ Matches list, set, frozenset, or tuple[X, ...].
316
+
317
+ Args:
318
+ tp: The type to check.
319
+
320
+ Returns:
321
+ True if tp is a variable-length collection type.
322
+ """
323
+ tp = _resolve_type(tp)
324
+ if _is_list(tp) or _is_set(tp) or _is_frozenset(tp):
325
+ return True
326
+ if _is_tuple(tp):
327
+ a = get_args(tp)
328
+ return len(a) == 2 and a[1] is Ellipsis
329
+ return False
330
+
331
+
332
+ def _elem_type(tp: Any) -> Any:
333
+ """Return the element type of a generic collection.
334
+
335
+ Args:
336
+ tp: A generic collection type (e.g. list[int]).
337
+
338
+ Returns:
339
+ The element type, or Any if not parameterized.
340
+ """
341
+ a = get_args(_resolve_type(tp))
342
+ return _resolve_type(a[0]) if a else Any
343
+
344
+
345
+ def _tuple_types(tp: Any) -> list[Any] | None:
346
+ """Return fixed-length element types for a tuple, or None for tuple[X, ...].
347
+
348
+ Args:
349
+ tp: A tuple type.
350
+
351
+ Returns:
352
+ A list of element types for fixed-length tuples, or None for variable-length.
353
+ """
354
+ a = get_args(_resolve_type(tp))
355
+ if len(a) == 2 and a[1] is Ellipsis:
356
+ return None
357
+ return [_resolve_type(x) for x in a]
358
+
359
+
360
+ def _dict_kv(tp: Any) -> tuple[Any, Any]:
361
+ """Return the key and value types of a dict type.
362
+
363
+ Args:
364
+ tp: A dict type.
365
+
366
+ Returns:
367
+ A (key_type, value_type) tuple, defaulting to (str, Any).
368
+ """
369
+ a = get_args(_resolve_type(tp))
370
+ return (_resolve_type(a[0]), _resolve_type(a[1])) if a else (str, Any)
371
+
372
+
373
+ def _is_literal(tp: Any) -> bool:
374
+ """Check whether a type is a Literal type.
375
+
376
+ Args:
377
+ tp: The type to check.
378
+
379
+ Returns:
380
+ True if tp is a Literal type.
381
+ """
382
+ from typing import Literal
383
+
384
+ return get_origin(_resolve_type(tp)) is Literal
385
+
386
+
387
+ def _literal_values(tp: Any) -> tuple[Any, ...]:
388
+ """Return the allowed values of a Literal type.
389
+
390
+ Args:
391
+ tp: A Literal type.
392
+
393
+ Returns:
394
+ A tuple of the Literal's allowed values.
395
+ """
396
+ return get_args(_resolve_type(tp))
397
+
398
+
399
+ def _is_enum(tp: Any) -> bool:
400
+ """Check whether a type is an Enum subclass.
401
+
402
+ Args:
403
+ tp: The type to check.
404
+
405
+ Returns:
406
+ True if tp is an Enum type.
407
+ """
408
+ tp = _resolve_type(tp)
409
+ return isinstance(tp, type) and issubclass(tp, enum.Enum)
410
+
411
+
412
+ def _all_have_defaults(tp: Any) -> bool:
413
+ """Check whether every field in a struct type has a default value."""
414
+ tp = _resolve_type(tp)
415
+ if not _is_struct(tp):
416
+ return False
417
+ defs = _struct_defaults(tp)
418
+ return all(name in defs for name in _struct_fields(tp))
419
+
420
+
421
+ _PLAIN_CLASS_BUILTINS = frozenset(
422
+ {str, int, float, bool, bytes, bytearray, type(None), list, dict, set, frozenset, tuple, CallableABC}
423
+ )
424
+
425
+
426
+ def _is_plain_class(tp: Any) -> bool:
427
+ """True if tp is a non-dataclass, non-primitive class with __init__ parameters."""
428
+ tp = _resolve_type(tp)
429
+ if not isinstance(tp, type):
430
+ return False
431
+ if _is_dc(tp):
432
+ return False
433
+ if tp in _PLAIN_CLASS_BUILTINS or tp.__module__ == "builtins":
434
+ return False
435
+ if issubclass(tp, enum.Enum) or issubclass(tp, PurePath):
436
+ return False
437
+ try:
438
+ sig = inspect.signature(tp.__init__)
439
+ return any(p.name != "self" for p in sig.parameters.values())
440
+ except (ValueError, TypeError):
441
+ return False
442
+
443
+
444
+ def _init_fields(tp: Any) -> dict[str, Any]:
445
+ """Return {name: resolved_type} for __init__ parameters of a plain class.
446
+
447
+ VAR_POSITIONAL (*args) becomes List[T] and VAR_KEYWORD (**kwargs) becomes
448
+ Dict[str, T], where T is the annotation on the parameter (Any if absent).
449
+ """
450
+ try:
451
+ hints = get_type_hints(tp.__init__)
452
+ except Exception:
453
+ hints = {}
454
+ sig = inspect.signature(tp.__init__)
455
+ result: dict[str, Any] = {}
456
+ for name, param in sig.parameters.items():
457
+ if name == "self":
458
+ continue
459
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
460
+ elem = _resolve_type(hints.get(name, Any))
461
+ result[name] = list[elem]
462
+ elif param.kind == inspect.Parameter.VAR_KEYWORD:
463
+ val = _resolve_type(hints.get(name, Any))
464
+ result[name] = dict[str, val]
465
+ else:
466
+ result[name] = _resolve_type(hints.get(name, Any))
467
+ return result
468
+
469
+
470
+ def _init_defaults(tp: Any) -> dict[str, Any]:
471
+ """Return {name: default} for __init__ parameters that have defaults.
472
+
473
+ VAR_POSITIONAL and VAR_KEYWORD params always get implicit defaults of [] and {}.
474
+ """
475
+ sig = inspect.signature(tp.__init__)
476
+ result: dict[str, Any] = {}
477
+ for name, param in sig.parameters.items():
478
+ if name == "self":
479
+ continue
480
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
481
+ result[name] = []
482
+ elif param.kind == inspect.Parameter.VAR_KEYWORD:
483
+ result[name] = {}
484
+ elif param.default is not inspect.Parameter.empty:
485
+ result[name] = param.default
486
+ return result
487
+
488
+
489
+ def _var_param_names(tp: Any) -> frozenset[str]:
490
+ """Return names of all *args and **kwargs parameters of tp.__init__."""
491
+ if _is_dc(tp):
492
+ return frozenset()
493
+ try:
494
+ sig = inspect.signature(tp.__init__)
495
+ return frozenset(
496
+ name
497
+ for name, param in sig.parameters.items()
498
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
499
+ )
500
+ except (ValueError, TypeError):
501
+ return frozenset()
502
+
503
+
504
+ def _var_positional_name(tp: Any) -> str | None:
505
+ """Return the name of the *args parameter of tp.__init__, or None."""
506
+ if _is_dc(tp):
507
+ return None
508
+ try:
509
+ sig = inspect.signature(tp.__init__)
510
+ for name, param in sig.parameters.items():
511
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
512
+ return name
513
+ except (ValueError, TypeError):
514
+ pass
515
+ return None
516
+
517
+
518
+ def _var_keyword_name(tp: Any) -> str | None:
519
+ """Return the name of the **kwargs parameter of tp.__init__, or None."""
520
+ if _is_dc(tp):
521
+ return None
522
+ try:
523
+ sig = inspect.signature(tp.__init__)
524
+ for name, param in sig.parameters.items():
525
+ if param.kind == inspect.Parameter.VAR_KEYWORD:
526
+ return name
527
+ except (ValueError, TypeError):
528
+ pass
529
+ return None
530
+
531
+
532
+ def _is_struct(tp: Any) -> bool:
533
+ """True if tp is a dataclass or a plain class with __init__ parameters."""
534
+ return _is_dc(tp) or _is_plain_class(tp)
535
+
536
+
537
+ def _struct_fields(tp: Any) -> dict[str, Any]:
538
+ """Return {name: type} for all fields/parameters of a struct or plain class."""
539
+ return _dc_fields(tp) if _is_dc(tp) else _init_fields(tp)
540
+
541
+
542
+ def _struct_defaults(tp: Any) -> dict[str, Any]:
543
+ """Return {name: default} for fields/parameters that have defaults."""
544
+ return _dc_defaults(tp) if _is_dc(tp) else _init_defaults(tp)
545
+
546
+
547
+ def _is_type_ref(tp: Any) -> bool:
548
+ """True for `type`, `type[X]`, or `Type[X]`."""
549
+ tp = _resolve_type(tp)
550
+ return tp is type or get_origin(tp) is type
551
+
552
+
553
+ def _type_ref_constraint(tp: Any) -> type:
554
+ """Upper-bound class from `type[X]`; `object` for bare `type`."""
555
+ tp = _resolve_type(tp)
556
+ args = get_args(tp)
557
+ if args:
558
+ c = _resolve_type(args[0])
559
+ return c if isinstance(c, type) else object
560
+ return object
561
+
562
+
563
+ def _is_callable(tp: Any) -> bool:
564
+ """Check whether a type is Callable[...] or bare Callable."""
565
+ tp = _resolve_type(tp)
566
+ return get_origin(tp) is CallableABC or tp is CallableABC
567
+
568
+
569
+ def _callable_param_types(tp: Any) -> list[Any] | None:
570
+ """Return the declared parameter types for Callable[[T1, T2], R], or None.
571
+
572
+ Returns None for bare Callable or Callable[..., R] (no param constraint).
573
+ """
574
+ args = get_args(_resolve_type(tp))
575
+ if not args:
576
+ return None
577
+ params = args[0]
578
+ if params is Ellipsis:
579
+ return None
580
+ return [_resolve_type(p) for p in params]
581
+
582
+
583
+ def _callable_return_type(tp: Any) -> Any | None:
584
+ """Return the declared return type for Callable[..., R], or None for bare Callable."""
585
+ args = get_args(_resolve_type(tp))
586
+ if len(args) < 2:
587
+ return None
588
+ return _resolve_type(args[1])
589
+
590
+
591
+ def _try_coerce(ft: Any, token: _StrToken) -> Any:
592
+ """Coerce a string token to the target type if unambiguous.
593
+
594
+ Coerces immediately for concrete leaf types (bool, int, float, Path,
595
+ Literal, Enum) so the merged dict has consistent types regardless of source.
596
+ str tokens are returned unchanged — _StrToken is already a str subclass.
597
+ For multi-variant unions, returns token unchanged for construct() to handle.
598
+ """
599
+ from confarg.typedload._coerce import _coerce_leaf # lazy — avoids circular import
600
+
601
+ if ft is None:
602
+ return token
603
+ ft = _resolve_type(ft)
604
+ if _is_union(ft):
605
+ non_none = _union_args_no_none(ft)
606
+ if len(non_none) != 1:
607
+ return token
608
+ ft = _resolve_type(non_none[0])
609
+ if not (_is_literal(ft) or _is_enum(ft) or ft in (bool, int, float, Path)):
610
+ return token
611
+ try:
612
+ return _coerce_leaf(ft, token)
613
+ except Exception:
614
+ return token
@@ -0,0 +1,34 @@
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
+ """confarg.dictexpr — expression interpolation engine for nested dicts.
6
+
7
+ Resolves ``${field.path}`` references and safe Python expressions embedded in
8
+ string values of a nested dict. The engine operates on plain dicts and has no
9
+ dependency on any config-source or dataclass logic.
10
+
11
+ Typical use::
12
+
13
+ from confarg.dictexpr import resolve_expressions
14
+
15
+ data = {"base": "/app", "log": "${base}/logs"}
16
+ resolved = resolve_expressions(data)
17
+ # resolved == {"base": "/app", "log": "/app/logs"}
18
+ """
19
+
20
+ from confarg._errors import (
21
+ CircularReferenceError,
22
+ ExpressionEvalError,
23
+ MissingReferenceError,
24
+ UnsafeExpressionError,
25
+ )
26
+ from confarg.dictexpr._expressions import resolve_expressions
27
+
28
+ __all__ = [
29
+ "CircularReferenceError",
30
+ "ExpressionEvalError",
31
+ "MissingReferenceError",
32
+ "UnsafeExpressionError",
33
+ "resolve_expressions",
34
+ ]