src-py-lib 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.
@@ -0,0 +1,603 @@
1
+ """Pydantic-backed Config loading for small CLIs and scripts.
2
+
3
+ Config values use this precedence:
4
+
5
+ code defaults < .env file < shell environment < CLI flags
6
+
7
+ Any field may hold a raw value or an `op://...` reference. Mark truly sensitive
8
+ fields with `secret=True` so snapshots redact them after references are resolved.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import os
15
+ from collections.abc import Iterable, Mapping, Sequence
16
+ from dataclasses import dataclass, replace
17
+ from pathlib import Path
18
+ from types import UnionType
19
+ from typing import Any, Final, Literal, TypeVar, Union, cast, get_args, get_origin
20
+
21
+ from dotenv import dotenv_values
22
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
23
+ from pydantic.config import JsonDict
24
+ from pydantic.fields import FieldInfo
25
+
26
+ from src_py_lib.clients.one_password import (
27
+ OnePasswordClient,
28
+ OnePasswordError,
29
+ resolve_op_secret_ref,
30
+ )
31
+
32
+ DEFAULT_CONFIG_ENV_FILE: Final[Path] = Path(".env")
33
+ CONFIG_HELP_MIN_POSITION: Final[int] = 24
34
+ CONFIG_HELP_MAX_POSITION_LIMIT: Final[int] = 48
35
+ CONFIG_HELP_PADDING: Final[int] = 4
36
+ _CONFIG_OPTION_KEY: Final[str] = "src_py_lib_config_option"
37
+ _MISSING: Final[object] = object()
38
+
39
+
40
+ class ConfigHelpFormatter(argparse.RawTextHelpFormatter):
41
+ """Help formatter for Config-backed CLIs."""
42
+
43
+ def __init__(
44
+ self,
45
+ prog: str,
46
+ indent_increment: int = 2,
47
+ max_help_position: int = CONFIG_HELP_MIN_POSITION,
48
+ width: int | None = None,
49
+ ) -> None:
50
+ super().__init__(prog, indent_increment, max_help_position, width)
51
+
52
+
53
+ class ConfigError(RuntimeError):
54
+ """Raised when Config loading, validation, or reference resolution fails."""
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class ConfigOption:
59
+ """Environment and CLI metadata for one Config field."""
60
+
61
+ field_name: str
62
+ env_var: str
63
+ cli_flag: str = ""
64
+ cli_aliases: tuple[str, ...] = ()
65
+ cli_action: Literal["auto", "store_true", "store_false"] = "auto"
66
+ cli_nargs: str | int | None = None
67
+ cli_const: object | None = None
68
+ metavar: str | None = None
69
+ help: str = ""
70
+ secret: bool = False
71
+ required: bool = False
72
+
73
+
74
+ class Config(BaseModel):
75
+ """Base class for project-specific Pydantic Config models."""
76
+
77
+ model_config = ConfigDict(extra="forbid")
78
+
79
+
80
+ ConfigType = TypeVar("ConfigType", bound=Config)
81
+
82
+
83
+ def config_field(
84
+ *,
85
+ default: Any = ...,
86
+ env_var: str,
87
+ cli_flag: str | None = None,
88
+ cli_aliases: Sequence[str] = (),
89
+ cli_action: Literal["auto", "store_true", "store_false"] = "auto",
90
+ cli_nargs: str | int | None = None,
91
+ cli_const: object | None = None,
92
+ metavar: str | None = None,
93
+ help: str = "",
94
+ secret: bool = False,
95
+ required: bool = False,
96
+ gt: int | float | None = None,
97
+ ge: int | float | None = None,
98
+ lt: int | float | None = None,
99
+ le: int | float | None = None,
100
+ pattern: str | None = None,
101
+ ) -> Any:
102
+ """Return a Pydantic field with Config environment and CLI metadata."""
103
+ option = ConfigOption(
104
+ field_name="",
105
+ env_var=env_var,
106
+ cli_flag=cli_flag or "",
107
+ cli_aliases=tuple(cli_aliases),
108
+ cli_action=cli_action,
109
+ cli_nargs=cli_nargs,
110
+ cli_const=cli_const,
111
+ metavar=metavar,
112
+ help=help,
113
+ secret=secret,
114
+ required=required,
115
+ )
116
+ field_kwargs: dict[str, Any] = {
117
+ "description": help or None,
118
+ "json_schema_extra": cast(JsonDict, {_CONFIG_OPTION_KEY: _config_option_payload(option)}),
119
+ }
120
+ if gt is not None:
121
+ field_kwargs["gt"] = gt
122
+ if ge is not None:
123
+ field_kwargs["ge"] = ge
124
+ if lt is not None:
125
+ field_kwargs["lt"] = lt
126
+ if le is not None:
127
+ field_kwargs["le"] = le
128
+ if pattern is not None:
129
+ field_kwargs["pattern"] = pattern
130
+ return Field(default, **field_kwargs)
131
+
132
+
133
+ def config_options(config_cls: type[Config]) -> tuple[ConfigOption, ...]:
134
+ """Return all Config-backed fields declared on `config_cls`."""
135
+ options: list[ConfigOption] = []
136
+ for field_name, field_info in config_cls.model_fields.items():
137
+ option = _config_option_from_field(field_name, field_info)
138
+ if option is not None:
139
+ options.append(option)
140
+ return tuple(options)
141
+
142
+
143
+ def load_config_env_file(path: Path | None) -> dict[str, str]:
144
+ """Load key/value pairs from a `.env` file.
145
+
146
+ Missing files are ignored. Bare keys without `=` are ignored.
147
+ """
148
+ if path is None or not path.exists():
149
+ return {}
150
+ return {key: value for key, value in dotenv_values(path).items() if value is not None}
151
+
152
+
153
+ def load_config(
154
+ config_cls: type[ConfigType],
155
+ *,
156
+ env_file: Path | None = DEFAULT_CONFIG_ENV_FILE,
157
+ cli_overrides: Mapping[str, object] | None = None,
158
+ env: Mapping[str, str] | None = None,
159
+ base_dir: Path | None = None,
160
+ resolve_op_refs: bool = False,
161
+ op_client: OnePasswordClient | None = None,
162
+ require: Iterable[str] = (),
163
+ ) -> ConfigType:
164
+ """Load, merge, and validate a Config model."""
165
+ base = Path.cwd() if base_dir is None else base_dir
166
+ resolved_env_file = _path_for_source(env_file, base) if env_file is not None else None
167
+ env_file_values = load_config_env_file(resolved_env_file)
168
+ shell_values = os.environ if env is None else env
169
+ override_values = cli_overrides or {}
170
+
171
+ values: dict[str, object] = {}
172
+ for option in config_options(config_cls):
173
+ raw = _selected_raw_value(option, env_file_values, shell_values, override_values)
174
+ if raw is not _MISSING:
175
+ if resolve_op_refs:
176
+ raw = _resolve_source_ref(option, raw, client=op_client)
177
+ field_info = config_cls.model_fields[option.field_name]
178
+ values[option.field_name] = _prepare_source_value(raw, field_info.annotation, base)
179
+
180
+ try:
181
+ config = config_cls.model_validate(values)
182
+ except ValidationError as exception:
183
+ raise ConfigError(f"Invalid Config: {exception}") from exception
184
+
185
+ required = tuple(option.field_name for option in config_options(config_cls) if option.required)
186
+ require_config_values(config, (*required, *tuple(require)))
187
+ return config
188
+
189
+
190
+ def add_config_arguments(
191
+ parser: argparse.ArgumentParser,
192
+ config_cls: type[Config],
193
+ *,
194
+ include_env_file: bool = True,
195
+ ) -> None:
196
+ """Add Config CLI flags to an argparse parser."""
197
+ group = parser.add_argument_group(
198
+ "Config",
199
+ "These options override matching environment variables and .env values",
200
+ )
201
+ if include_env_file:
202
+ group.add_argument(
203
+ "--env-file",
204
+ dest="env_file",
205
+ default=None,
206
+ metavar="PATH",
207
+ help="Read Config .env values from PATH (default: .env)",
208
+ )
209
+
210
+ for option in config_options(config_cls):
211
+ field_info = config_cls.model_fields[option.field_name]
212
+ argument_kwargs: dict[str, Any] = {
213
+ "dest": option.field_name,
214
+ "default": None,
215
+ "help": option.help,
216
+ }
217
+ if option.metavar is not None:
218
+ argument_kwargs["metavar"] = option.metavar
219
+ if option.cli_nargs is not None:
220
+ argument_kwargs["nargs"] = option.cli_nargs
221
+ if option.cli_const is not None:
222
+ argument_kwargs["const"] = option.cli_const
223
+ if _is_bool_annotation(field_info.annotation):
224
+ if option.cli_action == "auto":
225
+ argument_kwargs["action"] = argparse.BooleanOptionalAction
226
+ else:
227
+ argument_kwargs["action"] = option.cli_action
228
+ group.add_argument(option.cli_flag, *option.cli_aliases, **argument_kwargs)
229
+
230
+
231
+ def config_parse_args(
232
+ config_cls: type[ConfigType],
233
+ *,
234
+ parser: argparse.ArgumentParser | None = None,
235
+ argv: Sequence[str] | None = None,
236
+ description: str | None = None,
237
+ include_env_file: bool = True,
238
+ env: Mapping[str, str] | None = None,
239
+ base_dir: Path | None = None,
240
+ resolve_op_refs: bool = True,
241
+ op_client: OnePasswordClient | None = None,
242
+ require: Iterable[str] = (),
243
+ ) -> ConfigType:
244
+ """Parse Config CLI flags and return a validated Config model."""
245
+ max_help_position = _config_help_max_position(config_cls, include_env_file=include_env_file)
246
+ argument_parser = parser or argparse.ArgumentParser(
247
+ description=description,
248
+ formatter_class=_config_help_formatter(max_help_position),
249
+ )
250
+ add_config_arguments(argument_parser, config_cls, include_env_file=include_env_file)
251
+ args = argument_parser.parse_args(argv)
252
+ try:
253
+ return load_config_from_args(
254
+ config_cls,
255
+ args,
256
+ env=env,
257
+ base_dir=base_dir,
258
+ resolve_op_refs=resolve_op_refs,
259
+ op_client=op_client,
260
+ require=require,
261
+ )
262
+ except ConfigError as exception:
263
+ argument_parser.error(str(exception))
264
+
265
+
266
+ def _config_help_formatter(max_help_position: int) -> type[argparse.HelpFormatter]:
267
+ """Return a formatter class with this parser's computed help position."""
268
+
269
+ class DynamicConfigHelpFormatter(ConfigHelpFormatter):
270
+ def __init__(self, prog: str) -> None:
271
+ super().__init__(prog, max_help_position=max_help_position)
272
+
273
+ return DynamicConfigHelpFormatter
274
+
275
+
276
+ def _config_help_max_position(
277
+ config_cls: type[Config],
278
+ *,
279
+ include_env_file: bool,
280
+ ) -> int:
281
+ """Return help-column width based on this Config's CLI arguments."""
282
+ invocation_lengths = [len("--env-file PATH")] if include_env_file else []
283
+ invocation_lengths.extend(
284
+ _config_option_invocation_length(config_cls, option)
285
+ for option in config_options(config_cls)
286
+ )
287
+ longest_invocation = max(invocation_lengths, default=0)
288
+ return min(
289
+ max(CONFIG_HELP_MIN_POSITION, longest_invocation + CONFIG_HELP_PADDING),
290
+ CONFIG_HELP_MAX_POSITION_LIMIT,
291
+ )
292
+
293
+
294
+ def _config_option_invocation_length(config_cls: type[Config], option: ConfigOption) -> int:
295
+ """Return argparse-style option invocation length for help alignment."""
296
+ field_info = config_cls.model_fields[option.field_name]
297
+ option_strings = _config_option_strings(option, field_info)
298
+ if _config_option_takes_value(option, field_info):
299
+ arguments = _config_option_arguments(option)
300
+ return len(", ".join(f"{option_string} {arguments}" for option_string in option_strings))
301
+ return len(", ".join(option_strings))
302
+
303
+
304
+ def _config_option_strings(option: ConfigOption, field_info: FieldInfo) -> tuple[str, ...]:
305
+ """Return option strings as argparse will display them."""
306
+ if _is_bool_annotation(field_info.annotation) and option.cli_action == "auto":
307
+ long_options = tuple(
308
+ f"--no-{option_string.removeprefix('--')}"
309
+ for option_string in (option.cli_flag, *option.cli_aliases)
310
+ if option_string.startswith("--")
311
+ )
312
+ return (option.cli_flag, *long_options, *option.cli_aliases)
313
+ return (option.cli_flag, *option.cli_aliases)
314
+
315
+
316
+ def _config_option_takes_value(option: ConfigOption, field_info: FieldInfo) -> bool:
317
+ """Return whether argparse displays a value placeholder for this option."""
318
+ if not _is_bool_annotation(field_info.annotation):
319
+ return True
320
+ return option.cli_action == "auto" and option.cli_nargs is not None
321
+
322
+
323
+ def _config_option_arguments(option: ConfigOption) -> str:
324
+ """Return the argparse-style value placeholder for this option."""
325
+ metavar = option.metavar or option.field_name.upper()
326
+ if option.cli_nargs == "?":
327
+ return f"[{metavar}]"
328
+ if option.cli_nargs == "*":
329
+ return f"[{metavar} ...]"
330
+ if option.cli_nargs == "+":
331
+ return f"{metavar} [{metavar} ...]"
332
+ if isinstance(option.cli_nargs, int):
333
+ return " ".join(metavar for _ in range(option.cli_nargs))
334
+ return metavar
335
+
336
+
337
+ def config_overrides_from_args(
338
+ config_cls: type[Config], args: argparse.Namespace
339
+ ) -> dict[str, object]:
340
+ """Return Config CLI overrides from parsed argparse args."""
341
+ overrides: dict[str, object] = {}
342
+ for option in config_options(config_cls):
343
+ value = getattr(args, option.field_name, None)
344
+ if value is not None:
345
+ overrides[option.field_name] = value
346
+ return overrides
347
+
348
+
349
+ def config_env_file_from_args(args: argparse.Namespace, *, attr: str = "env_file") -> Path | None:
350
+ """Return the Config `.env` path from parsed argparse args, when supplied."""
351
+ value = getattr(args, attr, None)
352
+ return Path(cast(str, value)).expanduser() if value else None
353
+
354
+
355
+ def load_config_from_args(
356
+ config_cls: type[ConfigType],
357
+ args: argparse.Namespace,
358
+ *,
359
+ env: Mapping[str, str] | None = None,
360
+ base_dir: Path | None = None,
361
+ resolve_op_refs: bool = True,
362
+ op_client: OnePasswordClient | None = None,
363
+ require: Iterable[str] = (),
364
+ ) -> ConfigType:
365
+ """Load Config using argparse values produced by `add_config_arguments`.
366
+
367
+ Secret references are resolved by default because CLI entrypoints usually need
368
+ ready-to-use Config values.
369
+ """
370
+ return load_config(
371
+ config_cls,
372
+ env_file=config_env_file_from_args(args) or DEFAULT_CONFIG_ENV_FILE,
373
+ cli_overrides=config_overrides_from_args(config_cls, args),
374
+ env=env,
375
+ base_dir=base_dir,
376
+ resolve_op_refs=resolve_op_refs,
377
+ op_client=op_client,
378
+ require=require,
379
+ )
380
+
381
+
382
+ def require_config_values(config: Config, fields: Iterable[str]) -> None:
383
+ """Raise when any named Config field or environment variable is missing."""
384
+ missing: list[str] = []
385
+ options = config_options(type(config))
386
+ for name in dict.fromkeys(fields):
387
+ option = _option_by_name(options, name)
388
+ value = getattr(config, option.field_name)
389
+ if _value_is_missing(value):
390
+ missing.append(option.env_var)
391
+ if missing:
392
+ raise ConfigError("Missing required Config value(s): " + ", ".join(missing))
393
+
394
+
395
+ def resolve_config_refs(
396
+ config: ConfigType,
397
+ *,
398
+ fields: Iterable[str] | None = None,
399
+ client: OnePasswordClient | None = None,
400
+ ) -> ConfigType:
401
+ """Resolve `op://...` string fields and return an updated Config."""
402
+ selected = set(fields) if fields is not None else None
403
+ updates: dict[str, str] = {}
404
+ for option in config_options(type(config)):
405
+ if not _option_is_selected(option, selected):
406
+ continue
407
+ value = getattr(config, option.field_name)
408
+ if not isinstance(value, str) or not value.strip().startswith("op://"):
409
+ continue
410
+ try:
411
+ updates[option.field_name] = resolve_op_secret_ref(value, client=client)
412
+ except OnePasswordError as exception:
413
+ raise ConfigError(f"Failed to resolve {option.env_var}: {exception}") from exception
414
+ if not updates:
415
+ return config
416
+ return config.model_copy(update=updates)
417
+
418
+
419
+ def config_snapshot(config: Config) -> dict[str, object]:
420
+ """Return a Config snapshot with secret values reduced to safe states."""
421
+ snapshot: dict[str, object] = {}
422
+ for option in sorted(config_options(type(config)), key=lambda option: option.env_var):
423
+ value = getattr(config, option.field_name)
424
+ snapshot[option.env_var] = _secret_state(value) if option.secret else _snapshot_value(value)
425
+ return snapshot
426
+
427
+
428
+ def _config_option_from_field(field_name: str, field_info: FieldInfo) -> ConfigOption | None:
429
+ extra = field_info.json_schema_extra
430
+ if not isinstance(extra, Mapping):
431
+ return None
432
+ option_payload = cast(Mapping[str, object], extra).get(_CONFIG_OPTION_KEY)
433
+ if not isinstance(option_payload, Mapping):
434
+ return None
435
+ option = _config_option_from_payload(cast(Mapping[str, object], option_payload))
436
+ if option is None:
437
+ return None
438
+ return replace(
439
+ option,
440
+ field_name=field_name,
441
+ cli_flag=option.cli_flag or f"--{field_name.replace('_', '-')}",
442
+ )
443
+
444
+
445
+ def _config_option_payload(option: ConfigOption) -> dict[str, object]:
446
+ return {
447
+ "env_var": option.env_var,
448
+ "cli_flag": option.cli_flag,
449
+ "cli_aliases": list(option.cli_aliases),
450
+ "cli_action": option.cli_action,
451
+ "cli_nargs": option.cli_nargs,
452
+ "cli_const": option.cli_const,
453
+ "metavar": option.metavar,
454
+ "help": option.help,
455
+ "secret": option.secret,
456
+ "required": option.required,
457
+ }
458
+
459
+
460
+ def _config_option_from_payload(payload: Mapping[str, object]) -> ConfigOption | None:
461
+ env_var = payload.get("env_var")
462
+ if not isinstance(env_var, str) or not env_var:
463
+ return None
464
+ cli_flag = payload.get("cli_flag")
465
+ cli_aliases = payload.get("cli_aliases")
466
+ cli_action = payload.get("cli_action")
467
+ cli_nargs = payload.get("cli_nargs")
468
+ metavar = payload.get("metavar")
469
+ help_text = payload.get("help")
470
+ return ConfigOption(
471
+ field_name="",
472
+ env_var=env_var,
473
+ cli_flag=cli_flag if isinstance(cli_flag, str) else "",
474
+ cli_aliases=_string_tuple(cli_aliases),
475
+ cli_action=_cli_action(cli_action),
476
+ cli_nargs=cli_nargs if isinstance(cli_nargs, str | int) else None,
477
+ cli_const=payload.get("cli_const"),
478
+ metavar=metavar if isinstance(metavar, str) else None,
479
+ help=help_text if isinstance(help_text, str) else "",
480
+ secret=payload.get("secret") is True,
481
+ required=payload.get("required") is True,
482
+ )
483
+
484
+
485
+ def _string_tuple(value: object) -> tuple[str, ...]:
486
+ if not isinstance(value, Sequence) or isinstance(value, str | bytes):
487
+ return ()
488
+ return tuple(item for item in cast(Sequence[object], value) if isinstance(item, str))
489
+
490
+
491
+ def _cli_action(value: object) -> Literal["auto", "store_true", "store_false"]:
492
+ if value in {"store_true", "store_false"}:
493
+ return cast(Literal["store_true", "store_false"], value)
494
+ return "auto"
495
+
496
+
497
+ def _selected_raw_value(
498
+ option: ConfigOption,
499
+ env_file_values: Mapping[str, str],
500
+ shell_values: Mapping[str, str],
501
+ override_values: Mapping[str, object],
502
+ ) -> object:
503
+ raw: object = _MISSING
504
+ if option.env_var in env_file_values:
505
+ raw = env_file_values[option.env_var]
506
+ if option.env_var in shell_values:
507
+ raw = shell_values[option.env_var]
508
+ if option.env_var in override_values:
509
+ raw = override_values[option.env_var]
510
+ if option.field_name in override_values:
511
+ raw = override_values[option.field_name]
512
+ return raw
513
+
514
+
515
+ def _resolve_source_ref(
516
+ option: ConfigOption, raw: object, *, client: OnePasswordClient | None
517
+ ) -> object:
518
+ if not isinstance(raw, str) or not raw.strip().startswith("op://"):
519
+ return raw
520
+ try:
521
+ return resolve_op_secret_ref(raw, client=client)
522
+ except OnePasswordError as exception:
523
+ raise ConfigError(f"Failed to resolve {option.env_var}: {exception}") from exception
524
+
525
+
526
+ def _prepare_source_value(raw: object, annotation: object, base_dir: Path) -> object:
527
+ if not isinstance(raw, str):
528
+ return raw
529
+ if _is_collection_annotation(annotation):
530
+ return tuple(part.strip() for part in raw.split(",") if part.strip())
531
+ if _is_path_annotation(annotation):
532
+ return _path_for_source(Path(raw), base_dir)
533
+ return raw
534
+
535
+
536
+ def _path_for_source(path: Path | str, base_dir: Path) -> Path:
537
+ expanded = Path(path).expanduser()
538
+ return expanded if expanded.is_absolute() else base_dir / expanded
539
+
540
+
541
+ def _without_none(annotation: object) -> object:
542
+ origin = get_origin(annotation)
543
+ if origin not in (Union, UnionType):
544
+ return annotation
545
+ args = tuple(arg for arg in get_args(annotation) if arg is not type(None))
546
+ return args[0] if len(args) == 1 else annotation
547
+
548
+
549
+ def _is_bool_annotation(annotation: object) -> bool:
550
+ return _without_none(annotation) is bool
551
+
552
+
553
+ def _is_collection_annotation(annotation: object) -> bool:
554
+ target = _without_none(annotation)
555
+ return get_origin(target) in (list, tuple, set, frozenset)
556
+
557
+
558
+ def _is_path_annotation(annotation: object) -> bool:
559
+ target = _without_none(annotation)
560
+ try:
561
+ return isinstance(target, type) and issubclass(target, Path)
562
+ except TypeError:
563
+ return False
564
+
565
+
566
+ def _option_by_name(options: Iterable[ConfigOption], name: str) -> ConfigOption:
567
+ for option in options:
568
+ if name in {option.field_name, option.env_var}:
569
+ return option
570
+ raise ConfigError(f"Unknown Config field or environment variable: {name}")
571
+
572
+
573
+ def _option_is_selected(option: ConfigOption, selected: set[str] | None) -> bool:
574
+ return selected is None or option.field_name in selected or option.env_var in selected
575
+
576
+
577
+ def _value_is_missing(value: object) -> bool:
578
+ if value is None:
579
+ return True
580
+ if isinstance(value, str):
581
+ return not value.strip()
582
+ if isinstance(value, list | tuple | set | frozenset | dict):
583
+ return not value
584
+ return False
585
+
586
+
587
+ def _secret_state(value: object) -> str:
588
+ if _value_is_missing(value):
589
+ return "missing"
590
+ if isinstance(value, str) and value.strip().startswith("op://"):
591
+ return "reference"
592
+ return "provided"
593
+
594
+
595
+ def _snapshot_value(value: object) -> object:
596
+ if isinstance(value, Path):
597
+ return str(value)
598
+ if isinstance(value, list | tuple | set | frozenset):
599
+ items = cast(Iterable[object], value)
600
+ return [str(item) for item in items]
601
+ if isinstance(value, str | int | float | bool) or value is None:
602
+ return value
603
+ return str(value)