scanlayer 1.0.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,367 @@
1
+ """
2
+ Source image preprocessing.
3
+
4
+ Two outputs:
5
+ 1. background: straightened image, original colors/resolution.
6
+ 2. ocr_image: grayscale, illumination-corrected, denoised, contrast-enhanced.
7
+
8
+ Both share the same coordinate system (ocr_scale maps between them).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+ import cv2
16
+ import numpy as np
17
+ import pytesseract
18
+ from PIL import Image, ImageOps
19
+
20
+ from scanlayer import config
21
+ from scanlayer.utils.logger import get_logger, log_warning, stage_timer
22
+
23
+ log = get_logger(__name__)
24
+
25
+
26
+ @dataclass
27
+ class PreprocessResult:
28
+ background: Image.Image
29
+ ocr_image: np.ndarray
30
+ ocr_scale: float
31
+ effective_dpi: int
32
+ exif_orientation_applied: int = 1
33
+ gross_rotation_applied: int = 0
34
+ deskew_angle_applied: float = 0.0
35
+ manual_rotation_applied: "float | None" = None
36
+ orientation_correction_skipped: bool = False
37
+ source_std: float = 0.0
38
+ likely_blank: bool = False
39
+ preprocessing_warnings: tuple = ()
40
+
41
+
42
+ def _pil_to_cv(image: Image.Image) -> np.ndarray:
43
+ return cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2BGR)
44
+
45
+
46
+ def _cv_to_pil(image: np.ndarray) -> Image.Image:
47
+ return Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
48
+
49
+
50
+ def _apply_exif_orientation(image: Image.Image) -> tuple[Image.Image, int]:
51
+ """Apply the EXIF Orientation tag before any other processing.
52
+
53
+ PIL's Image.open() never does this automatically.
54
+ """
55
+ if not config.APPLY_EXIF_ORIENTATION:
56
+ return image, 1
57
+
58
+ try:
59
+ exif = image.getexif()
60
+ orientation = exif.get(0x0112, 1) # 0x0112 = Orientation tag
61
+ except Exception:
62
+ orientation = 1
63
+
64
+ if orientation == 1:
65
+ return image, 1
66
+
67
+ corrected = ImageOps.exif_transpose(image)
68
+ log.info(f"EXIF: orientation tag {orientation} applied")
69
+ return corrected, orientation
70
+
71
+
72
+ def _fix_gross_orientation(image: Image.Image) -> tuple[Image.Image, int]:
73
+ """Correct obvious rotations (0/90/180/270) using Tesseract OSD."""
74
+ try:
75
+ osd = pytesseract.image_to_osd(image, output_type=pytesseract.Output.DICT)
76
+ rotate = int(osd.get("rotate", 0))
77
+ confidence = float(osd.get("orientation_conf", 0))
78
+ except pytesseract.TesseractError as exc:
79
+ log_warning(
80
+ log,
81
+ f"OSD unavailable (page too text-sparse?), "
82
+ f"orientation not corrected. Detail: {exc}"
83
+ )
84
+ return image, 0
85
+ except Exception as exc:
86
+ # Non-Tesseract error (most often: missing tessdata, or
87
+ # image too large for OSD which downscales poorly internally).
88
+ log_warning(
89
+ log,
90
+ f"OSD failed ({type(exc).__name__}: {exc}), "
91
+ f"orientation not corrected."
92
+ )
93
+ return image, 0
94
+
95
+ if rotate == 0:
96
+ log.debug(f"OSD: orientation OK (conf={confidence:.1f})")
97
+ return image, 0
98
+
99
+ # If OSD has low confidence, we log a warning but still apply,
100
+ # a wrong gross angle ruins the entire OCR, better to try.
101
+ if confidence < 5.0:
102
+ log_warning(
103
+ log,
104
+ f"OSD: rotation {rotate}° proposed with very low confidence "
105
+ f"({confidence:.1f}/20), applied anyway, but verify the result."
106
+ )
107
+ else:
108
+ log.info(f"OSD: rotation {rotate}° applied (conf={confidence:.1f}/20)")
109
+
110
+ # PIL rotate() rotates counter-clockwise; OSD "rotate" indicates
111
+ # the clockwise angle needed to upright the text.
112
+ rotated = image.rotate(-rotate, expand=True, fillcolor=(255, 255, 255))
113
+ return rotated, rotate
114
+
115
+
116
+ def _rotate_by_angle(cv_image: np.ndarray, angle: float) -> np.ndarray:
117
+ """Rotate cv_image by an exact angle in degrees (clockwise positive)."""
118
+ (h, w) = cv_image.shape[:2]
119
+ center = (w // 2, h // 2)
120
+ matrix = cv2.getRotationMatrix2D(center, -angle, 1.0)
121
+ cos, sin = abs(matrix[0, 0]), abs(matrix[0, 1])
122
+ new_w = int((h * sin) + (w * cos))
123
+ new_h = int((h * cos) + (w * sin))
124
+ matrix[0, 2] += (new_w / 2) - center[0]
125
+ matrix[1, 2] += (new_h / 2) - center[1]
126
+ return cv2.warpAffine(
127
+ cv_image, matrix, (new_w, new_h),
128
+ flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_CONSTANT,
129
+ borderValue=(255, 255, 255),
130
+ )
131
+
132
+
133
+ def _deskew_fine(cv_image: np.ndarray) -> tuple[np.ndarray, float, float]:
134
+ """Correct slight tilt via minAreaRect on text pixels.
135
+
136
+ Returns (corrected_image, applied_angle, source_std).
137
+ """
138
+ gray = cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY)
139
+ source_std = float(gray.std())
140
+
141
+ if source_std < config.DESKEW_MIN_STD:
142
+ log.debug(
143
+ f"Deskew: image std {source_std:.2f} < {config.DESKEW_MIN_STD} "
144
+ f"(near-uniform image, likely blank), Otsu skipped, no correction."
145
+ )
146
+ return cv_image, 0.0, source_std
147
+
148
+ thresh = cv2.threshold(
149
+ gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU
150
+ )[1]
151
+
152
+ coords = np.column_stack(np.where(thresh > 0))
153
+ if len(coords) < config.DESKEW_MIN_PIXELS:
154
+ log.debug(
155
+ f"Deskew: not enough signal ({len(coords)} pixels < "
156
+ f"{config.DESKEW_MIN_PIXELS}), image is probably empty."
157
+ )
158
+ return cv_image, 0.0, source_std
159
+
160
+ angle = cv2.minAreaRect(coords)[-1]
161
+ if angle < -45:
162
+ angle = -(90 + angle)
163
+ else:
164
+ angle = -angle
165
+
166
+ if abs(angle) > config.DESKEW_MAX_ANGLE:
167
+ log_warning(
168
+ log,
169
+ f"Deskew: angle {angle:.2f}° rejected (beyond "
170
+ f"{config.DESKEW_MAX_ANGLE}°), probably a minAreaRect artifact "
171
+ f"(page border detected instead of text lines). "
172
+ f"Tilt not corrected."
173
+ )
174
+ return cv_image, 0.0, source_std
175
+
176
+ if abs(angle) < config.DESKEW_MIN_ANGLE:
177
+ log.debug(f"Deskew: angle {angle:.2f}° < threshold, no correction applied")
178
+ return cv_image, 0.0, source_std
179
+
180
+ log.info(f"Deskew: correction of {angle:.2f}° applied")
181
+
182
+ (h, w) = cv_image.shape[:2]
183
+ center = (w // 2, h // 2)
184
+ matrix = cv2.getRotationMatrix2D(center, angle, 1.0)
185
+ cos, sin = abs(matrix[0, 0]), abs(matrix[0, 1])
186
+ new_w = int((h * sin) + (w * cos))
187
+ new_h = int((h * cos) + (w * sin))
188
+ matrix[0, 2] += (new_w / 2) - center[0]
189
+ matrix[1, 2] += (new_h / 2) - center[1]
190
+
191
+ return cv2.warpAffine(
192
+ cv_image, matrix, (new_w, new_h),
193
+ flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_CONSTANT,
194
+ borderValue=(255, 255, 255),
195
+ ), angle, source_std
196
+
197
+
198
+ def _remove_shadows(gray: np.ndarray) -> np.ndarray:
199
+ """Correct non-uniform illumination (cast shadows, light gradients).
200
+
201
+ Divides by a background estimate (Gaussian + morphological closing).
202
+ """
203
+ h, w = gray.shape[:2]
204
+
205
+ sigma = max(31.0, min(w, h) / 20.0)
206
+ bg_gauss = cv2.GaussianBlur(gray, (0, 0), sigmaX=sigma)
207
+
208
+ kernel_size = min(51, max(21, min(w, h) // 50))
209
+ if kernel_size % 2 == 0:
210
+ kernel_size += 1
211
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size))
212
+ bg_morph = cv2.morphologyEx(gray, cv2.MORPH_CLOSE, kernel)
213
+
214
+ background = np.maximum(bg_gauss, bg_morph)
215
+ background = np.where(background == 0, 1, background).astype(np.float32)
216
+ normalized = cv2.divide(
217
+ gray.astype(np.float32), background, scale=255.0
218
+ )
219
+ normalized = np.clip(normalized, 0, 255).astype(np.uint8)
220
+ return normalized
221
+
222
+
223
+ def _sharpen(gray: np.ndarray) -> np.ndarray:
224
+ """Light unsharp mask after denoising/CLAHE to recover edge definition."""
225
+ amount = config.UNSHARP_AMOUNT
226
+ if amount <= 0:
227
+ return gray
228
+ blurred = cv2.GaussianBlur(gray, (0, 0), sigmaX=config.UNSHARP_SIGMA)
229
+ return cv2.addWeighted(gray, 1.0 + amount, blurred, -amount, 0)
230
+
231
+
232
+ def _build_ocr_image(
233
+ cv_image: np.ndarray, dpi: int
234
+ ) -> tuple[np.ndarray, float, int, tuple]:
235
+ """Build the grayscale copy optimized for Tesseract.
236
+
237
+ Returns (ocr_image, scale, effective_dpi, warnings).
238
+ """
239
+ warnings_list: list[str] = []
240
+ gray = cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY)
241
+ orig_width = gray.shape[1]
242
+
243
+ scale = 1.0
244
+ if orig_width < config.OCR_UPSCALE_MIN_WIDTH:
245
+ scale = config.OCR_UPSCALE_MIN_WIDTH / orig_width
246
+ gray = cv2.resize(
247
+ gray, None, fx=scale, fy=scale,
248
+ interpolation=cv2.INTER_CUBIC,
249
+ )
250
+ log.debug(
251
+ f"Upscale x{scale:.2f}: {orig_width}px -> {gray.shape[1]}px "
252
+ f"(below {config.OCR_UPSCALE_MIN_WIDTH}px)"
253
+ )
254
+ elif orig_width > config.OCR_DOWNSCALE_MAX_WIDTH:
255
+ scale = config.OCR_DOWNSCALE_MAX_WIDTH / orig_width
256
+ gray = cv2.resize(
257
+ gray, None, fx=scale, fy=scale,
258
+ interpolation=cv2.INTER_AREA,
259
+ )
260
+ warnings_list.append(
261
+ f"Very wide image ({orig_width}px > {config.OCR_DOWNSCALE_MAX_WIDTH}px) "
262
+ f"- downscaling x{scale:.2f} to avoid excessive processing time."
263
+ )
264
+ log.debug(
265
+ f"Downscale x{scale:.2f}: {orig_width}px -> {gray.shape[1]}px"
266
+ )
267
+
268
+ gray = _remove_shadows(gray)
269
+
270
+ denoised = cv2.fastNlMeansDenoising(gray, h=config.DENOISING_H)
271
+
272
+ clahe = cv2.createCLAHE(
273
+ clipLimit=config.CLAHE_CLIP_LIMIT,
274
+ tileGridSize=config.CLAHE_TILE_GRID,
275
+ )
276
+ enhanced = clahe.apply(denoised)
277
+ enhanced = _sharpen(enhanced)
278
+
279
+ effective_dpi = max(72, int(dpi * scale))
280
+
281
+ return enhanced, scale, effective_dpi, tuple(warnings_list)
282
+
283
+
284
+ def preprocess(
285
+ image: Image.Image, dpi: int, orientation: "str | float | None" = None
286
+ ) -> PreprocessResult:
287
+ """Full preprocessing pipeline.
288
+
289
+ orientation: None=auto (EXIF+OSD+deskew), "none"=disable, or float degrees.
290
+ """
291
+ manual_rotation_applied = None
292
+ orientation_correction_skipped = False
293
+
294
+ with stage_timer(log, "Preprocessing"):
295
+ if orientation is not None and str(orientation).strip().lower() == "none":
296
+ log.info("Orientation: correction disabled (orientation='none')")
297
+ exif_orientation = 1
298
+ gross_rot = 0
299
+ orientation_correction_skipped = True
300
+ cv_image = _pil_to_cv(image)
301
+ deskew_angle = 0.0
302
+ source_std = float(
303
+ cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY).std()
304
+ )
305
+ elif orientation is not None:
306
+ angle = float(orientation)
307
+ log.info(f"Orientation: manual angle {angle}° applied (auto-detection skipped)")
308
+ exif_orientation = 1
309
+ gross_rot = 0
310
+ manual_rotation_applied = angle
311
+ cv_image = _pil_to_cv(image)
312
+ cv_image = _rotate_by_angle(cv_image, angle)
313
+ deskew_angle = 0.0
314
+ source_std = float(
315
+ cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY).std()
316
+ )
317
+ else:
318
+ image, exif_orientation = _apply_exif_orientation(image)
319
+ image, gross_rot = _fix_gross_orientation(image)
320
+ cv_image = _pil_to_cv(image)
321
+ cv_image, deskew_angle, source_std = _deskew_fine(cv_image)
322
+
323
+ likely_blank = source_std < config.DESKEW_MIN_STD
324
+ if likely_blank:
325
+ log.info(
326
+ f"Preprocessing: source std={source_std:.2f} < "
327
+ f"{config.DESKEW_MIN_STD}, page is likely blank, "
328
+ f"skipping OCR-image build (denoise/CLAHE/upscale)."
329
+ )
330
+ ocr_image = np.zeros((1, 1), dtype=np.uint8) # unused placeholder
331
+ scale = 1.0
332
+ effective_dpi = max(72, dpi)
333
+ warnings_t: tuple = ()
334
+ else:
335
+ ocr_image, scale, effective_dpi, warnings_t = _build_ocr_image(
336
+ cv_image, dpi
337
+ )
338
+
339
+ background = _cv_to_pil(cv_image)
340
+
341
+ ocr_image_desc = (
342
+ "skipped (likely blank)"
343
+ if likely_blank
344
+ else f"{ocr_image.shape[1]}x{ocr_image.shape[0]}px"
345
+ )
346
+ log.info(
347
+ f"Preprocessing complete: "
348
+ f"exif_orientation={exif_orientation}, gross_rotation={gross_rot}°, "
349
+ f"background={background.size[0]}x{background.size[1]}px, "
350
+ f"ocr_image={ocr_image_desc}, "
351
+ f"scale=x{scale:.2f}, effective_dpi={effective_dpi}"
352
+ )
353
+
354
+ return PreprocessResult(
355
+ background=background,
356
+ ocr_image=ocr_image,
357
+ ocr_scale=scale,
358
+ effective_dpi=effective_dpi,
359
+ exif_orientation_applied=exif_orientation,
360
+ gross_rotation_applied=gross_rot,
361
+ deskew_angle_applied=deskew_angle,
362
+ manual_rotation_applied=manual_rotation_applied,
363
+ orientation_correction_skipped=orientation_correction_skipped,
364
+ source_std=source_std,
365
+ likely_blank=likely_blank,
366
+ preprocessing_warnings=warnings_t,
367
+ )
@@ -0,0 +1,19 @@
1
+ """utils - shared logging, validation, and diagnostics."""
2
+
3
+ from scanlayer.utils.logger import (
4
+ get_logger,
5
+ log_error,
6
+ log_stage_start,
7
+ log_success,
8
+ log_warning,
9
+ stage_timer,
10
+ )
11
+
12
+ __all__ = [
13
+ "get_logger",
14
+ "stage_timer",
15
+ "log_success",
16
+ "log_warning",
17
+ "log_error",
18
+ "log_stage_start",
19
+ ]
@@ -0,0 +1,85 @@
1
+ """
2
+ Debug visualization for OCR results.
3
+
4
+ Draws word bounding boxes, confidence scores, and run metadata onto
5
+ a copy of the PDF background image. Used with --debug-image CLI flag.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ from PIL import Image, ImageDraw, ImageFont
13
+
14
+ _HIGH_CONF = 80.0
15
+ _MID_CONF = 50.0
16
+
17
+ _COLOR_HIGH = (0, 150, 0)
18
+ _COLOR_MID = (210, 140, 0)
19
+ _COLOR_LOW = (200, 0, 0)
20
+
21
+ _BUNDLED_FONT_PATH = os.path.join(
22
+ os.path.dirname(__file__), "..", "fonts", "DejaVuSans.ttf"
23
+ )
24
+
25
+
26
+ def _load_font(size: int) -> ImageFont.FreeTypeFont:
27
+ try:
28
+ return ImageFont.truetype(_BUNDLED_FONT_PATH, size)
29
+ except OSError:
30
+ return ImageFont.load_default()
31
+
32
+
33
+ def _color_for_confidence(confidence: float) -> tuple[int, int, int]:
34
+ if confidence >= _HIGH_CONF:
35
+ return _COLOR_HIGH
36
+ if confidence >= _MID_CONF:
37
+ return _COLOR_MID
38
+ return _COLOR_LOW
39
+
40
+
41
+ def build_debug_image(
42
+ background: Image.Image,
43
+ words: list,
44
+ best_psm: "int | None" = None,
45
+ mean_confidence: float = 0.0,
46
+ language_used: str = "",
47
+ exif_orientation: int = 1,
48
+ gross_rotation: int = 0,
49
+ rotation_note: str = "",
50
+ ) -> Image.Image:
51
+ """Returns a NEW PIL image: a copy of `background` with word boxes,
52
+ per-word confidence, and a run-summary header overlaid. Does not
53
+ modify `background` in place.
54
+ """
55
+ img = background.convert("RGB").copy()
56
+ draw = ImageDraw.Draw(img)
57
+
58
+ word_font = _load_font(max(10, int(img.width / 150)))
59
+ header_font = _load_font(max(14, int(img.width / 90)))
60
+
61
+ for w in words:
62
+ x0, y0 = w.x, w.y
63
+ x1, y1 = w.x + w.width, w.y + w.height
64
+ color = _color_for_confidence(w.confidence)
65
+ draw.rectangle([x0, y0, x1, y1], outline=color, width=2)
66
+ draw.text((x0, max(0, y0 - word_font.size - 2)),
67
+ f"{w.confidence:.0f}", fill=color, font=word_font)
68
+
69
+ header_lines = [
70
+ f"PSM {best_psm if best_psm is not None else '-'} | "
71
+ f"{len(words)} word(s) | mean confidence {mean_confidence:.1f}% | "
72
+ f"lang={language_used or '-'}",
73
+ f"EXIF orientation={exif_orientation} gross_rotation={gross_rotation} deg"
74
+ + (f" {rotation_note}" if rotation_note else ""),
75
+ ]
76
+ pad = 6
77
+ line_height = header_font.size + 6
78
+ box_height = pad * 2 + line_height * len(header_lines)
79
+ draw.rectangle([0, 0, img.width, box_height], fill=(255, 255, 255))
80
+ y = pad
81
+ for line in header_lines:
82
+ draw.text((pad, y), line, fill=(0, 0, 0), font=header_font)
83
+ y += line_height
84
+
85
+ return img
@@ -0,0 +1,42 @@
1
+ """
2
+ Runtime pipeline errors, distinct from `utils.validators.ValidationError`.
3
+
4
+ Validators catch problems BEFORE any processing starts (missing file, missing
5
+ Tesseract binary...). This module covers failures that happen DURING
6
+ processing, once every precondition was already confirmed valid: a page
7
+ that times out, a language pack that fails to load, a PDF write that dies
8
+ mid-way. These are not user mistakes and not pre-flight environment issues,
9
+ they're operational failures that still need to surface loudly instead of
10
+ being swallowed into a silent "0 words, exit 0" success.
11
+ """
12
+
13
+
14
+ class PipelineError(Exception):
15
+ """Base class: a processing stage started but failed."""
16
+
17
+
18
+ class OCRProcessingError(PipelineError):
19
+ """Every configured PSM candidate failed for a real reason (timeout,
20
+ Tesseract crash, corrupted language data), not just 'no text found'."""
21
+
22
+
23
+ class PDFBuildError(PipelineError):
24
+ """The PDF could not be written (disk full, permissions, corrupted
25
+ background image, reportlab failure)."""
26
+
27
+
28
+ class BlankPageDetectedError(PipelineError):
29
+ """The source image is near-uniform (very low grayscale contrast),
30
+ almost certainly a blank/white page rather than genuine content.
31
+
32
+ Raised BEFORE the expensive OCR pipeline runs (not after), since the
33
+ signal (image std) is available early in preprocessing and there is
34
+ no point running denoising/CLAHE/upscale/4-way Tesseract on a page
35
+ that is, with high confidence, blank.
36
+
37
+ Not raised as a silent "0 words" success: a blank page is often
38
+ unexpected (wrong file, failed scan, camera fired on nothing) and
39
+ deserves an explicit signal rather than a quietly empty PDF. Pass
40
+ `force=True` to `convert()` (or `--force` on the CLI) to generate
41
+ the PDF anyway, background image only, no fabricated text layer.
42
+ """
@@ -0,0 +1,100 @@
1
+ """
2
+ Structured logger and per-stage timer.
3
+
4
+ Provides logging configuration, a timing context manager, and uniform
5
+ message helpers. The internal `LOG_LEVEL` env var (set by the CLI's
6
+ `--verbose`/`--quiet`) overrides `config.LOG_LEVEL`, which itself comes
7
+ from the user-facing `SCANLAYER_LOG_LEVEL` env var.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ import time
15
+ from contextlib import contextmanager
16
+ from typing import Generator
17
+
18
+ from scanlayer import config
19
+
20
+ _LOG_FORMAT = "%(asctime)s | %(levelname)-7s | %(name)-18s | %(message)s"
21
+ _DATE_FORMAT = "%H:%M:%S"
22
+
23
+
24
+ _known_loggers: list[logging.Logger] = []
25
+
26
+
27
+ def _resolve_level() -> int:
28
+ """Return the effective log level.
29
+
30
+ Priority: CLI `LOG_LEVEL` env var > `config.LOG_LEVEL` (from
31
+ `SCANLAYER_LOG_LEVEL`) > INFO.
32
+ """
33
+ raw = os.environ.get("LOG_LEVEL", "").strip().upper()
34
+ if not raw:
35
+ raw = str(getattr(config, "LOG_LEVEL", "INFO")).upper()
36
+ return getattr(logging, raw, logging.INFO)
37
+
38
+
39
+ def get_logger(name: str) -> logging.Logger:
40
+ """Return a named logger with the configured log level.
41
+
42
+ Idempotent: multiple calls won't overwrite the handler setup.
43
+ """
44
+ logger = logging.getLogger(name)
45
+ if not logger.handlers:
46
+ handler = logging.StreamHandler()
47
+ handler.setFormatter(logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT))
48
+ logger.addHandler(handler)
49
+ logger.propagate = False # avoid duplicates via the root logger
50
+ _known_loggers.append(logger)
51
+ logger.setLevel(_resolve_level())
52
+ return logger
53
+
54
+
55
+ def set_log_level(level: str) -> None:
56
+ """Apply a log level to all loggers created via get_logger().
57
+
58
+ Needed because modules call get_logger(__name__) at import time,
59
+ so a runtime configure() call wouldn't reach them otherwise.
60
+ """
61
+ level_value = getattr(logging, str(level).upper(), None)
62
+ if level_value is None:
63
+ raise ValueError(f"Unknown log level: {level!r}")
64
+ for logger in _known_loggers:
65
+ logger.setLevel(level_value)
66
+
67
+
68
+ @contextmanager
69
+ def stage_timer(logger: logging.Logger, stage_name: str) -> Generator[None, None, None]:
70
+ """Context manager that logs the duration of a block.
71
+
72
+ Timing is only logged if config.LOG_TIMING is True or DEBUG is active.
73
+ """
74
+ enable = bool(getattr(config, "LOG_TIMING", True)) or logger.isEnabledFor(logging.DEBUG)
75
+ start = time.perf_counter() if enable else None
76
+ try:
77
+ yield
78
+ finally:
79
+ if enable:
80
+ elapsed_ms = (time.perf_counter() - start) * 1000
81
+ logger.info(f"{stage_name}: completed in {elapsed_ms:.0f} ms")
82
+
83
+
84
+ def log_success(logger: logging.Logger, msg: str) -> None:
85
+ logger.info(msg)
86
+
87
+
88
+ def log_warning(logger: logging.Logger, msg: str) -> None:
89
+ logger.warning(msg)
90
+
91
+
92
+ def log_error(logger: logging.Logger, msg: str, exc: Exception | None = None) -> None:
93
+ if exc is not None:
94
+ logger.error(f"{msg}: {type(exc).__name__}: {exc}")
95
+ else:
96
+ logger.error(msg)
97
+
98
+
99
+ def log_stage_start(logger: logging.Logger, msg: str) -> None:
100
+ logger.info(msg)