limitsdb 0.5.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.
- limitsdb/__init__.py +0 -0
- limitsdb/cli/__init__.py +0 -0
- limitsdb/cli/ldb_crypt.py +44 -0
- limitsdb/cli/ldb_impl.py +207 -0
- limitsdb/cli/ldb_init.py +60 -0
- limitsdb/cli/ldb_run.py +53 -0
- limitsdb/core/__init__.py +0 -0
- limitsdb/core/ldb_config_loader.py +198 -0
- limitsdb/core/ldb_crypto.py +91 -0
- limitsdb/core/ldb_errors.py +30 -0
- limitsdb/core/ldb_ilm_config.py +228 -0
- limitsdb/core/ldb_logger.py +57 -0
- limitsdb/core/ldb_meta.py +31 -0
- limitsdb/core/ldb_params_config.py +459 -0
- limitsdb/core/ldb_runner.py +810 -0
- limitsdb/core/ldb_status.py +8 -0
- limitsdb/core/ldb_utils.py +332 -0
- limitsdb/db/__init__.py +0 -0
- limitsdb/db/ldb_engine_loader.py +16 -0
- limitsdb/db/ldb_engines.py +347 -0
- limitsdb/db/oracle/__init__.py +0 -0
- limitsdb/db/oracle/ldb_conf.sql +151 -0
- limitsdb/db/oracle/ldb_engine_impl.py +1505 -0
- limitsdb/resources/__init__.py +0 -0
- limitsdb/resources/ilm.example.yml +71 -0
- limitsdb-0.5.0.dist-info/METADATA +586 -0
- limitsdb-0.5.0.dist-info/RECORD +32 -0
- limitsdb-0.5.0.dist-info/WHEEL +4 -0
- limitsdb-0.5.0.dist-info/entry_points.txt +6 -0
- limitsdb-0.5.0.dist-info/licenses/LICENSE +118 -0
- limitsdb-0.5.0.dist-info/licenses/LICENSE-DUAL.md +59 -0
- limitsdb-0.5.0.dist-info/licenses/NOTICE +12 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
# limitsdb/core/ldb_params_config.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from dataclasses import asdict, dataclass, fields
|
|
7
|
+
from typing import (
|
|
8
|
+
Annotated,
|
|
9
|
+
Any,
|
|
10
|
+
Literal,
|
|
11
|
+
cast,
|
|
12
|
+
get_args,
|
|
13
|
+
get_origin,
|
|
14
|
+
get_type_hints,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from limitsdb.core.ldb_config_loader import load_runtime_config, read_env_overrides
|
|
18
|
+
from limitsdb.core.ldb_errors import ValidationError
|
|
19
|
+
from limitsdb.core.ldb_logger import get_logger
|
|
20
|
+
from limitsdb.core.ldb_meta import Cli, CliOnly, Env, Help, Secret
|
|
21
|
+
from limitsdb.core.ldb_utils import resolve_schema_file # markers for Annotated metadata
|
|
22
|
+
|
|
23
|
+
logger = get_logger("params_config")
|
|
24
|
+
|
|
25
|
+
VALID_MODES: tuple[str, ...] = ("VALIDATE", "PLAN", "PREVIEW", "SCRIPT", "EXECUTE")
|
|
26
|
+
MODE_ALIASES: dict[str, str] = {"DRY_RUN": "PREVIEW"}
|
|
27
|
+
MODES_REQUIRING_CONNECTIONS: set[str] = {"VALIDATE", "PREVIEW", "SCRIPT", "EXECUTE"}
|
|
28
|
+
FILE_ONLY_CONFIG_KEYS: frozenset[str] = frozenset({"use_added_columns", "add_ldb_columns"})
|
|
29
|
+
|
|
30
|
+
IlmAction = Literal["SOURCE_ILM", "HISTORY_ILM"]
|
|
31
|
+
RuntimeMode = Literal["VALIDATE", "PLAN", "PREVIEW", "SCRIPT", "EXECUTE"]
|
|
32
|
+
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
33
|
+
DatabaseEngineName = Literal["oracle"]
|
|
34
|
+
SUPPORTED_DATABASE_ENGINES: tuple[DatabaseEngineName, ...] = ("oracle",)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _normalize_mode(value: str) -> str:
|
|
38
|
+
normalized = (value or "").upper()
|
|
39
|
+
return MODE_ALIASES.get(normalized, normalized)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ExecutionConfig:
|
|
44
|
+
"""Typed, immutable execution settings derived from the public flat config."""
|
|
45
|
+
|
|
46
|
+
action: IlmAction
|
|
47
|
+
mode: RuntimeMode
|
|
48
|
+
chunk_size: int
|
|
49
|
+
use_added_columns: bool
|
|
50
|
+
add_ldb_columns: bool
|
|
51
|
+
generate_script: bool
|
|
52
|
+
parallel_max: int
|
|
53
|
+
log_level: LogLevel
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class DatabaseEndpoint:
|
|
58
|
+
"""Application connection values for one database environment."""
|
|
59
|
+
|
|
60
|
+
dsn: str | None
|
|
61
|
+
username: str | None
|
|
62
|
+
password: str | None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class ConnectionConfig:
|
|
67
|
+
"""Typed source/history connection settings."""
|
|
68
|
+
|
|
69
|
+
db_engine: DatabaseEngineName
|
|
70
|
+
source: DatabaseEndpoint
|
|
71
|
+
history: DatabaseEndpoint
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class AdministrativeCredentials:
|
|
76
|
+
"""Administrative credentials for one database environment."""
|
|
77
|
+
|
|
78
|
+
username: str | None
|
|
79
|
+
password: str | None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class AdministrationConfig:
|
|
84
|
+
"""Typed settings used to provision users, roles, and database links."""
|
|
85
|
+
|
|
86
|
+
source: AdministrativeCredentials
|
|
87
|
+
history: AdministrativeCredentials
|
|
88
|
+
source_default_tablespace: str | None
|
|
89
|
+
history_default_tablespace: str | None
|
|
90
|
+
source_to_history_dblink_name: str
|
|
91
|
+
history_to_source_dblink_name: str
|
|
92
|
+
source_role_name: str
|
|
93
|
+
history_role_name: str
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class RuntimeContext:
|
|
98
|
+
"""Identity and optional file override for one invocation."""
|
|
99
|
+
|
|
100
|
+
schema: str
|
|
101
|
+
profile: str | None
|
|
102
|
+
ilm_config_file: str | None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------------------
|
|
106
|
+
# Single source of truth: Config + Annotated metadata
|
|
107
|
+
# ------------------------------------------------------------------------------
|
|
108
|
+
@dataclass
|
|
109
|
+
class Config:
|
|
110
|
+
# Execution mode parameters
|
|
111
|
+
action: Annotated[
|
|
112
|
+
Literal["SOURCE_ILM", "HISTORY_ILM"],
|
|
113
|
+
Help("ILM target: months_keep_history_max (SOURCE_ILM) or history (HISTORY_ILM)"),
|
|
114
|
+
Cli("--action"),
|
|
115
|
+
Env("LDB_ACTION"),
|
|
116
|
+
] = "SOURCE_ILM"
|
|
117
|
+
mode: Annotated[
|
|
118
|
+
Literal["VALIDATE", "PLAN", "PREVIEW", "SCRIPT", "EXECUTE", "DRY_RUN"],
|
|
119
|
+
Help(
|
|
120
|
+
"Runtime mode: VALIDATE config/credentials, PLAN dependency order, PREVIEW simulate without changes, SCRIPT generate SQL scripts, EXECUTE apply changes"
|
|
121
|
+
),
|
|
122
|
+
Cli("--mode"),
|
|
123
|
+
Env("LDB_MODE"),
|
|
124
|
+
] = "PREVIEW"
|
|
125
|
+
chunk_size: Annotated[
|
|
126
|
+
int, Help("Rows per chunk when processing large tables"), Cli("--chunk-size"), Env("LDB_CHUNK_SIZE")
|
|
127
|
+
] = 100000
|
|
128
|
+
use_added_columns: Annotated[bool, Help("Populate derived columns in history tables")] = True
|
|
129
|
+
add_ldb_columns: Annotated[bool, Help("Add LimitsDb execution-date columns in history tables")] = True
|
|
130
|
+
generate_script: Annotated[bool, Help("Generate SQL script without executing (auto-enabled in SCRIPT mode)")] = (
|
|
131
|
+
False
|
|
132
|
+
)
|
|
133
|
+
parallel_max: Annotated[
|
|
134
|
+
int, Help("Maximum number of parallel processes"), Cli("--parallel-max"), Env("LDB_PARALLEL_MAX")
|
|
135
|
+
] = 10
|
|
136
|
+
db_engine: Annotated[
|
|
137
|
+
DatabaseEngineName,
|
|
138
|
+
Help("Database engine (Oracle only)"),
|
|
139
|
+
Cli("--db-engine"),
|
|
140
|
+
Env("LDB_DB_ENGINE"),
|
|
141
|
+
] = "oracle"
|
|
142
|
+
log_level: Annotated[
|
|
143
|
+
Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
|
144
|
+
Help("Logging level"),
|
|
145
|
+
Cli("--log-level"),
|
|
146
|
+
Env("LDB_LOG_LEVEL"),
|
|
147
|
+
] = "INFO"
|
|
148
|
+
# schema/profile + optional tables override file
|
|
149
|
+
schema: Annotated[
|
|
150
|
+
str, Help("Schema name (folder under schemas/)"), Cli("--schema"), Env("LDB_SCHEMA"), CliOnly()
|
|
151
|
+
] = ""
|
|
152
|
+
profile: Annotated[
|
|
153
|
+
str | None, Help("Profile name (e.g., dev, prod)"), Cli("--profile"), Env("LDB_PROFILE"), CliOnly()
|
|
154
|
+
] = None
|
|
155
|
+
ilm_config_file: Annotated[
|
|
156
|
+
str | None,
|
|
157
|
+
Help("YAML file with tables (bypass DB discovery)"),
|
|
158
|
+
Cli("--ilm-config-file"),
|
|
159
|
+
Env("LDB_ILM_CONFIG_FILE"),
|
|
160
|
+
CliOnly(),
|
|
161
|
+
] = None
|
|
162
|
+
# Database connection parameters
|
|
163
|
+
source_dsn: Annotated[
|
|
164
|
+
str | None,
|
|
165
|
+
Help("Oracle DSN / connection descriptor: host:port/service (EZCONNECT) or TNS alias (e.g., ORCL)."),
|
|
166
|
+
Env("LDB_SOURCE_DSN"),
|
|
167
|
+
] = ""
|
|
168
|
+
source_username: Annotated[str | None, Help("Username"), Env("LDB_SOURCE_USERNAME")] = ""
|
|
169
|
+
source_password: Annotated[str | None, Help("Password"), Secret()] = ""
|
|
170
|
+
history_dsn: Annotated[
|
|
171
|
+
str | None,
|
|
172
|
+
Help("Oracle DSN / connection descriptor: host:port/service (EZCONNECT) or TNS alias (e.g., ORCL)."),
|
|
173
|
+
Env("LDB_HISTORY_DSN"),
|
|
174
|
+
] = ""
|
|
175
|
+
history_username: Annotated[str | None, Help("Username"), Env("LDB_HISTORY_USERNAME")] = ""
|
|
176
|
+
history_password: Annotated[str | None, Help("Password"), Secret()] = ""
|
|
177
|
+
admin_source_username: Annotated[str | None, Help("Admin username"), Env("LDB_ADMIN_SOURCE_USERNAME")] = ""
|
|
178
|
+
admin_source_password: Annotated[str | None, Help("Admin Source password"), Secret()] = ""
|
|
179
|
+
admin_history_username: Annotated[str | None, Help("Admin History username"), Env("LDB_ADMIN_HISTORY_USERNAME")] = (
|
|
180
|
+
""
|
|
181
|
+
)
|
|
182
|
+
admin_history_password: Annotated[str | None, Help("Admin History password"), Secret()] = ""
|
|
183
|
+
source_default_tablespace: Annotated[str | None, Help("Default tablespace for the source user")] = None
|
|
184
|
+
history_default_tablespace: Annotated[str | None, Help("Default tablespace for the history user")] = None
|
|
185
|
+
source_to_history_dblink_name: Annotated[
|
|
186
|
+
str, Help("Database link name in source environment that connects to history")
|
|
187
|
+
] = "HIST"
|
|
188
|
+
history_to_source_dblink_name: Annotated[
|
|
189
|
+
str, Help("Database link name in history environment that connects to source")
|
|
190
|
+
] = "SRC"
|
|
191
|
+
source_role_name: Annotated[str, Help("Role name to create in the source environment")] = "LDB_SOURCE_ROLE"
|
|
192
|
+
history_role_name: Annotated[str, Help("Role name to create in the history environment")] = "LDB_HISTORY_ROLE"
|
|
193
|
+
|
|
194
|
+
def __post_init__(self) -> None:
|
|
195
|
+
normalized_mode = _normalize_mode(self.mode)
|
|
196
|
+
if normalized_mode not in VALID_MODES:
|
|
197
|
+
raise ValidationError(f"invalid mode: {self.mode}")
|
|
198
|
+
self.mode = normalized_mode # type: ignore[assignment]
|
|
199
|
+
if self.mode == "SCRIPT":
|
|
200
|
+
self.generate_script = True
|
|
201
|
+
requires_connections = self.mode in MODES_REQUIRING_CONNECTIONS
|
|
202
|
+
if not self.schema:
|
|
203
|
+
raise ValidationError("schema is required")
|
|
204
|
+
if self.db_engine not in SUPPORTED_DATABASE_ENGINES:
|
|
205
|
+
raise ValidationError(
|
|
206
|
+
f"unsupported db_engine: {self.db_engine}; supported engines: {', '.join(SUPPORTED_DATABASE_ENGINES)}"
|
|
207
|
+
)
|
|
208
|
+
if (
|
|
209
|
+
requires_connections
|
|
210
|
+
and self.action == "SOURCE_ILM"
|
|
211
|
+
and (not self.source_dsn or not self.source_username or not self.source_password)
|
|
212
|
+
):
|
|
213
|
+
raise ValidationError("source_dsn, source_username and source_password are required for SOURCE_ILM action")
|
|
214
|
+
if (
|
|
215
|
+
requires_connections
|
|
216
|
+
and self.action == "HISTORY_ILM"
|
|
217
|
+
and (not self.history_dsn or not self.history_username or not self.history_password)
|
|
218
|
+
):
|
|
219
|
+
raise ValidationError(
|
|
220
|
+
"history_dsn, history_username and history_password are required for HISTORY_ILM action"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
@classmethod
|
|
224
|
+
def from_dict(cls, d: dict[str, Any]) -> Config:
|
|
225
|
+
# Strict field validation
|
|
226
|
+
known = {field.name for field in fields(cls)}
|
|
227
|
+
unknown = set(d) - known
|
|
228
|
+
if unknown:
|
|
229
|
+
raise ValidationError(f"Unknown configuration keys: {sorted(unknown)}")
|
|
230
|
+
return cls(**d)
|
|
231
|
+
|
|
232
|
+
def to_dict(self) -> dict[str, Any]:
|
|
233
|
+
return asdict(self)
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def execution(self) -> ExecutionConfig:
|
|
237
|
+
"""Return the typed execution view without changing the public flat model."""
|
|
238
|
+
return ExecutionConfig(
|
|
239
|
+
action=self.action,
|
|
240
|
+
mode=cast(RuntimeMode, self.mode),
|
|
241
|
+
chunk_size=self.chunk_size,
|
|
242
|
+
use_added_columns=self.use_added_columns,
|
|
243
|
+
add_ldb_columns=self.add_ldb_columns,
|
|
244
|
+
generate_script=self.generate_script,
|
|
245
|
+
parallel_max=self.parallel_max,
|
|
246
|
+
log_level=self.log_level,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def connections(self) -> ConnectionConfig:
|
|
251
|
+
"""Return typed source/history endpoints without copying public keys."""
|
|
252
|
+
return ConnectionConfig(
|
|
253
|
+
db_engine=self.db_engine,
|
|
254
|
+
source=DatabaseEndpoint(
|
|
255
|
+
dsn=self.source_dsn,
|
|
256
|
+
username=self.source_username,
|
|
257
|
+
password=self.source_password,
|
|
258
|
+
),
|
|
259
|
+
history=DatabaseEndpoint(
|
|
260
|
+
dsn=self.history_dsn,
|
|
261
|
+
username=self.history_username,
|
|
262
|
+
password=self.history_password,
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def administration(self) -> AdministrationConfig:
|
|
268
|
+
"""Return typed administrative settings without changing public keys."""
|
|
269
|
+
return AdministrationConfig(
|
|
270
|
+
source=AdministrativeCredentials(
|
|
271
|
+
username=self.admin_source_username,
|
|
272
|
+
password=self.admin_source_password,
|
|
273
|
+
),
|
|
274
|
+
history=AdministrativeCredentials(
|
|
275
|
+
username=self.admin_history_username,
|
|
276
|
+
password=self.admin_history_password,
|
|
277
|
+
),
|
|
278
|
+
source_default_tablespace=self.source_default_tablespace,
|
|
279
|
+
history_default_tablespace=self.history_default_tablespace,
|
|
280
|
+
source_to_history_dblink_name=self.source_to_history_dblink_name,
|
|
281
|
+
history_to_source_dblink_name=self.history_to_source_dblink_name,
|
|
282
|
+
source_role_name=self.source_role_name,
|
|
283
|
+
history_role_name=self.history_role_name,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def context(self) -> RuntimeContext:
|
|
288
|
+
"""Return the typed invocation context without changing public keys."""
|
|
289
|
+
return RuntimeContext(schema=self.schema, profile=self.profile, ilm_config_file=self.ilm_config_file)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ------------------------------------------------------------------------------
|
|
293
|
+
# CLI builder from Config metadata (Annotated)
|
|
294
|
+
# ------------------------------------------------------------------------------
|
|
295
|
+
def _arg_type_from_default(default: Any) -> type[bool] | type[int] | type[float] | type[str]:
|
|
296
|
+
if isinstance(default, bool):
|
|
297
|
+
return bool
|
|
298
|
+
if isinstance(default, int):
|
|
299
|
+
return int
|
|
300
|
+
if isinstance(default, float):
|
|
301
|
+
return float
|
|
302
|
+
return str
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def env_bindings_from_config() -> dict[str, str]:
|
|
306
|
+
"""Return exact environment-name to Config-field bindings from Env metadata."""
|
|
307
|
+
bindings: dict[str, str] = {}
|
|
308
|
+
hints = get_type_hints(Config, include_extras=True)
|
|
309
|
+
for field_name, annotated in hints.items():
|
|
310
|
+
env = next((meta for meta in get_args(annotated)[1:] if isinstance(meta, Env)), None)
|
|
311
|
+
if env is None:
|
|
312
|
+
continue
|
|
313
|
+
if env.name in bindings:
|
|
314
|
+
raise RuntimeError(f"Duplicate environment binding: {env.name}")
|
|
315
|
+
bindings[env.name] = field_name
|
|
316
|
+
return bindings
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def build_argparser_from_config() -> argparse.ArgumentParser:
|
|
320
|
+
"""
|
|
321
|
+
Build an argparse.ArgumentParser from Config's Annotated metadata.
|
|
322
|
+
Also adds builder-only flags: --config-dir, --config-file, and --set.
|
|
323
|
+
CLI defaults are suppressed so they don't override values from YAML or ENV.
|
|
324
|
+
"""
|
|
325
|
+
parser = argparse.ArgumentParser(description="LimitsDb CLI", formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
|
326
|
+
env_values = read_env_overrides(env_bindings_from_config())
|
|
327
|
+
hints = get_type_hints(Config, include_extras=True)
|
|
328
|
+
for name, annotated in hints.items():
|
|
329
|
+
metas = get_args(annotated)
|
|
330
|
+
cli = next((m.flag for m in metas if isinstance(m, Cli)), None)
|
|
331
|
+
is_secret = any(getattr(m, "enabled", False) for m in metas if isinstance(m, Secret))
|
|
332
|
+
if not cli or is_secret:
|
|
333
|
+
continue # Only fields explicitly marked with Cli(...) become CLI flags; Secret fields are not exposed via CLI flags
|
|
334
|
+
help_text = next((m.text for m in metas if isinstance(m, Help)), None)
|
|
335
|
+
default = getattr(Config, name)
|
|
336
|
+
# Suppress defaults so argparse only sets values explicitly provided in CLI
|
|
337
|
+
arg_kwargs: dict[str, Any] = {"help": help_text or "", "default": argparse.SUPPRESS}
|
|
338
|
+
# infer choices from Literal if present
|
|
339
|
+
parameter_type = metas[0]
|
|
340
|
+
origin = get_origin(parameter_type)
|
|
341
|
+
if origin is Literal:
|
|
342
|
+
arg_kwargs["choices"] = tuple(get_args(parameter_type))
|
|
343
|
+
# Schema can be supplied by its explicitly registered environment variable.
|
|
344
|
+
if name == "schema" and "schema" not in env_values:
|
|
345
|
+
arg_kwargs["required"] = True
|
|
346
|
+
# Booleans: use BooleanOptionalAction for --flag / --no-flag
|
|
347
|
+
if isinstance(default, bool):
|
|
348
|
+
parser.add_argument(cli, action=argparse.BooleanOptionalAction, **arg_kwargs)
|
|
349
|
+
else:
|
|
350
|
+
parser.add_argument(cli, type=_arg_type_from_default(default), **arg_kwargs)
|
|
351
|
+
# Builder-only flags (not part of Config dataclass)
|
|
352
|
+
parser.add_argument(
|
|
353
|
+
"--config-dir", help="Configuration root (overrides autodiscovery of ~/.config/LimitsDb and /etc/limitsdb)"
|
|
354
|
+
)
|
|
355
|
+
parser.add_argument("--config-file", help="Additional YAML overlay (highest priority)")
|
|
356
|
+
parser.add_argument(
|
|
357
|
+
"--set", action="append", default=[], help="Overrides like key=value; supports dotted keys for nesting"
|
|
358
|
+
)
|
|
359
|
+
return parser
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def parse_args() -> argparse.Namespace:
|
|
363
|
+
return build_argparser_from_config().parse_args()
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ------------------------------------------------------------------------------
|
|
367
|
+
# CLI --set parser (k=v, dotted keys)
|
|
368
|
+
# ------------------------------------------------------------------------------
|
|
369
|
+
def _parse_cli_sets(pairs: Sequence[str]) -> dict[str, Any]:
|
|
370
|
+
"""
|
|
371
|
+
Parse a list of key=value strings into a dict, with simple auto-typing."""
|
|
372
|
+
out: dict[str, Any] = {}
|
|
373
|
+
for p in pairs or []:
|
|
374
|
+
if "=" not in p:
|
|
375
|
+
continue
|
|
376
|
+
k, v = p.split("=", 1)
|
|
377
|
+
key = k.strip()
|
|
378
|
+
if key.split(".", 1)[0] in FILE_ONLY_CONFIG_KEYS:
|
|
379
|
+
raise ValidationError(f"'{key}' can only be set in the persistent YAML configuration")
|
|
380
|
+
v = v.strip()
|
|
381
|
+
# simple auto-typing
|
|
382
|
+
if v.isdigit():
|
|
383
|
+
v_typed: Any = int(v)
|
|
384
|
+
elif v.lower() in ("true", "false"):
|
|
385
|
+
v_typed = v.lower() == "true"
|
|
386
|
+
else:
|
|
387
|
+
# try float
|
|
388
|
+
try:
|
|
389
|
+
v_typed = float(v)
|
|
390
|
+
except ValueError:
|
|
391
|
+
v_typed = v
|
|
392
|
+
out[key] = v_typed
|
|
393
|
+
return out
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# ------------------------------------------------------------------------------
|
|
397
|
+
# Build final config dict using loader + CLI overrides
|
|
398
|
+
# ------------------------------------------------------------------------------
|
|
399
|
+
def build_config(defaults: Mapping[str, Any], cli_args: argparse.Namespace) -> dict[str, Any]:
|
|
400
|
+
"""
|
|
401
|
+
Produce the final configuration dictionary using the new loader (overlays + secrets).
|
|
402
|
+
Environment and CLI overrides are handled inside the loader (ENV LDB_*), and here we map
|
|
403
|
+
explicit flags to --set so they win with the highest priority.
|
|
404
|
+
"""
|
|
405
|
+
env_bindings = env_bindings_from_config()
|
|
406
|
+
env_values = read_env_overrides(env_bindings)
|
|
407
|
+
schema = getattr(cli_args, "schema", None) or env_values.get("schema")
|
|
408
|
+
if not schema:
|
|
409
|
+
raise ValidationError("schema is required through --schema or LDB_SCHEMA")
|
|
410
|
+
profile = getattr(cli_args, "profile", None)
|
|
411
|
+
if profile is None:
|
|
412
|
+
profile = env_values.get("profile")
|
|
413
|
+
|
|
414
|
+
# Map CLI flags (derived from Config metadata) into --set key=value overrides
|
|
415
|
+
cli_sets: dict[str, Any] = _parse_cli_sets(getattr(cli_args, "set", []))
|
|
416
|
+
# Pull values for every CLI-exposed field from args and push into cli_sets if not None
|
|
417
|
+
hints = get_type_hints(Config, include_extras=True)
|
|
418
|
+
for name, annotated in hints.items():
|
|
419
|
+
metas = get_args(annotated)
|
|
420
|
+
cli = next((m.flag for m in metas if isinstance(m, Cli)), None)
|
|
421
|
+
if not cli:
|
|
422
|
+
continue
|
|
423
|
+
# skip secrets even if they had Cli (we didn't add them)
|
|
424
|
+
is_secret = any(getattr(m, "enabled", False) for m in metas if isinstance(m, Secret))
|
|
425
|
+
if is_secret:
|
|
426
|
+
continue
|
|
427
|
+
# argparse stores flags as dest = field name with dashes replaced by underscores,
|
|
428
|
+
# but since we used the field name to derive flags, we can read by attribute name.
|
|
429
|
+
if hasattr(cli_args, name):
|
|
430
|
+
val = getattr(cli_args, name)
|
|
431
|
+
# Only propagate explicit values (argparse always populates defaults; let loader/defaults handle true defaults)
|
|
432
|
+
# Here we still push values to ensure CLI has top priority.
|
|
433
|
+
if val is not None:
|
|
434
|
+
cli_sets[name] = val
|
|
435
|
+
# Call the loader with overlay sources
|
|
436
|
+
cfg = load_runtime_config(
|
|
437
|
+
schema=str(schema),
|
|
438
|
+
profile=cast(str | None, profile),
|
|
439
|
+
cli_sets=cli_sets,
|
|
440
|
+
env_bindings=env_bindings,
|
|
441
|
+
explicit_config_file=getattr(cli_args, "config_file", None),
|
|
442
|
+
explicit_config_dir=getattr(cli_args, "config_dir", None),
|
|
443
|
+
)
|
|
444
|
+
# Merge with embedded defaults (if you still keep some minimal defaults in code)
|
|
445
|
+
final = dict(defaults)
|
|
446
|
+
final.update(cfg)
|
|
447
|
+
# Ensure schema/profile land in the object (the loader also uses them but they are not part of the dict)
|
|
448
|
+
final["schema"] = schema
|
|
449
|
+
final["profile"] = profile
|
|
450
|
+
final["ilm_config_file"] = resolve_schema_file(
|
|
451
|
+
schema=str(schema),
|
|
452
|
+
profile=cast(str | None, profile),
|
|
453
|
+
explicit_config_dir=getattr(cli_args, "config_dir", None),
|
|
454
|
+
explicit_file=cast(str | None, final.get("ilm_config_file")),
|
|
455
|
+
prefix_name="ilm",
|
|
456
|
+
extension_name="yml",
|
|
457
|
+
description="ilm configuration file",
|
|
458
|
+
)
|
|
459
|
+
return final
|