Coverage for core / src / sensorkit / common / logging.py: 71%
65 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-02 00:03 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-02 00:03 +0000
1# SPDX-License-Identifier: Apache-2.0
2from __future__ import annotations
4import functools
5import os
6import pathlib
7import sys
8import time
9from datetime import datetime
10from typing import TYPE_CHECKING, Any, Callable, Self, cast
12from loguru import logger
14if TYPE_CHECKING:
15 from loguru import Logger
16else:
17 Logger = Any
19DEFAULT_DEBUG_FILE = "sensorkit.log"
20DEFAULT_LIMIT_INTERVAL = 5.0
23def _format_time(dt: datetime):
24 return dt.strftime(f"[%m/%d/%y %H:%M:%S.{dt.microsecond // 1000}]")
27def configure_logging(
28 *,
29 level: str | None = None,
30 format: str | Callable | None = None,
31 force_color: bool = True,
32):
33 if level is None:
34 level = "DEBUG" if os.environ.get("SENSORKIT_DEBUG") else "INFO"
36 if force_color:
37 os.environ["FORCE_COLOR"] = "1"
39 if format is None:
40 format = (
41 "<green>[{time:YYYY-MM-DD HH:mm:ss.SSS}]</green> "
42 "<level>{level: <8}</level> "
43 "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> "
44 "<level>{message}</level>"
45 )
47 logger.remove()
48 logger.add(
49 sys.stderr,
50 format=format,
51 level=level,
52 colorize=True,
53 diagnose=False,
54 backtrace=False,
55 )
58def add_debug_logger(
59 *,
60 file: str | None = None,
61 syslog_id: str = "sensorkit",
62 append: bool = True,
63 backtrace: bool = True,
64 diagnose: bool = True,
65) -> str:
66 if file is None:
67 try:
68 from systemd.journal import JournalHandler
70 logger.add(
71 JournalHandler(SYSLOG_IDENTIFIER=syslog_id),
72 level="DEBUG",
73 colorize=False,
74 enqueue=True,
75 backtrace=backtrace,
76 diagnose=diagnose,
77 )
78 return f"system log -- run: `journalctl -t {syslog_id} -f` to watch"
79 except ImportError:
80 # systemd not available, fall back to default.
81 pass
83 path = pathlib.Path(file or DEFAULT_DEBUG_FILE)
84 logger.add(
85 path,
86 mode="a" if append else "w",
87 level="DEBUG",
88 colorize=False,
89 enqueue=True,
90 backtrace=True,
91 diagnose=True,
92 )
93 return str(path.absolute())
96class NullLogger:
97 """Stand-in for a loguru logger that discards everything sent to it.
99 Any attribute resolves to a call accepting anything and returning the same
100 object, so chained forms such as `opt(exception=e).warning(...)` stay valid.
101 """
103 def __getattr__(self, name: str) -> Callable[..., Self]:
104 return self.discard
106 def discard(self, *args: Any, **kwargs: Any) -> Self:
107 return self
110class RateLimiter:
111 __slots__ = ("deadline",)
113 def __init__(self):
114 self.deadline = float("-inf")
116 def allow(self, interval: float) -> bool:
117 now = time.monotonic()
119 if now < self.deadline:
120 return False
122 self.deadline = now + interval
123 return True
126NULL_LOGGER = cast(Logger, cast(object, NullLogger()))
129@functools.lru_cache(maxsize=1024)
130def _logger_limiter(_: str) -> RateLimiter:
131 return RateLimiter()
134def limited_logger(subject: str | None = None, *, interval: float = DEFAULT_LIMIT_INTERVAL) -> Logger:
135 """Get a logger that emits at most once per interval for the calling site.
137 The limit applies to the call site rather than to message content, so while a
138 site is limited, every call made through the returned logger is discarded. The
139 first call always emits and opens the next window.
141 The result *must* used immediately, as in `limited_logger().info(...)`.
142 Acquiring it is what consumes the window, so holding one in a variable and
143 reusing it defeats the limit. For the same reason, wrapping this function in a
144 helper keys every one of that helper's callers to the helper's own line.
146 Args:
147 subject: Optional qualifier dividing one call site into independent limits.
148 Sites are keyed by file and line, so equal subjects arising at different
149 call sites never share a window.
150 interval: Minimum seconds between emissions. It is read on every call, so a
151 site can be limited at different rates as conditions change.
153 Returns:
154 The loguru logger when the call site is outside its window, otherwise a
155 stand-in that discards everything sent to it.
156 """
157 import inspect
159 frame = inspect.currentframe()
160 caller = frame.f_back if frame is not None else None
162 if caller is None:
163 # No frame support, so there is no call site to key on. Emit unlimited
164 # rather than share one window across every unrelated call site.
165 return logger
167 key = f"{caller.f_code.co_filename}:{caller.f_lineno}"
169 if subject is not None:
170 key = f"{key}:{subject}"
172 if _logger_limiter(key).allow(interval):
173 return logger
175 return NULL_LOGGER