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/_parse_env.py ADDED
@@ -0,0 +1,279 @@
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
+ """Environment variable parsing for confarg."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import warnings
11
+ from collections.abc import Mapping
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from confarg._errors import ConfargError, ConfargWarning
16
+ from confarg._merge import DICT_DELETE, _accumulate_list_delete, _set_nested
17
+ from confarg._types import (
18
+ _dict_kv,
19
+ _elem_type,
20
+ _is_callable,
21
+ _is_dict,
22
+ _is_frozenset,
23
+ _is_list,
24
+ _is_set,
25
+ _is_struct,
26
+ _is_struct_like,
27
+ _is_tuple,
28
+ _is_union,
29
+ _is_varlen_collection,
30
+ _resolve_type,
31
+ _StrToken,
32
+ _struct_fields,
33
+ _try_coerce,
34
+ _tuple_types,
35
+ _union_args_no_none,
36
+ )
37
+
38
+
39
+ def _resolve_env_parts(target: Any, parts: list[str]) -> tuple[list[str], Any]:
40
+ """Map env var parts to actual field names using case-insensitive matching.
41
+
42
+ Walks the type tree to find the correct casing for each path segment.
43
+ Falls back to lowercase when the type is not a dataclass (e.g. dict keys,
44
+ list indices).
45
+
46
+ Args:
47
+ target: The root target type.
48
+ parts: A list of env var path segments.
49
+
50
+ Returns:
51
+ A tuple of (resolved_parts, leaf_type) where resolved_parts has correct
52
+ casing and leaf_type is the resolved type of the final field (or None).
53
+
54
+ Raises:
55
+ ConfargError: If a part matches multiple field names at the same level.
56
+ """
57
+ resolved: list[str] = []
58
+ tp: Any = _resolve_type(target)
59
+
60
+ for part in parts:
61
+ name, tp = _match_env_part(tp, part)
62
+ resolved.append(name)
63
+
64
+ return resolved, tp
65
+
66
+
67
+ def _match_env_part(tp: Any, part: str) -> tuple[str, Any]:
68
+ """Match a single env var part against the current type level.
69
+
70
+ Args:
71
+ tp: The current type being walked.
72
+ part: The env var path segment to match.
73
+
74
+ Returns:
75
+ A tuple of (resolved_name, next_type) where next_type may be None.
76
+ """
77
+ if tp is None:
78
+ return part.lower(), None
79
+
80
+ tp = _resolve_type(tp)
81
+
82
+ if _is_struct(tp):
83
+ flds = _struct_fields(tp)
84
+ matches = [name for name in flds if name.lower() == part.lower()]
85
+ if len(matches) > 1:
86
+ raise ConfargError(
87
+ f"Ambiguous env var segment {part!r}: matches multiple fields {matches} in {tp.__name__}."
88
+ " Check your dataclass definition for duplicate case-insensitive field names."
89
+ )
90
+ if len(matches) == 1:
91
+ return matches[0], flds[matches[0]]
92
+ return part.lower(), None
93
+
94
+ if _is_union(tp):
95
+ name_to_types: dict[str, list[Any]] = {}
96
+ for variant in _union_args_no_none(tp):
97
+ v = _resolve_type(variant)
98
+ if _is_struct(v):
99
+ flds = _struct_fields(v)
100
+ for fname in flds:
101
+ if fname.lower() == part.lower():
102
+ name_to_types.setdefault(fname, []).append(flds[fname])
103
+ if len(name_to_types) > 1:
104
+ raise ConfargError(
105
+ f"Ambiguous env var segment {part!r}: matches fields"
106
+ f" {sorted(name_to_types.keys())} across union variants."
107
+ " Use a more specific environment variable name or add a discriminator field."
108
+ )
109
+ if len(name_to_types) == 1:
110
+ name = next(iter(name_to_types))
111
+ types = name_to_types[name]
112
+ # Only return a concrete type when all variants agree; otherwise None (defer to construct)
113
+ ft = types[0] if all(t == types[0] for t in types[1:]) else None
114
+ return name, ft
115
+ return part.lower(), None
116
+
117
+ if _is_list(tp) or _is_set(tp) or _is_frozenset(tp):
118
+ return part.lower(), _elem_type(tp)
119
+
120
+ if _is_tuple(tp):
121
+ tt = _tuple_types(tp)
122
+ if tt is None:
123
+ return part.lower(), _elem_type(tp)
124
+ try:
125
+ idx = int(part)
126
+ if 0 <= idx < len(tt):
127
+ return part.lower(), tt[idx]
128
+ except ValueError:
129
+ pass
130
+ return part.lower(), None
131
+
132
+ if _is_dict(tp):
133
+ _, vt = _dict_kv(tp)
134
+ return part.lower(), vt
135
+
136
+ return part.lower(), None
137
+
138
+
139
+ def _parse_env(
140
+ env: Mapping[str, str],
141
+ prefix: str,
142
+ separator: str,
143
+ target: Any,
144
+ config_flag: str = "config",
145
+ ) -> tuple[dict[str, Any], list[tuple[str, Path]]]:
146
+ """Parse environment variables into a nested dict matching the target type.
147
+
148
+ Variables are matched by prefix and split by separator into nested keys.
149
+ Field names are matched case-insensitively against the target type tree.
150
+ For non-dataclass targets, the value is stored under a ``__root__`` key.
151
+
152
+ A variable whose first segment (after stripping the prefix) matches
153
+ ``config_flag`` (case-insensitive) is treated as a sub-config file pointer:
154
+ the value is a file path, and the remaining segments form the subpath at
155
+ which the file's contents will be merged (e.g. ``CONFARG_CONFIG__DB=db.yaml``
156
+ merges ``db.yaml`` under the ``db`` key).
157
+
158
+ Args:
159
+ env: The environment variable mapping to scan.
160
+ prefix: Required prefix for relevant variables (empty string to match all).
161
+ separator: Separator used to split variable names into nested keys.
162
+ target: The target type, used to determine dataclass vs scalar handling.
163
+ config_flag: The magic segment name that marks a sub-config file pointer.
164
+
165
+ Returns:
166
+ A tuple of (data_dict, env_configs) where data_dict contains inline values
167
+ and env_configs is a list of (subpath, Path) pairs for deferred file loading.
168
+
169
+ Raises:
170
+ ConfargError: If an env var segment matches multiple field names.
171
+ """
172
+ data: dict[str, Any] = {}
173
+ env_configs: list[tuple[str, Path]] = []
174
+ is_struct = _is_struct_like(_resolve_type(target))
175
+
176
+ for orig_key, value in env.items():
177
+ key = orig_key
178
+ if prefix:
179
+ if not key.startswith(prefix):
180
+ continue
181
+ key = key[len(prefix) :]
182
+ key = key.removeprefix(separator)
183
+
184
+ parts = key.split(separator) if separator in key else [key]
185
+
186
+ # Config flag: CONFARG_CONFIG[__subpath]=file.yaml → defer to file loading
187
+ if parts[0].lower() == config_flag.lower():
188
+ subpath_parts = parts[1:]
189
+ if subpath_parts:
190
+ resolved_parts, _ = _resolve_env_parts(target, subpath_parts)
191
+ subpath = ".".join(resolved_parts)
192
+ else:
193
+ subpath = ""
194
+ env_configs.append((subpath, Path(value)))
195
+ continue
196
+
197
+ # Delete sentinel: FOO__BAR- deletes key BAR from FOO (dict-key deletion).
198
+ # FOO__ITEMS__1- deletes index 1 from FOO__ITEMS (list-index deletion).
199
+ if parts[-1].endswith("-") and len(parts[-1]) > 1:
200
+ raw_last = parts[-1][:-1]
201
+ try:
202
+ delete_idx = int(raw_last)
203
+ is_list_delete = True
204
+ except ValueError:
205
+ is_list_delete = False
206
+ delete_idx = -1
207
+
208
+ if is_list_delete:
209
+ parent_raw = parts[:-1]
210
+ if parent_raw:
211
+ parent_parts, _ = _resolve_env_parts(target, parent_raw)
212
+ else:
213
+ parent_parts = []
214
+ _accumulate_list_delete(data, parent_parts, delete_idx, orig_key)
215
+ else:
216
+ del_parts_raw = parts[:-1] + [raw_last]
217
+ del_parts, _ = _resolve_env_parts(target, del_parts_raw)
218
+ _set_nested(data, del_parts, DICT_DELETE)
219
+ continue
220
+
221
+ if not is_struct:
222
+ # For scalar targets, store under sentinel key
223
+ data["__root__"] = _try_coerce(_resolve_type(target), _StrToken(value))
224
+ continue
225
+
226
+ parts, ft = _resolve_env_parts(target, parts)
227
+ # Skip env vars whose first segment doesn't match a known field
228
+ root_tp = _resolve_type(target)
229
+ if _is_struct(root_tp) and parts[0] not in _struct_fields(root_tp):
230
+ known = sorted(_struct_fields(root_tp).keys())
231
+ warnings.warn(
232
+ f"Environment variable {orig_key!r} has no matching field"
233
+ f" (segment {parts[0]!r} not found in {root_tp.__name__})."
234
+ f" Known fields: {known}. The variable will be ignored.",
235
+ ConfargWarning,
236
+ stacklevel=3,
237
+ )
238
+ continue
239
+ if _is_union(root_tp):
240
+ struct_variants = [_resolve_type(v) for v in _union_args_no_none(root_tp) if _is_struct(_resolve_type(v))]
241
+ if struct_variants and not any(parts[0] in _struct_fields(v) for v in struct_variants):
242
+ all_fields = sorted({f for v in struct_variants for f in _struct_fields(v)})
243
+ warnings.warn(
244
+ f"Environment variable {orig_key!r} has no matching field"
245
+ f" (segment {parts[0]!r} not found in any union variant)."
246
+ f" Known fields across variants: {all_fields}. The variable will be ignored.",
247
+ ConfargWarning,
248
+ stacklevel=3,
249
+ )
250
+ continue
251
+ if value.startswith(("[", "{")):
252
+ accepts_obj = value.startswith("{") and (
253
+ _is_struct(ft)
254
+ or _is_dict(ft)
255
+ or _is_callable(ft)
256
+ or (_is_union(ft) and any(_is_struct(_resolve_type(v)) for v in _union_args_no_none(ft)))
257
+ )
258
+ accepts_arr = value.startswith("[") and (
259
+ _is_varlen_collection(ft)
260
+ or _is_tuple(ft)
261
+ or (
262
+ _is_union(ft)
263
+ and any(
264
+ _is_varlen_collection(_resolve_type(v)) or _is_tuple(_resolve_type(v))
265
+ for v in _union_args_no_none(ft)
266
+ )
267
+ )
268
+ )
269
+ if accepts_obj or accepts_arr:
270
+ try:
271
+ parsed = json.loads(value)
272
+ if isinstance(parsed, list | dict):
273
+ _set_nested(data, parts, parsed)
274
+ continue
275
+ except json.JSONDecodeError:
276
+ pass
277
+ _set_nested(data, parts, _try_coerce(ft, _StrToken(value)))
278
+
279
+ return data, env_configs
confarg/_serialize.py ADDED
@@ -0,0 +1,206 @@
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
+ """Serialization of dataclass instances to plain dicts."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import enum
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from confarg._errors import ConfargError
14
+ from confarg._types import (
15
+ TagPolicy,
16
+ _dict_kv,
17
+ _elem_type,
18
+ _is_callable,
19
+ _is_dict,
20
+ _is_frozenset,
21
+ _is_list,
22
+ _is_set,
23
+ _is_struct,
24
+ _is_tuple,
25
+ _is_union,
26
+ _resolve_type,
27
+ _StrToken,
28
+ _struct_fields,
29
+ _tuple_types,
30
+ _union_args_no_none,
31
+ )
32
+ from confarg.typedload._construct import _disambiguate_struct
33
+
34
+
35
+ def _serialize(
36
+ tp: Any,
37
+ instance: Any,
38
+ path: str,
39
+ union_tag: str,
40
+ tag_policy: TagPolicy,
41
+ ) -> Any:
42
+ """Serialize a typed value to a plain dict/list/leaf structure.
43
+
44
+ Args:
45
+ tp: The declared type of the value.
46
+ instance: The value to serialize.
47
+ path: Dot-separated field path for diagnostics.
48
+ union_tag: The field name used as a discriminator tag in unions.
49
+ tag_policy: "auto" or "always".
50
+
51
+ Returns:
52
+ A JSON-compatible structure (dict, list, or leaf value).
53
+ """
54
+ tp = _resolve_type(tp)
55
+
56
+ if instance is None:
57
+ return None
58
+
59
+ if _is_callable(tp):
60
+ from confarg._callable import _serialize_callable
61
+
62
+ return _serialize_callable(instance)
63
+
64
+ if _is_union(tp):
65
+ return _serialize_union(tp, instance, path, union_tag, tag_policy)
66
+
67
+ if _is_struct(tp):
68
+ return _serialize_struct(tp, instance, path, union_tag, tag_policy)
69
+
70
+ if _is_list(tp):
71
+ et = _elem_type(tp)
72
+ return [_serialize(et, v, f"{path}[{i}]", union_tag, tag_policy) for i, v in enumerate(instance)]
73
+
74
+ if _is_set(tp) or _is_frozenset(tp):
75
+ et = _elem_type(tp)
76
+ items = [_serialize(et, v, path, union_tag, tag_policy) for v in instance]
77
+ return sorted(items, key=_sort_key)
78
+
79
+ if _is_tuple(tp):
80
+ return _serialize_tuple(tp, instance, path, union_tag, tag_policy)
81
+
82
+ if _is_dict(tp):
83
+ return _serialize_dict(tp, instance, path, union_tag, tag_policy)
84
+
85
+ return _serialize_leaf(tp, instance)
86
+
87
+
88
+ def _serialize_struct(
89
+ tp: Any,
90
+ instance: Any,
91
+ path: str,
92
+ union_tag: str,
93
+ tag_policy: TagPolicy,
94
+ ) -> dict[str, Any]:
95
+ """Serialize a dataclass or plain-class instance to a dict."""
96
+ actual_tp = type(instance)
97
+ if actual_tp is not tp and _is_struct(actual_tp) and issubclass(actual_tp, tp):
98
+ out = _serialize_struct(actual_tp, instance, path, union_tag, tag_policy)
99
+ out[union_tag] = f"{actual_tp.__module__}.{actual_tp.__name__}"
100
+ return out
101
+ flds = _struct_fields(tp)
102
+ out: dict[str, Any] = {}
103
+ for name, ft in flds.items():
104
+ try:
105
+ value = getattr(instance, name)
106
+ except AttributeError:
107
+ raise ConfargError(
108
+ f"Field '{name}' is declared in {type(instance).__name__}.__init__"
109
+ f" but not accessible on the instance as self.{name}."
110
+ f" Plain classes must store every __init__ parameter as a same-named instance attribute."
111
+ ) from None
112
+ fp = f"{path}.{name}" if path else name
113
+ out[name] = _serialize(ft, value, fp, union_tag, tag_policy)
114
+ return out
115
+
116
+
117
+ def _serialize_union(
118
+ tp: Any,
119
+ instance: Any,
120
+ path: str,
121
+ union_tag: str,
122
+ tag_policy: TagPolicy,
123
+ ) -> Any:
124
+ """Serialize a Union value, adding a class tag when needed."""
125
+ variant_tp = _find_variant_type(tp, instance)
126
+ if variant_tp is None:
127
+ return instance
128
+
129
+ serialized = _serialize(variant_tp, instance, path, union_tag, tag_policy)
130
+
131
+ if _is_struct(variant_tp) and isinstance(serialized, dict):
132
+ if tag_policy == "always" or _needs_tag(tp, serialized, union_tag):
133
+ serialized[union_tag] = f"{variant_tp.__module__}.{variant_tp.__name__}"
134
+
135
+ return serialized
136
+
137
+
138
+ def _serialize_tuple(
139
+ tp: Any,
140
+ instance: Any,
141
+ path: str,
142
+ union_tag: str,
143
+ tag_policy: TagPolicy,
144
+ ) -> list[Any]:
145
+ """Serialize a tuple to a list."""
146
+ tt = _tuple_types(tp)
147
+ if tt is None:
148
+ et = _elem_type(tp)
149
+ return [_serialize(et, v, f"{path}[{i}]", union_tag, tag_policy) for i, v in enumerate(instance)]
150
+ return [
151
+ _serialize(et, v, f"{path}[{i}]", union_tag, tag_policy)
152
+ for i, (et, v) in enumerate(zip(tt, instance, strict=False))
153
+ ]
154
+
155
+
156
+ def _serialize_dict(
157
+ tp: Any,
158
+ instance: Any,
159
+ path: str,
160
+ union_tag: str,
161
+ tag_policy: TagPolicy,
162
+ ) -> dict[Any, Any]:
163
+ """Serialize a typed dict."""
164
+ kt, vt = _dict_kv(tp)
165
+ return {
166
+ _serialize_leaf(kt, k): _serialize(vt, v, f"{path}.{k}", union_tag, tag_policy) for k, v in instance.items()
167
+ }
168
+
169
+
170
+ def _serialize_leaf(tp: Any, value: Any) -> Any:
171
+ """Serialize a leaf value: Enum → .value, Path → str, float → float, else passthrough."""
172
+ if isinstance(value, enum.Enum):
173
+ return value.value
174
+ if isinstance(value, Path):
175
+ return str(value)
176
+ if tp is float and isinstance(value, int) and not isinstance(value, bool):
177
+ return float(value)
178
+ if type(value) is _StrToken:
179
+ return str(value)
180
+ if isinstance(value, type):
181
+ return f"{value.__module__}.{value.__qualname__}"
182
+ return value
183
+
184
+
185
+ def _find_variant_type(tp: Any, instance: Any) -> Any | None:
186
+ """Find which Union variant matches the instance's type."""
187
+ args = _union_args_no_none(tp)
188
+ for arg in args:
189
+ arg_r = _resolve_type(arg)
190
+ if isinstance(instance, arg_r):
191
+ return arg_r
192
+ return None
193
+
194
+
195
+ def _needs_tag(tp: Any, serialized_data: dict[str, Any], union_tag: str) -> bool:
196
+ """Check if a class tag is needed by running disambiguation on the serialized data."""
197
+ struct_vars = [v for v in _union_args_no_none(tp) if _is_struct(_resolve_type(v))]
198
+ if len(struct_vars) <= 1:
199
+ return False
200
+ matches = _disambiguate_struct(struct_vars, serialized_data, union_tag)
201
+ return len(matches) != 1
202
+
203
+
204
+ def _sort_key(value: Any) -> tuple[str, str]:
205
+ """Sort key for heterogeneous set/frozenset serialization."""
206
+ return (type(value).__name__, str(value))