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.
- src_py_lib/__init__.py +170 -0
- src_py_lib/clients/__init__.py +3 -0
- src_py_lib/clients/github.py +157 -0
- src_py_lib/clients/google_sheets.py +131 -0
- src_py_lib/clients/graphql.py +476 -0
- src_py_lib/clients/linear.py +101 -0
- src_py_lib/clients/one_password.py +95 -0
- src_py_lib/clients/slack.py +146 -0
- src_py_lib/clients/sourcegraph.py +127 -0
- src_py_lib/py.typed +0 -0
- src_py_lib/utils/__init__.py +3 -0
- src_py_lib/utils/config.py +603 -0
- src_py_lib/utils/http.py +279 -0
- src_py_lib/utils/json_cache.py +42 -0
- src_py_lib/utils/json_types.py +54 -0
- src_py_lib/utils/logging.py +950 -0
- src_py_lib/utils/tsv.py +95 -0
- src_py_lib-0.1.0.dist-info/METADATA +163 -0
- src_py_lib-0.1.0.dist-info/RECORD +21 -0
- src_py_lib-0.1.0.dist-info/WHEEL +4 -0
- src_py_lib-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|