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,8 @@
1
+ class Status:
2
+ TABLE_START = "TSTART"
3
+ TABLE_END = "TEND"
4
+ CHUNK_START = "CSTART"
5
+ CHUNK_END = "CEND"
6
+ GENERATED = "GENERATED"
7
+ ERROR = "ERROR"
8
+ SKIPPED = "SKIPPED"
@@ -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
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")