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,30 @@
1
+ """Domain errors exposed by LimitsDb boundaries.
2
+
3
+ All errors retain their original exception through ``raise ... from exc`` at
4
+ the boundary where low-level parsing, cryptography, database, or worker errors
5
+ are translated.
6
+ """
7
+
8
+
9
+ class LimitsDbError(Exception):
10
+ """Base class for recoverable LimitsDb failures."""
11
+
12
+
13
+ class ConfigurationError(LimitsDbError, ValueError):
14
+ """Configuration could not be read or interpreted."""
15
+
16
+
17
+ class SecretError(ConfigurationError):
18
+ """Secret storage, key management, or decryption failed."""
19
+
20
+
21
+ class DatabaseConnectionError(LimitsDbError, ConnectionError):
22
+ """A database connection could not be established or checked."""
23
+
24
+
25
+ class ValidationError(LimitsDbError, ValueError):
26
+ """Configuration or runtime state failed domain validation."""
27
+
28
+
29
+ class ExecutionError(LimitsDbError, RuntimeError):
30
+ """An ILM or administrative operation failed."""
@@ -0,0 +1,228 @@
1
+ # limitsdb/core/ldb_ilm_config.py
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import ItemsView, Mapping
5
+ from typing import Any, NotRequired, TypedDict, cast
6
+
7
+ import yaml
8
+
9
+ from limitsdb.core.ldb_config_loader import load_ilm_config
10
+
11
+ _id_counter = 0
12
+ _loaded_tables: set[tuple[str, str]] = set()
13
+
14
+ _VALID_ROOT_KEYS: set[str] = {"tables"}
15
+
16
+ _VALID_TABLE_KEYS: set[str] = {
17
+ "source_owner",
18
+ "history_owner",
19
+ "table_name",
20
+ "retain_months_source",
21
+ "retain_months_history",
22
+ "exec_day",
23
+ "frecuency",
24
+ "purge_date_expr",
25
+ "additional_filter_expr",
26
+ "history_addtl_filter_expr",
27
+ "history_hint_expr",
28
+ "orphan_check_column",
29
+ "referencing_tables",
30
+ "join_expr",
31
+ "hint_expr",
32
+ "long_columns",
33
+ "source_orphan_purge",
34
+ "has_lob_columns",
35
+ }
36
+
37
+ _VALID_COND_KEYS: set[str] = _VALID_TABLE_KEYS | {"is_active"}
38
+
39
+
40
+ class IlmRule(TypedDict):
41
+ """Normalized engine-neutral ILM rule consumed by the runner."""
42
+
43
+ id: int
44
+ source_owner: str
45
+ history_owner: str
46
+ table_name: str
47
+ retain_months_source: int | None
48
+ retain_months_history: int | None
49
+ exec_day: int | None
50
+ frecuency: str | None
51
+ is_active: str
52
+ purge_date_expr: str | None
53
+ additional_filter_expr: str | None
54
+ history_addtl_filter_expr: str | None
55
+ history_hint_expr: str | None
56
+ source_orphan_purge: str
57
+ orphan_check_column: str | None
58
+ has_lob_columns: str
59
+ referencing_tables: str | None
60
+ join_expr: str | None
61
+ hint_expr: str | None
62
+ long_columns: str | None
63
+ ctl_status: str | None
64
+ cond_expr: NotRequired[str]
65
+
66
+
67
+ def _assert_valid_keys(keys: set[str], *, where: str, allowed: set[str]) -> None:
68
+ """Raise if any key in `keys` is not present in `allowed`."""
69
+ invalid = sorted(k for k in keys if k not in allowed)
70
+ if invalid:
71
+ allowed_sorted = ", ".join(sorted(allowed))
72
+ invalid_sorted = ", ".join(invalid)
73
+ raise KeyError(f"Invalid key(s) in ILM config at {where}: {invalid_sorted}. Expected only: {allowed_sorted}")
74
+
75
+
76
+ def _normalize_bool(val: Any) -> Any:
77
+ return "Y" if val else "N" if val is not None else None
78
+
79
+
80
+ def normalize_table_row(table: dict[str, Any], cond: dict[str, Any]) -> IlmRule:
81
+ global _id_counter
82
+ row: dict[str, Any] = {}
83
+ # Merge the table definition with the condition (excluding nested "conds").
84
+ for key, value in list(table.items()) + list(cond.items()):
85
+ if key == "conds":
86
+ continue
87
+ row[key] = _normalize_bool(value) if isinstance(value, bool) else value
88
+ row["ctl_status"] = None
89
+ # Ensure default flags are present when omitted.
90
+ for key in ["is_active", "source_orphan_purge", "has_lob_columns"]:
91
+ if key not in row:
92
+ row[key] = "N" if key != "is_active" else "Y"
93
+ for key in [
94
+ "source_owner",
95
+ "history_owner",
96
+ "table_name",
97
+ "retain_months_source",
98
+ "retain_months_history",
99
+ "exec_day",
100
+ "frecuency",
101
+ "purge_date_expr",
102
+ "additional_filter_expr",
103
+ "history_addtl_filter_expr",
104
+ "history_hint_expr",
105
+ "orphan_check_column",
106
+ "referencing_tables",
107
+ "join_expr",
108
+ "hint_expr",
109
+ "long_columns",
110
+ ]:
111
+ if key not in row:
112
+ row[key] = None
113
+ row["id"] = _id_counter
114
+ _id_counter += 1
115
+ return cast(IlmRule, row)
116
+
117
+
118
+ def _ensure_mapping(obj: Any, where: str) -> dict[str, Any]:
119
+ """Ensure obj is a mapping[str, Any] and return a plain dict[str, Any]."""
120
+ if obj is None:
121
+ return {}
122
+ if not isinstance(obj, Mapping):
123
+ raise TypeError(f"YAML root at {where} must be a mapping (dict). Got {type(obj).__name__}")
124
+ out: dict[str, Any] = {}
125
+ # Explicitly type the ItemsView iterator for mypy.
126
+ obj_items: ItemsView[Any, Any] = obj.items()
127
+ for k_any, v_any in obj_items:
128
+ key: str = str(k_any)
129
+ val: Any = v_any
130
+ out[key] = val
131
+ return out
132
+
133
+
134
+ def _ensure_list_of_mappings(obj: Any, where: str) -> list[dict[str, Any]]:
135
+ """Ensure obj is a list of mapping-like items and normalize each to dict[str, Any]."""
136
+ if obj is None:
137
+ return []
138
+ if not isinstance(obj, list):
139
+ raise TypeError(f"'{where}' must be a list. Got {type(obj).__name__}")
140
+ out: list[dict[str, Any]] = []
141
+ # Cast the heterogeneous list so we can iterate with type checking.
142
+ obj_list: list[Any] = obj
143
+ for i, el_any in enumerate(obj_list):
144
+ if not isinstance(el_any, Mapping):
145
+ raise TypeError(f"'{where}[{i}]' must be a mapping (dict). Got {type(el_any).__name__}")
146
+ el_map: Mapping[Any, Any] = el_any
147
+ d: dict[str, Any] = {}
148
+ el_items: ItemsView[Any, Any] = el_map.items()
149
+ for k_any, v_any in el_items:
150
+ key: str = str(k_any)
151
+ val: Any = v_any
152
+ d[key] = val
153
+ out.append(d)
154
+ return out
155
+
156
+
157
+ def load_rows_from_yaml(yaml_path: str) -> list[IlmRule]:
158
+ _loaded_tables: set[tuple[str, str]] = set()
159
+ with open(yaml_path, encoding="utf-8") as f:
160
+ data_any: Any = yaml.safe_load(f)
161
+ data: dict[str, Any] = _ensure_mapping(data_any, yaml_path)
162
+ _assert_valid_keys(set(data.keys()), where=f"{yaml_path} (root)", allowed=_VALID_ROOT_KEYS)
163
+ rows: list[IlmRule] = []
164
+ tables: list[dict[str, Any]] = _ensure_list_of_mappings(data.get("tables"), "tables")
165
+ for idx, table in enumerate(tables):
166
+ table_keys = set(table.keys()) - {"conds"}
167
+ _assert_valid_keys(table_keys, where=f"{yaml_path} tables[{idx}]", allowed=_VALID_TABLE_KEYS)
168
+ owner: str = str(table.get("source_owner", "") or "")
169
+ table_name: str = str(table.get("table_name", "") or "")
170
+ if (owner, table_name) in _loaded_tables:
171
+ raise ValueError(f"Duplicate table in ilm-config-file: {owner}.{table_name}")
172
+ # Normalize nested "conds" entries for the table.
173
+ conds_any = table.get("conds")
174
+ conds: list[dict[str, Any]] = _ensure_list_of_mappings(conds_any, "conds") if conds_any is not None else []
175
+ if not conds:
176
+ conds = [{"is_active": True}]
177
+ active_conds: list[dict[str, Any]] = []
178
+ for c_idx, cond in enumerate(conds):
179
+ _assert_valid_keys(
180
+ set(cond.keys()), where=f"{yaml_path} tables[{idx}].conds[{c_idx}]", allowed=_VALID_COND_KEYS
181
+ )
182
+ if bool(cond.get("is_active")) is True:
183
+ active_conds.append(cond)
184
+ for cond in active_conds:
185
+ rows.append(normalize_table_row(table, cond))
186
+ if active_conds:
187
+ _loaded_tables.add((owner, table_name))
188
+ return rows
189
+
190
+
191
+ def resolve_and_load_ilm_rows(*, schema: str, profile: str | None, config_dir: str | None = None) -> list[IlmRule]:
192
+ """Load merged ILM for schema/profile and return normalized rows."""
193
+ ilm_dict: dict[str, Any] = load_ilm_config(schema=schema, profile=profile, explicit_config_dir=config_dir)
194
+ rows: list[IlmRule] = []
195
+ global _loaded_tables, _id_counter
196
+ _loaded_tables = set()
197
+ _id_counter = 0
198
+ _assert_valid_keys(
199
+ set(ilm_dict.keys()), where=f"ILM config for schema={schema} profile={profile} (root)", allowed=_VALID_ROOT_KEYS
200
+ )
201
+ tables: list[dict[str, Any]] = _ensure_list_of_mappings(ilm_dict.get("tables"), "tables")
202
+ for idx, table in enumerate(tables):
203
+ table_keys = set(table.keys()) - {"conds"}
204
+ _assert_valid_keys(
205
+ table_keys, where=f"ILM config tables[{idx}] (schema={schema} profile={profile})", allowed=_VALID_TABLE_KEYS
206
+ )
207
+ owner: str = str(table.get("source_owner", "") or "")
208
+ table_name: str = str(table.get("table_name", "") or "")
209
+ if (owner, table_name) in _loaded_tables:
210
+ raise ValueError(f"Duplicate table in ILM config: {owner}.{table_name}")
211
+ conds_any = table.get("conds")
212
+ conds: list[dict[str, Any]] = _ensure_list_of_mappings(conds_any, "conds") if conds_any is not None else []
213
+ if not conds:
214
+ conds = [{"is_active": True}]
215
+ active_conds: list[dict[str, Any]] = []
216
+ for c_idx, cond in enumerate(conds):
217
+ _assert_valid_keys(
218
+ set(cond.keys()),
219
+ where=f"ILM config tables[{idx}].conds[{c_idx}] (schema={schema} profile={profile})",
220
+ allowed=_VALID_COND_KEYS,
221
+ )
222
+ if bool(cond.get("is_active")) is True:
223
+ active_conds.append(cond)
224
+ for cond in active_conds:
225
+ rows.append(normalize_table_row(table, cond))
226
+ if active_conds:
227
+ _loaded_tables.add((owner, table_name))
228
+ return rows
@@ -0,0 +1,57 @@
1
+ # ldb_logger.py
2
+ import logging
3
+ import threading
4
+
5
+ _base_name = "ldb"
6
+ _configured = False
7
+ _lock = threading.RLock()
8
+
9
+ # Un único formato legible (sin JSON, sin ambientes)
10
+ _FORMAT = logging.Formatter(
11
+ fmt="%(asctime)s [%(levelname)s] %(processName)s-%(name)s: %(message)s",
12
+ datefmt="%Y-%m-%d %H:%M:%S",
13
+ )
14
+
15
+
16
+ def configure_logger(level: str = "INFO", base_name: str = _base_name) -> logging.Logger:
17
+ """Configura el logger base una sola vez (handlers/format)."""
18
+ global _configured, _base_name
19
+ with _lock:
20
+ _base_name = base_name
21
+ base = logging.getLogger(_base_name)
22
+ lvl = getattr(logging, level.upper(), logging.INFO)
23
+
24
+ if not _configured:
25
+ base.setLevel(lvl)
26
+ base.propagate = False # evita duplicar en root
27
+ ch = logging.StreamHandler()
28
+ ch.setLevel(lvl)
29
+ ch.setFormatter(_FORMAT)
30
+ base.addHandler(ch)
31
+ _configured = True
32
+ else:
33
+ base.setLevel(lvl)
34
+ for h in base.handlers:
35
+ h.setLevel(lvl)
36
+ h.setFormatter(_FORMAT)
37
+ return base
38
+
39
+
40
+ def get_logger(name: str = _base_name) -> logging.Logger:
41
+ """Devuelve el logger base o un hijo ('limitsdb.<name>')."""
42
+ if not _configured:
43
+ configure_logger()
44
+ if name == _base_name or name == "":
45
+ return logging.getLogger(_base_name)
46
+ return logging.getLogger(f"{_base_name}.{name}")
47
+
48
+
49
+ def reconfigure_logger(level: str = "INFO") -> None:
50
+ """Ajusta el nivel del logger base y sus handlers."""
51
+ if not _configured:
52
+ return
53
+ lvl = getattr(logging, level.upper(), logging.INFO)
54
+ base = logging.getLogger(_base_name)
55
+ base.setLevel(lvl)
56
+ for h in base.handlers:
57
+ h.setLevel(lvl)
@@ -0,0 +1,31 @@
1
+ # limitsdb/core/ldb_meta.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Help:
9
+ text: str
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Cli:
14
+ flag: str # e.g. "--parallel-max"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Secret:
19
+ enabled: bool = True # default marks the field as secret
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Env:
24
+ name: str # e.g. "LDB_PARALLEL_MAX"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class CliOnly:
29
+ """Marker for fields that are used only through CLI (never stored in config.yml)."""
30
+
31
+ enabled: bool = True