python-infrakit-dev 0.1.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.
- infrakit/__init__.py +0 -0
- infrakit/cli/__init__.py +1 -0
- infrakit/cli/commands/__init__.py +1 -0
- infrakit/cli/commands/deps.py +530 -0
- infrakit/cli/commands/init.py +129 -0
- infrakit/cli/commands/llm.py +295 -0
- infrakit/cli/commands/logger.py +160 -0
- infrakit/cli/commands/module.py +342 -0
- infrakit/cli/commands/time.py +81 -0
- infrakit/cli/main.py +65 -0
- infrakit/core/__init__.py +0 -0
- infrakit/core/config/__init__.py +0 -0
- infrakit/core/config/converter.py +480 -0
- infrakit/core/config/exporter.py +304 -0
- infrakit/core/config/loader.py +713 -0
- infrakit/core/config/validator.py +389 -0
- infrakit/core/logger/__init__.py +21 -0
- infrakit/core/logger/formatters.py +143 -0
- infrakit/core/logger/handlers.py +322 -0
- infrakit/core/logger/retention.py +176 -0
- infrakit/core/logger/setup.py +314 -0
- infrakit/deps/__init__.py +239 -0
- infrakit/deps/clean.py +141 -0
- infrakit/deps/depfile.py +405 -0
- infrakit/deps/health.py +357 -0
- infrakit/deps/optimizer.py +642 -0
- infrakit/deps/scanner.py +550 -0
- infrakit/llm/__init__.py +35 -0
- infrakit/llm/batch.py +165 -0
- infrakit/llm/client.py +575 -0
- infrakit/llm/key_manager.py +728 -0
- infrakit/llm/llm_readme.md +306 -0
- infrakit/llm/models.py +148 -0
- infrakit/llm/providers/__init__.py +5 -0
- infrakit/llm/providers/base.py +112 -0
- infrakit/llm/providers/gemini.py +164 -0
- infrakit/llm/providers/openai.py +168 -0
- infrakit/llm/rate_limiter.py +54 -0
- infrakit/scaffolder/__init__.py +31 -0
- infrakit/scaffolder/ai.py +508 -0
- infrakit/scaffolder/backend.py +555 -0
- infrakit/scaffolder/cli_tool.py +386 -0
- infrakit/scaffolder/generator.py +338 -0
- infrakit/scaffolder/pipeline.py +562 -0
- infrakit/scaffolder/registry.py +121 -0
- infrakit/time/__init__.py +60 -0
- infrakit/time/profiler.py +511 -0
- python_infrakit_dev-0.1.0.dist-info/METADATA +124 -0
- python_infrakit_dev-0.1.0.dist-info/RECORD +51 -0
- python_infrakit_dev-0.1.0.dist-info/WHEEL +4 -0
- python_infrakit_dev-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""
|
|
2
|
+
infrakit.core.config.converter
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Convert a config dict (or file) between supported formats.
|
|
5
|
+
Lossy conversions (e.g. YAML list → INI) are handled by stringifying the value
|
|
6
|
+
and recording a warning — no data is silently dropped.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from infrakit.core.config.converter import convert_file, convert_dict
|
|
10
|
+
|
|
11
|
+
# File → file
|
|
12
|
+
warnings = convert_file("config.yaml", "config.ini")
|
|
13
|
+
|
|
14
|
+
# Dict → string (useful for previewing output)
|
|
15
|
+
output, warnings = convert_dict(data, from_format="yaml", to_format="ini")
|
|
16
|
+
|
|
17
|
+
# Check for lossy conversions
|
|
18
|
+
for w in warnings:
|
|
19
|
+
print(w)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from configparser import ConfigParser
|
|
26
|
+
from io import StringIO
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import yaml
|
|
32
|
+
_YAML_AVAILABLE = True
|
|
33
|
+
except ImportError:
|
|
34
|
+
_YAML_AVAILABLE = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Public types
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
ConfigDict = dict[str, Any]
|
|
42
|
+
Format = str # "json" | "yaml" | "ini" | "env"
|
|
43
|
+
|
|
44
|
+
SUPPORTED_FORMATS = {"json", "yaml", "ini", "env"}
|
|
45
|
+
|
|
46
|
+
# Maps file extensions to format names
|
|
47
|
+
_EXT_TO_FORMAT: dict[str, Format] = {
|
|
48
|
+
".json": "json",
|
|
49
|
+
".yaml": "yaml",
|
|
50
|
+
".yml": "yaml",
|
|
51
|
+
".ini": "ini",
|
|
52
|
+
".cfg": "ini",
|
|
53
|
+
".env": "env",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Maps format names to canonical output extensions
|
|
57
|
+
_FORMAT_TO_EXT: dict[Format, str] = {
|
|
58
|
+
"json": ".json",
|
|
59
|
+
"yaml": ".yaml",
|
|
60
|
+
"ini": ".ini",
|
|
61
|
+
"env": ".env",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Exceptions
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
class ConversionError(Exception):
|
|
70
|
+
"""Raised when a conversion cannot be completed."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Conversion warnings
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
class ConversionWarning:
|
|
78
|
+
"""Records a lossy conversion on a single key.
|
|
79
|
+
|
|
80
|
+
Attributes
|
|
81
|
+
----------
|
|
82
|
+
key:
|
|
83
|
+
The dot-separated config key that was affected.
|
|
84
|
+
original:
|
|
85
|
+
The original Python value.
|
|
86
|
+
stringified:
|
|
87
|
+
The string representation written to the output.
|
|
88
|
+
reason:
|
|
89
|
+
Human-readable explanation of why the conversion was lossy.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
key: str,
|
|
95
|
+
original: Any,
|
|
96
|
+
stringified: str,
|
|
97
|
+
reason: str,
|
|
98
|
+
) -> None:
|
|
99
|
+
self.key = key
|
|
100
|
+
self.original = original
|
|
101
|
+
self.stringified = stringified
|
|
102
|
+
self.reason = reason
|
|
103
|
+
|
|
104
|
+
def __str__(self) -> str:
|
|
105
|
+
return (
|
|
106
|
+
f"[{self.key}] Lossy conversion: {self.reason}. "
|
|
107
|
+
f"Value {self.original!r} written as {self.stringified!r}."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def __repr__(self) -> str:
|
|
111
|
+
return f"ConversionWarning(key={self.key!r}, reason={self.reason!r})"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Public API
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def convert_file(
|
|
119
|
+
source: str | Path,
|
|
120
|
+
target: str | Path,
|
|
121
|
+
*,
|
|
122
|
+
overwrite: bool = False,
|
|
123
|
+
) -> list[ConversionWarning]:
|
|
124
|
+
"""Convert *source* config file to the format implied by *target*'s extension.
|
|
125
|
+
|
|
126
|
+
Parameters
|
|
127
|
+
----------
|
|
128
|
+
source:
|
|
129
|
+
Path to the input file. Format inferred from extension.
|
|
130
|
+
target:
|
|
131
|
+
Path to the output file. Format inferred from extension.
|
|
132
|
+
The file is created (or overwritten if *overwrite* is True).
|
|
133
|
+
overwrite:
|
|
134
|
+
If False (default), raises :exc:`FileExistsError` when *target* exists.
|
|
135
|
+
|
|
136
|
+
Returns
|
|
137
|
+
-------
|
|
138
|
+
list[ConversionWarning]
|
|
139
|
+
Any lossy conversions that occurred. Empty if conversion was lossless.
|
|
140
|
+
|
|
141
|
+
Raises
|
|
142
|
+
------
|
|
143
|
+
FileNotFoundError
|
|
144
|
+
If *source* does not exist.
|
|
145
|
+
FileExistsError
|
|
146
|
+
If *target* exists and *overwrite* is False.
|
|
147
|
+
ConversionError
|
|
148
|
+
If the format combination is not supported.
|
|
149
|
+
"""
|
|
150
|
+
source = Path(source)
|
|
151
|
+
target = Path(target)
|
|
152
|
+
|
|
153
|
+
if not source.exists():
|
|
154
|
+
raise FileNotFoundError(f"Source file not found: '{source}'")
|
|
155
|
+
|
|
156
|
+
if target.exists() and not overwrite:
|
|
157
|
+
raise FileExistsError(
|
|
158
|
+
f"Target file '{target}' already exists. Pass overwrite=True to replace it."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
from_fmt = _infer_format(source)
|
|
162
|
+
to_fmt = _infer_format(target)
|
|
163
|
+
|
|
164
|
+
raw = source.read_text(encoding="utf-8")
|
|
165
|
+
data = _parse(raw, from_fmt)
|
|
166
|
+
output, warnings = _serialize(data, to_fmt, source_format=from_fmt)
|
|
167
|
+
|
|
168
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
target.write_text(output, encoding="utf-8")
|
|
170
|
+
|
|
171
|
+
return warnings
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def convert_dict(
|
|
175
|
+
data: ConfigDict,
|
|
176
|
+
*,
|
|
177
|
+
from_format: Format,
|
|
178
|
+
to_format: Format,
|
|
179
|
+
) -> tuple[str, list[ConversionWarning]]:
|
|
180
|
+
"""Convert *data* from one format to another, returning the serialized string.
|
|
181
|
+
|
|
182
|
+
Parameters
|
|
183
|
+
----------
|
|
184
|
+
data:
|
|
185
|
+
A config dict (as returned by the loader).
|
|
186
|
+
from_format:
|
|
187
|
+
The logical origin format of *data* — used to decide which lossy
|
|
188
|
+
conversion rules apply (e.g. INI sections vs flat keys).
|
|
189
|
+
to_format:
|
|
190
|
+
The desired output format.
|
|
191
|
+
|
|
192
|
+
Returns
|
|
193
|
+
-------
|
|
194
|
+
tuple[str, list[ConversionWarning]]
|
|
195
|
+
``(serialized_string, warnings)``
|
|
196
|
+
"""
|
|
197
|
+
if from_format not in SUPPORTED_FORMATS:
|
|
198
|
+
raise ConversionError(
|
|
199
|
+
f"Unknown source format '{from_format}'. "
|
|
200
|
+
f"Supported: {', '.join(sorted(SUPPORTED_FORMATS))}"
|
|
201
|
+
)
|
|
202
|
+
if to_format not in SUPPORTED_FORMATS:
|
|
203
|
+
raise ConversionError(
|
|
204
|
+
f"Unknown target format '{to_format}'. "
|
|
205
|
+
f"Supported: {', '.join(sorted(SUPPORTED_FORMATS))}"
|
|
206
|
+
)
|
|
207
|
+
return _serialize(data, to_format, source_format=from_format)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# Format parsers (string → dict)
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
def _parse(raw: str, fmt: Format) -> ConfigDict:
|
|
215
|
+
if fmt == "json":
|
|
216
|
+
return _parse_json(raw)
|
|
217
|
+
if fmt == "yaml":
|
|
218
|
+
return _parse_yaml(raw)
|
|
219
|
+
if fmt == "ini":
|
|
220
|
+
return _parse_ini(raw)
|
|
221
|
+
if fmt == "env":
|
|
222
|
+
return _parse_env(raw)
|
|
223
|
+
raise ConversionError(f"No parser for format '{fmt}'")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _parse_json(raw: str) -> ConfigDict:
|
|
227
|
+
try:
|
|
228
|
+
data = json.loads(raw)
|
|
229
|
+
except json.JSONDecodeError as exc:
|
|
230
|
+
raise ConversionError(f"Invalid JSON: {exc}") from exc
|
|
231
|
+
if not isinstance(data, dict):
|
|
232
|
+
raise ConversionError("JSON root must be an object.")
|
|
233
|
+
return data
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _parse_yaml(raw: str) -> ConfigDict:
|
|
237
|
+
if not _YAML_AVAILABLE:
|
|
238
|
+
raise ConversionError(
|
|
239
|
+
"PyYAML is required for YAML conversion. pip install pyyaml"
|
|
240
|
+
)
|
|
241
|
+
try:
|
|
242
|
+
data = yaml.safe_load(raw)
|
|
243
|
+
except yaml.YAMLError as exc:
|
|
244
|
+
raise ConversionError(f"Invalid YAML: {exc}") from exc
|
|
245
|
+
return data or {}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _parse_ini(raw: str) -> ConfigDict:
|
|
249
|
+
parser = ConfigParser()
|
|
250
|
+
parser.read_string(raw)
|
|
251
|
+
result: ConfigDict = {}
|
|
252
|
+
if parser.defaults():
|
|
253
|
+
result["DEFAULT"] = dict(parser.defaults())
|
|
254
|
+
for section in parser.sections():
|
|
255
|
+
result[section] = {
|
|
256
|
+
k: parser.get(section, k)
|
|
257
|
+
for k in parser.options(section)
|
|
258
|
+
if k not in parser.defaults()
|
|
259
|
+
}
|
|
260
|
+
return result
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _parse_env(raw: str) -> ConfigDict:
|
|
264
|
+
result: ConfigDict = {}
|
|
265
|
+
for line in raw.splitlines():
|
|
266
|
+
line = line.strip()
|
|
267
|
+
if not line or line.startswith("#"):
|
|
268
|
+
continue
|
|
269
|
+
if "=" not in line:
|
|
270
|
+
continue
|
|
271
|
+
key, _, value = line.partition("=")
|
|
272
|
+
result[key.strip()] = value.strip().strip('"').strip("'")
|
|
273
|
+
return result
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
# Format serializers (dict → string)
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
def _serialize(
|
|
281
|
+
data: ConfigDict,
|
|
282
|
+
fmt: Format,
|
|
283
|
+
*,
|
|
284
|
+
source_format: Format,
|
|
285
|
+
) -> tuple[str, list[ConversionWarning]]:
|
|
286
|
+
if fmt == "json":
|
|
287
|
+
return _serialize_json(data)
|
|
288
|
+
if fmt == "yaml":
|
|
289
|
+
return _serialize_yaml(data)
|
|
290
|
+
if fmt == "ini":
|
|
291
|
+
return _serialize_ini(data, source_format=source_format)
|
|
292
|
+
if fmt == "env":
|
|
293
|
+
return _serialize_env(data, source_format=source_format)
|
|
294
|
+
raise ConversionError(f"No serializer for format '{fmt}'")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _serialize_json(data: ConfigDict) -> tuple[str, list[ConversionWarning]]:
|
|
298
|
+
"""JSON supports all Python types natively — never lossy."""
|
|
299
|
+
return json.dumps(data, indent=2, default=str) + "\n", []
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _serialize_yaml(data: ConfigDict) -> tuple[str, list[ConversionWarning]]:
|
|
303
|
+
if not _YAML_AVAILABLE:
|
|
304
|
+
raise ConversionError(
|
|
305
|
+
"PyYAML is required for YAML serialization. pip install pyyaml"
|
|
306
|
+
)
|
|
307
|
+
output = yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
308
|
+
return output, []
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _serialize_ini(
|
|
312
|
+
data: ConfigDict,
|
|
313
|
+
*,
|
|
314
|
+
source_format: Format,
|
|
315
|
+
) -> tuple[str, list[ConversionWarning]]:
|
|
316
|
+
"""Serialize to INI format.
|
|
317
|
+
|
|
318
|
+
INI limitations:
|
|
319
|
+
- All values are strings
|
|
320
|
+
- No native list, bool, int, float, or null types
|
|
321
|
+
- Keys must live inside a [section] — top-level flat keys go into [DEFAULT]
|
|
322
|
+
|
|
323
|
+
Lossy conversions are stringified and recorded as warnings.
|
|
324
|
+
"""
|
|
325
|
+
warnings: list[ConversionWarning] = []
|
|
326
|
+
parser = ConfigParser()
|
|
327
|
+
|
|
328
|
+
def _add_section(section: str, mapping: dict[str, Any]) -> None:
|
|
329
|
+
parser.add_section(section)
|
|
330
|
+
for k, v in mapping.items():
|
|
331
|
+
str_v, warn = _to_ini_string(f"{section}.{k}", v)
|
|
332
|
+
if warn:
|
|
333
|
+
warnings.append(warn)
|
|
334
|
+
parser.set(section, k, str_v)
|
|
335
|
+
|
|
336
|
+
# If source was already INI-shaped (nested dicts = sections), preserve that.
|
|
337
|
+
# Otherwise, flatten everything into a single [config] section.
|
|
338
|
+
has_sections = source_format in {"ini"} or all(
|
|
339
|
+
isinstance(v, dict) for v in data.values()
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
if has_sections:
|
|
343
|
+
for section, value in data.items():
|
|
344
|
+
if isinstance(value, dict):
|
|
345
|
+
_add_section(section, value)
|
|
346
|
+
else:
|
|
347
|
+
# Top-level scalar — goes into DEFAULT
|
|
348
|
+
if not parser.has_section("DEFAULT"):
|
|
349
|
+
pass # ConfigParser DEFAULT is implicit
|
|
350
|
+
str_v, warn = _to_ini_string(section, value)
|
|
351
|
+
if warn:
|
|
352
|
+
warnings.append(warn)
|
|
353
|
+
parser.defaults()[section] = str_v
|
|
354
|
+
else:
|
|
355
|
+
_add_section("config", data)
|
|
356
|
+
|
|
357
|
+
buf = StringIO()
|
|
358
|
+
parser.write(buf)
|
|
359
|
+
return buf.getvalue(), warnings
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _serialize_env(
|
|
363
|
+
data: ConfigDict,
|
|
364
|
+
*,
|
|
365
|
+
source_format: Format,
|
|
366
|
+
) -> tuple[str, list[ConversionWarning]]:
|
|
367
|
+
""".env format: KEY=value, flat, all strings. No sections."""
|
|
368
|
+
warnings: list[ConversionWarning] = []
|
|
369
|
+
lines: list[str] = []
|
|
370
|
+
|
|
371
|
+
def _add(original_key: str, output_key: str, value: Any) -> None:
|
|
372
|
+
# original_key used in warnings (preserves source casing)
|
|
373
|
+
# output_key used in the actual .env line (uppercased for non-env sources)
|
|
374
|
+
str_v, warn = _to_env_string(original_key, value)
|
|
375
|
+
if warn:
|
|
376
|
+
warnings.append(warn)
|
|
377
|
+
lines.append(f"{output_key}={str_v}")
|
|
378
|
+
|
|
379
|
+
for k, v in data.items():
|
|
380
|
+
if isinstance(v, dict):
|
|
381
|
+
# Flatten nested dicts: {"db": {"host": "x"}} → DB__HOST=x
|
|
382
|
+
for nested_k, nested_v in v.items():
|
|
383
|
+
flat_key = f"{k.upper()}__{nested_k.upper()}"
|
|
384
|
+
original = f"{k}__{nested_k}"
|
|
385
|
+
_add(original, flat_key, nested_v)
|
|
386
|
+
else:
|
|
387
|
+
out_key = k if source_format == "env" else k.upper()
|
|
388
|
+
_add(k, out_key, v)
|
|
389
|
+
|
|
390
|
+
return "\n".join(lines) + "\n", warnings
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# ---------------------------------------------------------------------------
|
|
394
|
+
# Value stringification helpers
|
|
395
|
+
# ---------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
def _to_ini_string(key: str, value: Any) -> tuple[str, ConversionWarning | None]:
|
|
398
|
+
"""Convert *value* to an INI-safe string, recording a warning if lossy."""
|
|
399
|
+
if isinstance(value, str):
|
|
400
|
+
return value, None
|
|
401
|
+
if isinstance(value, bool):
|
|
402
|
+
return str(value).lower(), None # true/false — readable
|
|
403
|
+
if isinstance(value, (int, float)):
|
|
404
|
+
return str(value), None
|
|
405
|
+
if value is None:
|
|
406
|
+
return "", None
|
|
407
|
+
|
|
408
|
+
# Lists, dicts, and anything else → stringify + warn
|
|
409
|
+
stringified = _stringify_complex(value)
|
|
410
|
+
return stringified, ConversionWarning(
|
|
411
|
+
key=key,
|
|
412
|
+
original=value,
|
|
413
|
+
stringified=stringified,
|
|
414
|
+
reason=f"INI format does not support {type(value).__name__} values",
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _to_env_string(key: str, value: Any) -> tuple[str, ConversionWarning | None]:
|
|
419
|
+
""".env values are always strings — same lossy rules as INI."""
|
|
420
|
+
if isinstance(value, str):
|
|
421
|
+
# Quote values with spaces
|
|
422
|
+
return f'"{value}"' if " " in value else value, None
|
|
423
|
+
if isinstance(value, bool):
|
|
424
|
+
return str(value).lower(), None
|
|
425
|
+
if isinstance(value, (int, float)):
|
|
426
|
+
return str(value), None
|
|
427
|
+
if value is None:
|
|
428
|
+
return "", None
|
|
429
|
+
|
|
430
|
+
stringified = _stringify_complex(value)
|
|
431
|
+
return stringified, ConversionWarning(
|
|
432
|
+
key=key,
|
|
433
|
+
original=value,
|
|
434
|
+
stringified=stringified,
|
|
435
|
+
reason=f".env format does not support {type(value).__name__} values",
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _stringify_complex(value: Any) -> str:
|
|
440
|
+
"""Stringify a complex value (list, dict, etc.) into a compact string.
|
|
441
|
+
|
|
442
|
+
Lists → comma-joined: [1, "a", True] → "1,a,true"
|
|
443
|
+
Dicts → JSON inline: {"a": 1} → '{"a": 1}'
|
|
444
|
+
Other → repr fallback
|
|
445
|
+
"""
|
|
446
|
+
if isinstance(value, list):
|
|
447
|
+
return ",".join(_scalar_str(item) for item in value)
|
|
448
|
+
if isinstance(value, dict):
|
|
449
|
+
return json.dumps(value, separators=(",", ":"))
|
|
450
|
+
return repr(value)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _scalar_str(value: Any) -> str:
|
|
454
|
+
"""Compact string representation of a scalar for use inside lists."""
|
|
455
|
+
if isinstance(value, bool):
|
|
456
|
+
return str(value).lower()
|
|
457
|
+
if value is None:
|
|
458
|
+
return ""
|
|
459
|
+
return str(value)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ---------------------------------------------------------------------------
|
|
463
|
+
# Helpers
|
|
464
|
+
# ---------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
def _infer_format(path: Path) -> Format:
|
|
467
|
+
"""Infer format from file extension, handling dotfiles like .env."""
|
|
468
|
+
suffix = path.suffix.lower()
|
|
469
|
+
if not suffix:
|
|
470
|
+
# Dotfile: .env, .envrc, etc.
|
|
471
|
+
name = path.name.lower()
|
|
472
|
+
if name.startswith(".") and "." not in name[1:]:
|
|
473
|
+
suffix = name
|
|
474
|
+
fmt = _EXT_TO_FORMAT.get(suffix)
|
|
475
|
+
if fmt is None:
|
|
476
|
+
raise ConversionError(
|
|
477
|
+
f"Cannot infer format from extension '{suffix}'. "
|
|
478
|
+
f"Supported extensions: {', '.join(sorted(_EXT_TO_FORMAT))}"
|
|
479
|
+
)
|
|
480
|
+
return fmt
|