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,332 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterable, Sequence
|
|
5
|
+
from importlib.resources import files as ir_files
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import (
|
|
8
|
+
TYPE_CHECKING,
|
|
9
|
+
Any,
|
|
10
|
+
Literal,
|
|
11
|
+
get_args,
|
|
12
|
+
get_origin,
|
|
13
|
+
get_type_hints,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from platformdirs import PlatformDirs # requerido
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from limitsdb.core.ldb_params_config import Config
|
|
20
|
+
from limitsdb.core.ldb_crypto import encrypt, is_encrypted, load_or_create_key
|
|
21
|
+
from limitsdb.core.ldb_errors import SecretError, ValidationError
|
|
22
|
+
from limitsdb.core.ldb_logger import get_logger
|
|
23
|
+
from limitsdb.core.ldb_meta import CliOnly, Help, Secret
|
|
24
|
+
|
|
25
|
+
logger = get_logger("utils")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def nvl(value: Any | None, default: Any) -> Any:
|
|
29
|
+
"""Returns the value if it is not None, otherwise returns the default value.
|
|
30
|
+
Args:
|
|
31
|
+
value: The value to evaluate.
|
|
32
|
+
default: The value to return if `value` is None.
|
|
33
|
+
Returns:
|
|
34
|
+
The original value if not None, else the default."""
|
|
35
|
+
return value if value is not None else default
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def max_ignore_none[T](values: Sequence[T | None]) -> T | None:
|
|
39
|
+
"""
|
|
40
|
+
Returns the maximum value from a sequence, ignoring None values.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ValueError: If no non-None values are found.
|
|
44
|
+
TypeError: If values are not mutually comparable.
|
|
45
|
+
|
|
46
|
+
:param values: Sequence of optional values.
|
|
47
|
+
:return: The maximum value or None if all values are None.
|
|
48
|
+
"""
|
|
49
|
+
filtered = [v for v in values if v is not None]
|
|
50
|
+
if not filtered:
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
return max(filtered) # type: ignore[type-var]
|
|
54
|
+
except TypeError as e:
|
|
55
|
+
raise TypeError("Values are not mutually comparable.") from e
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def indent_lines(text: str, spaces: int) -> str:
|
|
59
|
+
"""
|
|
60
|
+
Returns the input string with all lines after the first indented by the given number of spaces.
|
|
61
|
+
|
|
62
|
+
:param text: Multiline string to process.
|
|
63
|
+
:param spaces: Number of spaces to prepend to each line after the first.
|
|
64
|
+
:return: Modified string with indentation applied.
|
|
65
|
+
"""
|
|
66
|
+
return ("\n" + (" " * spaces)).join(text.splitlines())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def join_wrapped(connector: str, items: Iterable[str], max_line_length: int) -> str:
|
|
70
|
+
"""
|
|
71
|
+
Joins items into a comma-separated string, wrapping lines when the max_line_length is exceeded.
|
|
72
|
+
Lines after the first begin with ',' to indicate continuation.
|
|
73
|
+
|
|
74
|
+
:param connector: String to use as a separator (e.g., ',').
|
|
75
|
+
:param items: Iterable of elements to join (converted to strings).
|
|
76
|
+
:param max_line_length: Max allowed characters per line before wrapping.
|
|
77
|
+
:return: String with comma-separated items and continuation lines starting with ','.
|
|
78
|
+
"""
|
|
79
|
+
lines: list[str] = []
|
|
80
|
+
current_line = ""
|
|
81
|
+
for item in map(str, items):
|
|
82
|
+
candidate = (connector if current_line else "") + item
|
|
83
|
+
if len(current_line) + len(candidate) > max_line_length:
|
|
84
|
+
lines.append(current_line)
|
|
85
|
+
current_line = connector + item
|
|
86
|
+
else:
|
|
87
|
+
current_line += candidate
|
|
88
|
+
if current_line:
|
|
89
|
+
lines.append(current_line)
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_effective_credentials(
|
|
94
|
+
cfg: Config, *, admin: bool = False, env: Literal["SOURCE", "HISTORY"] | None = None
|
|
95
|
+
) -> tuple[str, str, str]:
|
|
96
|
+
"""Return (username, password, dsn) tuple according to action and admin flag."""
|
|
97
|
+
is_source = cfg.execution.action == "SOURCE_ILM" if env is None else (env == "SOURCE")
|
|
98
|
+
env_part = "source" if is_source else "history"
|
|
99
|
+
endpoint = cfg.connections.source if is_source else cfg.connections.history
|
|
100
|
+
credentials = cfg.administration.source if is_source else cfg.administration.history
|
|
101
|
+
user_key = f"{'admin_' if admin else ''}{env_part}_username"
|
|
102
|
+
pwd_key = f"{'admin_' if admin else ''}{env_part}_password"
|
|
103
|
+
dsn_key = f"{env_part}_dsn"
|
|
104
|
+
user = credentials.username if admin else endpoint.username
|
|
105
|
+
pwd = credentials.password if admin else endpoint.password
|
|
106
|
+
dsn = endpoint.dsn
|
|
107
|
+
missing = [key for key, value in ((user_key, user), (pwd_key, pwd), (dsn_key, dsn)) if not value]
|
|
108
|
+
if missing:
|
|
109
|
+
raise ValidationError(f"Missing {'/'.join(missing)} for action={cfg.execution.action}")
|
|
110
|
+
assert user is not None and pwd is not None and dsn is not None
|
|
111
|
+
return user, pwd, dsn
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
APPNAME = "LimitsDb"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _schema_dir(schema: str, profile: str | None, config_root: str | None) -> Path:
|
|
118
|
+
base = Path(config_root) if config_root else Path(get_config_roots(APPNAME)[0])
|
|
119
|
+
d = base / "schemas" / schema
|
|
120
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
return d
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def resolve_schema_file(
|
|
125
|
+
*,
|
|
126
|
+
schema: str,
|
|
127
|
+
profile: str | None,
|
|
128
|
+
explicit_config_dir: str | None,
|
|
129
|
+
explicit_file: str | None,
|
|
130
|
+
prefix_name: Literal["config", "ilm", "secrets"],
|
|
131
|
+
extension_name: Literal["yml", "json"] = "yml",
|
|
132
|
+
description: str,
|
|
133
|
+
) -> str | None:
|
|
134
|
+
"""
|
|
135
|
+
Resolve the file path for a schema-level file (config.yml, ilm.yml, secrets.json).
|
|
136
|
+
|
|
137
|
+
Rules:
|
|
138
|
+
1. Always start from --config-dir if provided; otherwise use default roots.
|
|
139
|
+
2. If explicit_file is given:
|
|
140
|
+
- If absolute, return as-is.
|
|
141
|
+
- If relative, resolve under <root>/schemas/<schema>/.
|
|
142
|
+
3. If not given:
|
|
143
|
+
- Use <prefix>.<profile>.yml if profile is set, else <prefix>.yml.
|
|
144
|
+
"""
|
|
145
|
+
user_root, sys_root = get_config_roots()
|
|
146
|
+
roots = [Path(explicit_config_dir)] if explicit_config_dir else [user_root, sys_root]
|
|
147
|
+
# Case 1: explicit path
|
|
148
|
+
if explicit_file:
|
|
149
|
+
p = Path(explicit_file)
|
|
150
|
+
if not p.is_absolute():
|
|
151
|
+
for root in roots:
|
|
152
|
+
cand = root / "schemas" / schema / p
|
|
153
|
+
if cand.exists():
|
|
154
|
+
return str(cand.resolve())
|
|
155
|
+
if p.exists():
|
|
156
|
+
return str(p.resolve())
|
|
157
|
+
raise FileNotFoundError(f"Specified {description} not found: {explicit_file}")
|
|
158
|
+
# Case 2: automatic candidate
|
|
159
|
+
filename = f"{prefix_name}.{profile}.{extension_name}" if profile else f"{prefix_name}.{extension_name}"
|
|
160
|
+
for root in roots:
|
|
161
|
+
cand = root / "schemas" / schema / filename
|
|
162
|
+
if cand.exists():
|
|
163
|
+
return str(cand.resolve())
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _hints() -> dict[str, Any]:
|
|
168
|
+
from limitsdb.core.ldb_params_config import Config # lazy import to avoid circulars
|
|
169
|
+
|
|
170
|
+
return get_type_hints(Config, include_extras=True)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def secret_keys_from_config() -> list[str]:
|
|
174
|
+
keys: list[str] = []
|
|
175
|
+
for name, annotated in _hints().items():
|
|
176
|
+
metas = get_args(annotated)
|
|
177
|
+
if any(isinstance(m, Secret) and m.enabled for m in metas):
|
|
178
|
+
keys.append(name)
|
|
179
|
+
return keys
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def render_config_template_with_help() -> str:
|
|
183
|
+
from limitsdb.core.ldb_params_config import Config # lazy import to avoid circulars
|
|
184
|
+
|
|
185
|
+
lines: list[str] = [
|
|
186
|
+
"# LimitsDb config template",
|
|
187
|
+
"# All keys are commented; defaults are shown on the right; help after the hash.",
|
|
188
|
+
"",
|
|
189
|
+
]
|
|
190
|
+
for name, annotated in _hints().items():
|
|
191
|
+
metas = get_args(annotated)
|
|
192
|
+
# skip secrets from config.yml (they go in secrets.json)
|
|
193
|
+
if any(isinstance(m, Secret) and m.enabled for m in metas) or any(
|
|
194
|
+
isinstance(m, CliOnly) and m.enabled for m in metas
|
|
195
|
+
):
|
|
196
|
+
continue
|
|
197
|
+
help_text = next((m.text for m in metas if isinstance(m, Help)), "")
|
|
198
|
+
default = getattr(Config, name)
|
|
199
|
+
# show Literal choices nicely if default is a Literal member
|
|
200
|
+
if get_origin(annotated) is Literal:
|
|
201
|
+
choices = ", ".join(repr(x) for x in get_args(annotated))
|
|
202
|
+
if help_text:
|
|
203
|
+
help_text = f"{help_text} (choices: {choices})"
|
|
204
|
+
else:
|
|
205
|
+
help_text = f"(choices: {choices})"
|
|
206
|
+
dv = f'"{default}"' if isinstance(default, str) else default
|
|
207
|
+
lines.append(f"# {name}: {dv} # {help_text}".rstrip())
|
|
208
|
+
lines.append("")
|
|
209
|
+
return "\n".join(lines)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def write_or_update_secrets(schema: str, profile: str | None, config_root: str | None, overwrite: bool = False) -> Path:
|
|
213
|
+
sd = _schema_dir(schema, profile, config_root)
|
|
214
|
+
p = sd / ("secrets.json" if not profile else f"secrets.{profile}.json")
|
|
215
|
+
current: dict[str, Any] = {}
|
|
216
|
+
if p.exists():
|
|
217
|
+
try:
|
|
218
|
+
loaded: Any = json.loads(p.read_text(encoding="utf-8"))
|
|
219
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
220
|
+
raise SecretError(f"Unable to read secrets file at {p}: {exc}") from exc
|
|
221
|
+
if not isinstance(loaded, dict):
|
|
222
|
+
raise SecretError(f"Secrets root must be a mapping (object) in {p}")
|
|
223
|
+
current = loaded
|
|
224
|
+
keys = secret_keys_from_config()
|
|
225
|
+
if overwrite:
|
|
226
|
+
current = {k: "" for k in keys}
|
|
227
|
+
else:
|
|
228
|
+
for k in keys:
|
|
229
|
+
current.setdefault(k, "")
|
|
230
|
+
p.write_text(json.dumps(current, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
231
|
+
return p
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def encrypt_secrets_in_place(schema: str, profile: str | None, config_root: str | None) -> Path | None:
|
|
235
|
+
load_or_create_key()
|
|
236
|
+
sd = _schema_dir(schema, profile, config_root)
|
|
237
|
+
p = sd / ("secrets.json" if not profile else f"secrets.{profile}.json")
|
|
238
|
+
if not p.exists():
|
|
239
|
+
return None
|
|
240
|
+
try:
|
|
241
|
+
loaded: Any = json.loads(p.read_text(encoding="utf-8"))
|
|
242
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
243
|
+
raise SecretError(f"Unable to read secrets file at {p}: {exc}") from exc
|
|
244
|
+
if not isinstance(loaded, dict):
|
|
245
|
+
raise SecretError(f"Secrets root must be a mapping (object) in {p}")
|
|
246
|
+
data: dict[str, Any] = loaded
|
|
247
|
+
updated = False
|
|
248
|
+
for k in secret_keys_from_config():
|
|
249
|
+
v = data.get(k)
|
|
250
|
+
if isinstance(v, str) and v != "" and not is_encrypted(v):
|
|
251
|
+
data[k] = encrypt(v)
|
|
252
|
+
updated = True
|
|
253
|
+
if updated:
|
|
254
|
+
p.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
255
|
+
return p
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def write_config_yaml(schema: str, profile: str | None, config_root: str | None, overwrite: bool = False) -> Path:
|
|
260
|
+
sd = _schema_dir(schema, profile, config_root)
|
|
261
|
+
p = sd / ("config.yml" if not profile else f"config.{profile}.yml")
|
|
262
|
+
if overwrite or not p.exists():
|
|
263
|
+
p.write_text(render_config_template_with_help(), encoding="utf-8")
|
|
264
|
+
return p
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def write_ilm_yaml(schema: str, profile: str | None, config_root: str | None, overwrite: bool = False) -> Path:
|
|
268
|
+
sd = _schema_dir(schema, profile, config_root)
|
|
269
|
+
p = sd / ("ilm.yml" if not profile else f"ilm.{profile}.yml")
|
|
270
|
+
if overwrite or not p.exists():
|
|
271
|
+
p.write_text("tables: []\n", encoding="utf-8")
|
|
272
|
+
return p
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _read_ilm_example_text() -> str:
|
|
276
|
+
# Will work whether installed as a wheel or running from source
|
|
277
|
+
logger.debug(
|
|
278
|
+
"Reading ILM example template from package resources %s", (ir_files("limitsdb.resources") / "ilm.example.yml")
|
|
279
|
+
)
|
|
280
|
+
return (ir_files("limitsdb.resources") / "ilm.example.yml").read_text(encoding="utf-8")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def write_ilm_example(schema: str, profile: str | None, config_root: str | None, overwrite: bool = False) -> Path:
|
|
284
|
+
logger.debug(
|
|
285
|
+
"Writing ILM example template for schema=%s, profile=%s, config_root=%s, overwrite=%s",
|
|
286
|
+
schema,
|
|
287
|
+
profile,
|
|
288
|
+
config_root,
|
|
289
|
+
overwrite,
|
|
290
|
+
)
|
|
291
|
+
sd = _schema_dir(schema, profile, config_root)
|
|
292
|
+
out = sd / ("ilm.example.yml" if not profile else f"ilm.{profile}.example.yml")
|
|
293
|
+
logger.debug("ILM example path: %s", out)
|
|
294
|
+
if overwrite or not out.exists():
|
|
295
|
+
logger.debug("Creating ILM example file")
|
|
296
|
+
example_text = _read_ilm_example_text()
|
|
297
|
+
out.write_text(example_text, encoding="utf-8")
|
|
298
|
+
return out
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def get_config_roots(appname: str = APPNAME) -> tuple[Path, Path]:
|
|
302
|
+
d = PlatformDirs(appname, appauthor=False)
|
|
303
|
+
return Path(d.user_config_dir), Path("/etc/limitsdb")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def init_schema(
|
|
307
|
+
*,
|
|
308
|
+
schema: str,
|
|
309
|
+
profile: str | None = None,
|
|
310
|
+
config_root: str | None = None,
|
|
311
|
+
overwrite: bool = False,
|
|
312
|
+
with_examples: bool = True,
|
|
313
|
+
auto_encrypt: bool = True,
|
|
314
|
+
) -> tuple[Path, Path, Path, Path | None]:
|
|
315
|
+
"""Initialize schema/profile configuration structure."""
|
|
316
|
+
# ensure key exists (idempotent)
|
|
317
|
+
load_or_create_key()
|
|
318
|
+
logger.debug(
|
|
319
|
+
"Initializing schema/profile structure with_examples=%s, auto_encrypt=%s, overwrite=%s",
|
|
320
|
+
with_examples,
|
|
321
|
+
auto_encrypt,
|
|
322
|
+
overwrite,
|
|
323
|
+
)
|
|
324
|
+
cfg_p = write_config_yaml(schema, profile, config_root, overwrite)
|
|
325
|
+
ilm_p = write_ilm_yaml(schema, profile, config_root, overwrite)
|
|
326
|
+
sec_p = write_or_update_secrets(schema, profile, config_root, overwrite)
|
|
327
|
+
ilm_exple_p = None
|
|
328
|
+
if with_examples:
|
|
329
|
+
ilm_exple_p = write_ilm_example(schema, profile, config_root, overwrite)
|
|
330
|
+
if auto_encrypt:
|
|
331
|
+
encrypt_secrets_in_place(schema, profile, config_root)
|
|
332
|
+
return cfg_p, ilm_p, sec_p, ilm_exple_p
|
limitsdb/db/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from typing import cast
|
|
2
|
+
|
|
3
|
+
from limitsdb.db.ldb_engines import DatabaseEngine
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_db_engine(engine_name: str) -> DatabaseEngine:
|
|
7
|
+
"""Retrieves the database engine class based on the provided engine name.
|
|
8
|
+
Args:
|
|
9
|
+
engine_name (str): The name of the database engine.
|
|
10
|
+
Returns:
|
|
11
|
+
type: DatabaseEngine interface"""
|
|
12
|
+
if engine_name == "oracle":
|
|
13
|
+
from limitsdb.db.oracle.ldb_engine_impl import OracleEngine
|
|
14
|
+
|
|
15
|
+
return cast(DatabaseEngine, OracleEngine)
|
|
16
|
+
raise ValueError(f"Unsupported database engine: {engine_name}; supported engines: oracle")
|