scistacklog 0.1.1__tar.gz

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,15 @@
1
+ .venv
2
+ .DS_Store
3
+ */*/__pycache__
4
+ others_projects/sciforge
5
+ scistack-gui/extension/node_modules/
6
+ scistack-gui/frontend/node_modules/__pycache__/
7
+ *.pyc
8
+ __pycache__/
9
+ *.pyc
10
+ *.pyo
11
+
12
+ # Generated database artifacts (DuckDB data/lineage + write-ahead logs)
13
+ *.duckdb
14
+ *.duckdb.wal
15
+ *.wal
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: scistacklog
3
+ Version: 0.1.1
4
+ Summary: Shared logging facade for the scistack framework (dual-sink console/file logging with layer tags)
5
+ Author: SciStack Contributors
6
+ License-Expression: MIT
7
+ Keywords: data-science,logging,pipeline
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Scientific/Engineering
18
+ Classifier: Topic :: System :: Logging
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.9
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7.0; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # scistacklog
26
+
27
+ The shared logging facade for every scistack layer. This is the lowest-level
28
+ package in the stack — it has no dependencies, and every other layer (scifor,
29
+ scidb, sciduckdb, …) emits through it or through a plain
30
+ `logging.getLogger("<layer>")` child logger that it covers.
31
+
32
+ ## Design
33
+
34
+ One `Log` call, two destinations with independent levels:
35
+
36
+ - **console** (stderr) — a concise pipeline narrative for the person running
37
+ the pipeline. Time-only timestamps; level prefix only at WARN and above.
38
+
39
+ ```
40
+ 14:32:06 [scifor] for_each(compute_psd) — 120 iterations: subject=12 values [s01,…,s12]
41
+ 14:33:15 [scifor] WARN: iteration failed: subject=s03, session=2 — ValueError: bad channel count
42
+ ```
43
+
44
+ - **file** (`scidb.log` next to the database, pointed via `Log.set_path`,
45
+ done automatically by `scidb.configure_database()`) — the run document.
46
+ One record per line: date + millisecond timestamp, level, originating layer.
47
+
48
+ ```
49
+ 2026-07-07 14:32:06.001 INFO [scifor] for_each(compute_psd) — 120 iterations: subject=12 values [s01,…,s12]
50
+ 2026-07-07 14:33:20.100 INFO [scidb] [timing] save_batch(PSD): 114 items, 12 schemas, 0.412s
51
+ ```
52
+
53
+ Both sinks default to INFO. DEBUG detail (per-iteration lines, timing phase
54
+ tables, framework internals) is opt-in:
55
+
56
+ ```python
57
+ from scistacklog import Log
58
+
59
+ Log.set_level("DEBUG", sink="file") # full detail in the file only
60
+ Log.set_level("DEBUG") # both sinks
61
+ ```
62
+
63
+ or set the `SCIDB_LOG_LEVEL` environment variable (applies to both sinks),
64
+ or pass `-v` to the `scidb` CLI.
65
+
66
+ ## Usage
67
+
68
+ ```python
69
+ from scistacklog import Log
70
+
71
+ Log.info("for_each(compute_psd) — 120 iterations", layer="scifor")
72
+ Log.debug("combo detail: %s", combo, layer="scifor")
73
+ Log.error("save failed", layer="scidb", exc_info=True)
74
+ ```
75
+
76
+ `layer` must be one of `scistacklog.LAYERS`
77
+ (`scidb`, `scifor`, `sciduck`, `scihist`, `scilineage`, `scistack`,
78
+ `scistack_gui`, `matlab`).
79
+
80
+ ## Contracts
81
+
82
+ - **caplog**: level filtering happens on the two handlers, never on the
83
+ loggers — every record propagates to the root logger, so pytest's `caplog`
84
+ captures everything regardless of sink levels. `Log.*(...)` with the
85
+ default layer emits on logger `"scidb"`, unchanged from the historical
86
+ implementation.
87
+ - **MATLAB**: `scimatlab`'s `+scidb/Log.m` delegates to this class via
88
+ `py.scidb.log.Log.*` (a re-export shim in scidb). The numeric level scale
89
+ (`DEBUG=0 … ERROR=3`) and positional call shapes are frozen for that
90
+ bridge.
@@ -0,0 +1,66 @@
1
+ # scistacklog
2
+
3
+ The shared logging facade for every scistack layer. This is the lowest-level
4
+ package in the stack — it has no dependencies, and every other layer (scifor,
5
+ scidb, sciduckdb, …) emits through it or through a plain
6
+ `logging.getLogger("<layer>")` child logger that it covers.
7
+
8
+ ## Design
9
+
10
+ One `Log` call, two destinations with independent levels:
11
+
12
+ - **console** (stderr) — a concise pipeline narrative for the person running
13
+ the pipeline. Time-only timestamps; level prefix only at WARN and above.
14
+
15
+ ```
16
+ 14:32:06 [scifor] for_each(compute_psd) — 120 iterations: subject=12 values [s01,…,s12]
17
+ 14:33:15 [scifor] WARN: iteration failed: subject=s03, session=2 — ValueError: bad channel count
18
+ ```
19
+
20
+ - **file** (`scidb.log` next to the database, pointed via `Log.set_path`,
21
+ done automatically by `scidb.configure_database()`) — the run document.
22
+ One record per line: date + millisecond timestamp, level, originating layer.
23
+
24
+ ```
25
+ 2026-07-07 14:32:06.001 INFO [scifor] for_each(compute_psd) — 120 iterations: subject=12 values [s01,…,s12]
26
+ 2026-07-07 14:33:20.100 INFO [scidb] [timing] save_batch(PSD): 114 items, 12 schemas, 0.412s
27
+ ```
28
+
29
+ Both sinks default to INFO. DEBUG detail (per-iteration lines, timing phase
30
+ tables, framework internals) is opt-in:
31
+
32
+ ```python
33
+ from scistacklog import Log
34
+
35
+ Log.set_level("DEBUG", sink="file") # full detail in the file only
36
+ Log.set_level("DEBUG") # both sinks
37
+ ```
38
+
39
+ or set the `SCIDB_LOG_LEVEL` environment variable (applies to both sinks),
40
+ or pass `-v` to the `scidb` CLI.
41
+
42
+ ## Usage
43
+
44
+ ```python
45
+ from scistacklog import Log
46
+
47
+ Log.info("for_each(compute_psd) — 120 iterations", layer="scifor")
48
+ Log.debug("combo detail: %s", combo, layer="scifor")
49
+ Log.error("save failed", layer="scidb", exc_info=True)
50
+ ```
51
+
52
+ `layer` must be one of `scistacklog.LAYERS`
53
+ (`scidb`, `scifor`, `sciduck`, `scihist`, `scilineage`, `scistack`,
54
+ `scistack_gui`, `matlab`).
55
+
56
+ ## Contracts
57
+
58
+ - **caplog**: level filtering happens on the two handlers, never on the
59
+ loggers — every record propagates to the root logger, so pytest's `caplog`
60
+ captures everything regardless of sink levels. `Log.*(...)` with the
61
+ default layer emits on logger `"scidb"`, unchanged from the historical
62
+ implementation.
63
+ - **MATLAB**: `scimatlab`'s `+scidb/Log.m` delegates to this class via
64
+ `py.scidb.log.Log.*` (a re-export shim in scidb). The numeric level scale
65
+ (`DEBUG=0 … ERROR=3`) and positional call shapes are frozen for that
66
+ bridge.
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [tool.hatch.version]
6
+ source = "vcs"
7
+ raw-options = { search_parent_directories = true }
8
+
9
+ [project]
10
+ name = "scistacklog"
11
+ dynamic = ["version"]
12
+ description = "Shared logging facade for the scistack framework (dual-sink console/file logging with layer tags)"
13
+ readme = "README.md"
14
+ license = "MIT"
15
+ requires-python = ">=3.9"
16
+ authors = [
17
+ { name = "SciStack Contributors" }
18
+ ]
19
+ keywords = [
20
+ "logging",
21
+ "pipeline",
22
+ "data-science",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 4 - Beta",
26
+ "Intended Audience :: Developers",
27
+ "Intended Audience :: Science/Research",
28
+ "License :: OSI Approved :: MIT License",
29
+ "Operating System :: OS Independent",
30
+ "Programming Language :: Python :: 3",
31
+ "Programming Language :: Python :: 3.10",
32
+ "Programming Language :: Python :: 3.11",
33
+ "Programming Language :: Python :: 3.12",
34
+ "Topic :: Scientific/Engineering",
35
+ "Topic :: System :: Logging",
36
+ "Typing :: Typed",
37
+ ]
38
+ dependencies = []
39
+
40
+ [project.optional-dependencies]
41
+ dev = [
42
+ "pytest>=7.0",
43
+ ]
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ include = [
47
+ "/src",
48
+ ]
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/scistacklog"]
52
+
53
+ [tool.pytest.ini_options]
54
+ testpaths = ["tests"]
55
+ pythonpath = ["src"]
@@ -0,0 +1,438 @@
1
+ """scistacklog — the shared logging facade for every scistack layer.
2
+
3
+ Dual-sink design:
4
+
5
+ - **console** (stderr): concise pipeline narrative for the person running the
6
+ pipeline. Time-only timestamps, level shown only at WARN and above.
7
+ - **file** (``scidb.log`` next to the database, set via ``Log.set_path``):
8
+ the run document. Date + millisecond timestamps, level, and originating
9
+ layer on every line; one record per line.
10
+
11
+ Both sinks default to INFO. DEBUG detail (per-iteration lines, timing phase
12
+ tables, framework internals) is opt-in per sink::
13
+
14
+ Log.set_level("DEBUG", sink="file") # full detail in the file only
15
+ Log.set_level("DEBUG") # both sinks
16
+
17
+ or via the ``SCIDB_LOG_LEVEL`` environment variable (applied to both sinks).
18
+
19
+ Usage::
20
+
21
+ from scistacklog import Log
22
+
23
+ Log.info("for_each(compute_psd) — 120 iterations", layer="scifor")
24
+ Log.debug("combo detail: %s", combo, layer="scifor")
25
+ Log.error("save failed", layer="scidb", exc_info=True)
26
+
27
+ Every emit goes through a stdlib logger named after the layer (``scidb``,
28
+ ``scifor``, …), so existing module loggers such as ``scidb.discover`` or
29
+ ``scilineage.hashing`` are covered automatically as children. Level
30
+ filtering happens on the two handlers, never on the loggers: records always
31
+ propagate to the root logger, so pytest's ``caplog`` captures them
32
+ regardless of sink levels.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import logging
38
+ import os
39
+ import sys
40
+ import threading
41
+ import time
42
+ import warnings
43
+ from contextlib import contextmanager
44
+
45
+ __all__ = ["Log", "LAYERS"]
46
+
47
+ #: Top-level logger name for each scistack layer. A record's ``[layer]`` tag
48
+ #: in the log line is the first dotted component of its logger name.
49
+ LAYERS = (
50
+ "scidb",
51
+ "scifor",
52
+ "sciduck",
53
+ "scihist",
54
+ "scilineage",
55
+ "scistack",
56
+ "scistack_gui",
57
+ "matlab",
58
+ )
59
+
60
+ # scidb's historical 0-3 level scale — the MATLAB-facing numeric contract.
61
+ _SCIDB_TO_PY = {
62
+ 0: logging.DEBUG,
63
+ 1: logging.INFO,
64
+ 2: logging.WARNING,
65
+ 3: logging.ERROR,
66
+ }
67
+ _NAME_TO_SCIDB = {"DEBUG": 0, "INFO": 1, "WARN": 2, "WARNING": 2, "ERROR": 3}
68
+ _SHORT_NAMES = {
69
+ logging.DEBUG: "DEBUG",
70
+ logging.INFO: "INFO",
71
+ logging.WARNING: "WARN",
72
+ logging.ERROR: "ERROR",
73
+ logging.CRITICAL: "ERROR",
74
+ }
75
+
76
+ _FILE_FORMAT = "%(asctime)s.%(msecs)03d %(levelshort)-5s [%(layer)s] %(message)s"
77
+ _FILE_DATEFMT = "%Y-%m-%d %H:%M:%S"
78
+ _CONSOLE_DATEFMT = "%H:%M:%S"
79
+
80
+
81
+ class _RecordTagger(logging.Filter):
82
+ """Adds the fields the formatters need: layer and short level name."""
83
+
84
+ def filter(self, record: logging.LogRecord) -> bool:
85
+ record.layer = record.name.split(".", 1)[0]
86
+ record.levelshort = _SHORT_NAMES.get(record.levelno, record.levelname)
87
+ return True
88
+
89
+
90
+ class _ConsoleFormatter(logging.Formatter):
91
+ """Time-only timestamps; level prefix only at WARN and above."""
92
+
93
+ def __init__(self) -> None:
94
+ super().__init__(
95
+ "%(asctime)s [%(layer)s] %(message)s", datefmt=_CONSOLE_DATEFMT
96
+ )
97
+ self._alert = logging.Formatter(
98
+ "%(asctime)s [%(layer)s] %(levelshort)s: %(message)s",
99
+ datefmt=_CONSOLE_DATEFMT,
100
+ )
101
+
102
+ def format(self, record: logging.LogRecord) -> str:
103
+ if record.levelno >= logging.WARNING:
104
+ return self._alert.format(record)
105
+ return super().format(record)
106
+
107
+
108
+ class _StderrHandler(logging.StreamHandler):
109
+ """Console handler that resolves ``sys.stderr`` at emit time.
110
+
111
+ Looking the stream up dynamically keeps the handler correct when the
112
+ hosting process swaps stderr (pytest's capsys, MATLAB, GUI harnesses).
113
+ """
114
+
115
+ def __init__(self) -> None:
116
+ super().__init__(sys.stderr)
117
+
118
+ @property
119
+ def stream(self):
120
+ return sys.stderr
121
+
122
+ @stream.setter
123
+ def stream(self, value):
124
+ pass # always dynamic; StreamHandler.__init__/setStream assignments are no-ops
125
+
126
+
127
+ class _Timer:
128
+ """Collects named phase durations for :meth:`Log.timer`."""
129
+
130
+ def __init__(self) -> None:
131
+ self.phases: "list[tuple[str, float]]" = []
132
+
133
+ @contextmanager
134
+ def phase(self, name: str):
135
+ t0 = time.perf_counter()
136
+ try:
137
+ yield
138
+ finally:
139
+ self.phases.append((name, time.perf_counter() - t0))
140
+
141
+
142
+ class Log:
143
+ """Framework-wide logging facade (classmethod API, MATLAB-bridged).
144
+
145
+ Backed by stdlib logging: one stderr console handler and one file
146
+ handler, both attached to every layer logger in :data:`LAYERS`.
147
+ """
148
+
149
+ DEBUG = 0
150
+ INFO = 1
151
+ WARN = 2
152
+ ERROR = 3
153
+
154
+ _lock = threading.RLock()
155
+ _attached = False
156
+ _tagger = _RecordTagger()
157
+ _console_handler: "logging.Handler | None" = None
158
+ _file_handler: "logging.FileHandler | None" = None
159
+ _path: "str | None" = None
160
+ _console_py_level: int = logging.INFO
161
+ _file_py_level: int = logging.INFO
162
+ _unknown_layers: "set[str]" = set()
163
+
164
+ # -- setup ------------------------------------------------------------
165
+
166
+ @classmethod
167
+ def attach(cls) -> None:
168
+ """Create the sinks and attach them to every layer logger.
169
+
170
+ Idempotent; called lazily by every other method, so nothing happens
171
+ at import time. Reads ``SCIDB_LOG_LEVEL`` (both sinks) on first call.
172
+ """
173
+ with cls._lock:
174
+ if cls._attached:
175
+ return
176
+ env_level = os.environ.get("SCIDB_LOG_LEVEL")
177
+ if env_level:
178
+ py_level = cls._to_py_level(env_level)
179
+ cls._console_py_level = py_level
180
+ cls._file_py_level = py_level
181
+ handler = _StderrHandler()
182
+ handler.setLevel(cls._console_py_level)
183
+ handler.setFormatter(_ConsoleFormatter())
184
+ handler.addFilter(cls._tagger)
185
+ cls._console_handler = handler
186
+ for name in LAYERS:
187
+ layer_logger = logging.getLogger(name)
188
+ # Filtering happens on the handlers; the logger passes
189
+ # everything so caplog/root handlers see all records.
190
+ layer_logger.setLevel(logging.DEBUG)
191
+ layer_logger.propagate = True
192
+ if handler not in layer_logger.handlers:
193
+ layer_logger.addHandler(handler)
194
+ cls._attached = True
195
+
196
+ @classmethod
197
+ def bridge_python_logging(cls) -> None:
198
+ """Deprecated alias for :meth:`attach`.
199
+
200
+ The old implementation installed a bridge handler on a hardcoded
201
+ subset of logger names; the layer-logger hierarchy replaces it.
202
+ """
203
+ cls.attach()
204
+
205
+ @classmethod
206
+ def set_path(cls, log_path: "str | None") -> None:
207
+ """Point the file sink at ``log_path`` (``None`` detaches it).
208
+
209
+ Called automatically by ``scidb.configure_database()``.
210
+ """
211
+ cls.attach()
212
+ with cls._lock:
213
+ if cls._file_handler is not None:
214
+ for name in LAYERS:
215
+ logging.getLogger(name).removeHandler(cls._file_handler)
216
+ cls._file_handler.close()
217
+ cls._file_handler = None
218
+ cls._path = None if log_path is None else str(log_path)
219
+ if cls._path is None:
220
+ return
221
+ handler = logging.FileHandler(
222
+ cls._path, mode="a", encoding="utf-8", delay=True
223
+ )
224
+ handler.setLevel(cls._file_py_level)
225
+ handler.setFormatter(
226
+ logging.Formatter(_FILE_FORMAT, datefmt=_FILE_DATEFMT)
227
+ )
228
+ handler.addFilter(cls._tagger)
229
+ cls._file_handler = handler
230
+ for name in LAYERS:
231
+ logging.getLogger(name).addHandler(handler)
232
+
233
+ @classmethod
234
+ def get_path(cls) -> "str | None":
235
+ """Current log file path (``None`` if the file sink is detached)."""
236
+ return cls._path
237
+
238
+ # -- levels -----------------------------------------------------------
239
+
240
+ @classmethod
241
+ def set_level(cls, level: "int | str", sink: str = "both") -> None:
242
+ """Set the level of one or both sinks.
243
+
244
+ Args:
245
+ level: ``'DEBUG'``/``'INFO'``/``'WARN'``/``'ERROR'`` or numeric 0-3.
246
+ sink: ``'console'``, ``'file'``, or ``'both'``.
247
+ """
248
+ if sink not in ("console", "file", "both"):
249
+ raise ValueError(
250
+ f"Unknown sink '{sink}' (expected 'console', 'file', or 'both')"
251
+ )
252
+ cls.attach()
253
+ py_level = cls._to_py_level(level)
254
+ with cls._lock:
255
+ if sink in ("console", "both"):
256
+ cls._console_py_level = py_level
257
+ if cls._console_handler is not None:
258
+ cls._console_handler.setLevel(py_level)
259
+ if sink in ("file", "both"):
260
+ cls._file_py_level = py_level
261
+ if cls._file_handler is not None:
262
+ cls._file_handler.setLevel(py_level)
263
+
264
+ @classmethod
265
+ def get_level(cls, sink: "str | None" = None) -> int:
266
+ """Level of a sink on the 0-3 scale; min of both sinks when ``None``.
267
+
268
+ The ``None`` form is what MATLAB caches to gate calls client-side:
269
+ a message suppressed by both sinks never needs to cross the bridge.
270
+ """
271
+ if sink == "console":
272
+ return cls._from_py_level(cls._console_py_level)
273
+ if sink == "file":
274
+ return cls._from_py_level(cls._file_py_level)
275
+ return cls._from_py_level(
276
+ min(cls._console_py_level, cls._file_py_level)
277
+ )
278
+
279
+ # -- emission ---------------------------------------------------------
280
+
281
+ @classmethod
282
+ def debug(cls, msg: str, *args, layer: str = "scidb",
283
+ exc_info: bool = False) -> None:
284
+ """Log a message at DEBUG level."""
285
+ cls._log(logging.DEBUG, msg, args, layer, exc_info)
286
+
287
+ @classmethod
288
+ def info(cls, msg: str, *args, layer: str = "scidb",
289
+ exc_info: bool = False) -> None:
290
+ """Log a message at INFO level."""
291
+ cls._log(logging.INFO, msg, args, layer, exc_info)
292
+
293
+ @classmethod
294
+ def warn(cls, msg: str, *args, layer: str = "scidb",
295
+ exc_info: bool = False) -> None:
296
+ """Log a message at WARN level."""
297
+ cls._log(logging.WARNING, msg, args, layer, exc_info)
298
+
299
+ @classmethod
300
+ def error(cls, msg: str, *args, layer: str = "scidb",
301
+ exc_info: bool = False) -> None:
302
+ """Log a message at ERROR level."""
303
+ cls._log(logging.ERROR, msg, args, layer, exc_info)
304
+
305
+ # -- instrumentation ----------------------------------------------------
306
+
307
+ @classmethod
308
+ @contextmanager
309
+ def step(cls, name: str, *, layer: str = "scidb"):
310
+ """Trace one named operation: entry/exit at DEBUG, failure at ERROR.
311
+
312
+ Names are snake_case operations (``resolve_empty_lists``,
313
+ ``delegate_to_scifor``) — never numbered steps; ordering is conveyed
314
+ by the log's own sequence. At the default (INFO) level these are
315
+ silent; at DEBUG the file reads as a nested execution trace with
316
+ per-step durations. An exception is logged with its traceback and
317
+ duration, then re-raised — so a failed run's log always ends with
318
+ the error in context.
319
+
320
+ Usage::
321
+
322
+ with Log.step("save_batch(PSD)", layer="scidb"):
323
+ ...
324
+ """
325
+ cls.debug(f"→ {name}", layer=layer)
326
+ t0 = time.perf_counter()
327
+ try:
328
+ yield
329
+ except Exception as e:
330
+ cls.error(
331
+ f"✗ {name} failed after {time.perf_counter() - t0:.3f}s: "
332
+ f"{type(e).__name__}: {e}",
333
+ layer=layer, exc_info=True,
334
+ )
335
+ raise
336
+ cls.debug(f"← {name} done in {time.perf_counter() - t0:.3f}s",
337
+ layer=layer)
338
+
339
+ @classmethod
340
+ @contextmanager
341
+ def timer(cls, name: str, *, layer: str = "scidb",
342
+ extra: "str | None" = None):
343
+ """Phase timing for a hot operation, emitted under the ``[timing]`` tag.
344
+
345
+ Yields a :class:`_Timer`; wrap sub-phases with ``t.phase("name")``
346
+ (snake_case, never numbered). On exit emits one INFO summary line —
347
+ ``[timing] name: TOTAL=…s (phase=…s, …)`` — plus one DEBUG table
348
+ line per phase. The ``[timing]`` prefix is a stable grep target
349
+ (MATLAB timing-test archives rely on it).
350
+
351
+ Usage::
352
+
353
+ with Log.timer("save_batch(PSD)", extra="114 items") as t:
354
+ with t.phase("canonical_hash"): ...
355
+ with t.phase("commit"): ...
356
+ """
357
+ t = _Timer()
358
+ t0 = time.perf_counter()
359
+ try:
360
+ yield t
361
+ finally:
362
+ total = time.perf_counter() - t0
363
+ parts = ", ".join(f"{p}={s:.3f}s" for p, s in t.phases)
364
+ detail = f" ({parts})" if parts else ""
365
+ prefix = f"{extra}, " if extra else ""
366
+ cls.info(f"[timing] {name}: {prefix}TOTAL={total:.3f}s{detail}",
367
+ layer=layer)
368
+ for p, s in t.phases:
369
+ cls.debug(f" {name} {p:<30s} {s:.3f}s", layer=layer)
370
+
371
+ # -- internals ---------------------------------------------------------
372
+
373
+ @classmethod
374
+ def _log(cls, py_level: int, msg: str, args: tuple, layer: str,
375
+ exc_info: bool) -> None:
376
+ cls.attach()
377
+ name = str(layer).split(".", 1)[0]
378
+ if name not in LAYERS:
379
+ with cls._lock:
380
+ if name not in cls._unknown_layers:
381
+ cls._unknown_layers.add(name)
382
+ warnings.warn(
383
+ f"Unknown scistack layer '{layer}', logging as 'scidb'.",
384
+ UserWarning,
385
+ )
386
+ name = "scidb"
387
+ logging.getLogger(name).log(py_level, msg, *args, exc_info=exc_info)
388
+
389
+ @classmethod
390
+ def _to_py_level(cls, level: "int | str") -> int:
391
+ if isinstance(level, str):
392
+ name = level.upper()
393
+ if name not in _NAME_TO_SCIDB:
394
+ warnings.warn(
395
+ f"Unknown log level '{name}', defaulting to INFO.",
396
+ UserWarning,
397
+ )
398
+ return logging.INFO
399
+ return _SCIDB_TO_PY[_NAME_TO_SCIDB[name]]
400
+ try:
401
+ numeric = int(level)
402
+ except (TypeError, ValueError):
403
+ warnings.warn(
404
+ f"Unknown log level {level!r}, defaulting to INFO.", UserWarning
405
+ )
406
+ return logging.INFO
407
+ if numeric not in _SCIDB_TO_PY:
408
+ warnings.warn(
409
+ f"Unknown log level {level!r}, defaulting to INFO.", UserWarning
410
+ )
411
+ return logging.INFO
412
+ return _SCIDB_TO_PY[numeric]
413
+
414
+ @staticmethod
415
+ def _from_py_level(py_level: int) -> int:
416
+ if py_level <= logging.DEBUG:
417
+ return Log.DEBUG
418
+ if py_level <= logging.INFO:
419
+ return Log.INFO
420
+ if py_level <= logging.WARNING:
421
+ return Log.WARN
422
+ return Log.ERROR
423
+
424
+ @classmethod
425
+ def _reset_for_tests(cls) -> None:
426
+ """Detach all handlers and reset state. Test isolation only."""
427
+ with cls._lock:
428
+ for handler in (cls._console_handler, cls._file_handler):
429
+ if handler is not None:
430
+ for name in LAYERS:
431
+ logging.getLogger(name).removeHandler(handler)
432
+ handler.close()
433
+ cls._console_handler = None
434
+ cls._file_handler = None
435
+ cls._path = None
436
+ cls._attached = False
437
+ cls._console_py_level = logging.INFO
438
+ cls._file_py_level = logging.INFO