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.
@@ -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