cells-log 0.2.2__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.
cells/log/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """cells-log public API."""
2
+
3
+ from .config import FieldProcessor, JsonDumps, LogConfig, config_from_env
4
+ from .context import (
5
+ bind_log_context,
6
+ get_log_context,
7
+ get_trace_id,
8
+ reset_log_context,
9
+ reset_trace_id,
10
+ set_trace_id,
11
+ )
12
+ from .levels import TRACE_LEVEL_NUM, parse_log_level
13
+ from .logger import BoundLogger
14
+ from .manager import (
15
+ LogStage,
16
+ bootstrap_logging,
17
+ configure_logging,
18
+ configure_logging_from_env,
19
+ get_logger,
20
+ get_queue_stats,
21
+ shutdown_logging,
22
+ )
23
+ from .timings import TimingCollector
24
+
25
+ __all__ = [
26
+ "BoundLogger",
27
+ "FieldProcessor",
28
+ "JsonDumps",
29
+ "LogConfig",
30
+ "LogStage",
31
+ "TRACE_LEVEL_NUM",
32
+ "TimingCollector",
33
+ "bind_log_context",
34
+ "bootstrap_logging",
35
+ "config_from_env",
36
+ "configure_logging",
37
+ "configure_logging_from_env",
38
+ "get_log_context",
39
+ "get_logger",
40
+ "get_queue_stats",
41
+ "get_trace_id",
42
+ "parse_log_level",
43
+ "reset_log_context",
44
+ "reset_trace_id",
45
+ "set_trace_id",
46
+ "shutdown_logging",
47
+ ]
cells/log/config.py ADDED
@@ -0,0 +1,293 @@
1
+ """Configuration model for cells-log."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as std_json
6
+ import os
7
+ from collections.abc import Callable, Mapping
8
+ from dataclasses import dataclass, replace
9
+ from pathlib import Path
10
+ from typing import Any, Literal
11
+
12
+ from cells.log.env import (
13
+ env_bool,
14
+ env_int,
15
+ env_key,
16
+ env_path,
17
+ env_size,
18
+ warn_config_fallback,
19
+ )
20
+
21
+ LogFormat = Literal["json", "text"]
22
+ RotationMode = Literal["daily", "size"]
23
+ QueueFullPolicy = Literal["drop", "block", "sync_fallback"]
24
+ # Transform merged structured fields without processing the message or exception text.
25
+ FieldProcessor = Callable[[dict[str, Any]], Mapping[str, Any] | dict[str, Any]]
26
+ # Return str; accept either the ensure_ascii keyword or just the object to serialize.
27
+ JsonDumps = Callable[..., str]
28
+
29
+
30
+ def _default_json_dumps(obj: Any, **kwargs: Any) -> str:
31
+ """Default JSON dumper with safe fallback for non-primitive objects."""
32
+ kwargs.setdefault("ensure_ascii", False)
33
+ kwargs.setdefault("default", str)
34
+ return std_json.dumps(obj, **kwargs)
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class LogConfig:
39
+ """Configure output, file rotation, queueing and context extensions.
40
+
41
+ Defaults apply to explicit production configuration: console JSON with an
42
+ in-process queue, no files, no root ownership and no warnings capture.
43
+ Bootstrap always disables files and the queue. Callers map application
44
+ settings to this object; changing it does not reconfigure active handlers.
45
+ Call configure_logging() again to apply changes.
46
+
47
+ Fields merge in context < bind < call order; later values win. Field hooks
48
+ receive shallow copies and may run repeatedly per record, once per sink,
49
+ in the listener or caller thread. JSON hooks must return str and handle
50
+ non-JSON objects. They receive ensure_ascii=False, with one retry without
51
+ that keyword on TypeError; they also encode text output's field suffixes.
52
+ """
53
+
54
+ # Options: <str>. "app" (default) is the fallback logger name and aggregate filename prefix.
55
+ app_name: str = "app"
56
+ # Options: <"TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "NOTSET", int>.
57
+ # "INFO" (default); WARN/FATAL aliases accepted. Unknown names warn and use INFO.
58
+ level: str | int = "INFO"
59
+ # Options: <"json", "text">. "json" (default) for single-line JSON;
60
+ # "text" for human-readable output. Applies to console and files.
61
+ format: LogFormat = "json"
62
+ # Options: <Path, str, None>. None (default); required for any file output, even
63
+ # with an absolute total_file. Expands ~; the manager creates the directory.
64
+ log_dir: Path | str | None = None
65
+
66
+ # Options: <True, False>. True (default) enables console output; False disables it.
67
+ # Independent of module and aggregate file output.
68
+ enable_console: bool = True
69
+ # Options: <True, False>. True (default) writes to stderr; False writes to stdout.
70
+ # Configuration diagnostics always use stderr.
71
+ console_to_stderr: bool = True
72
+ # Options: <True, False>. False (default) hides console fields; True includes them.
73
+ # Files always include fields; simplified text errors omit them on the console.
74
+ console_include_fields: bool = False
75
+ # Options: <True, False>. False (default) hides console tracebacks; True includes them.
76
+ # Files retain available exceptions; simplified text errors omit console tracebacks.
77
+ console_include_exception: bool = False
78
+ # Options: <True, False>. True (default) shows only a summary and "details in log file"
79
+ # for console text errors >= ERROR when files are enabled; False keeps normal detail.
80
+ simplify_console_errors: bool = True
81
+
82
+ # Options: <True, False>. False (default) disables module files; True routes by logger name.
83
+ # Each file is created lazily on its first record; bootstrap always disables this option.
84
+ enable_module_file: bool = False
85
+ # Options: <str>. "modules" (default) is the module file subdirectory under log_dir.
86
+ # An absolute path selects a directory independently of log_dir.
87
+ module_dir_name: str = "modules"
88
+ # Options: <int >= 1>. 128 (default) cached module handlers; new paths beyond the limit
89
+ # use overflow.log, adding at most one handler without evicting existing handlers.
90
+ max_module_handlers: int = 128
91
+ # Options: <tuple[str, ...]>. () (default) accepts all names; otherwise str.startswith
92
+ # matches prefixes, without package boundaries. Unmatched names use external.log.
93
+ module_name_prefixes: tuple[str, ...] = ()
94
+
95
+ # Options: <True, False>. False (default) disables the aggregate file; True writes all records.
96
+ # Independent of module files; bootstrap always disables this option.
97
+ enable_total_file: bool = False
98
+ # Options: <Path, str, None>. None (default) selects log_dir/<app_name>.log;
99
+ # relative paths use log_dir, absolute paths stand alone. Expands ~.
100
+ total_file: Path | str | None = None
101
+
102
+ # Options: <"daily", "size">. "daily" (default) rotates on the first write after local
103
+ # midnight; "size" uses size_limit. timestamp_utc does not change the rotation timezone.
104
+ rotation: RotationMode = "daily"
105
+ # Options: <int >= 0>. 14 (default) backups per file, not days. With 0, daily keeps all
106
+ # backups; size creates none and the active file may keep growing. Zero does not delete logs.
107
+ retention: int = 14
108
+ # Options: <int bytes, str bytes/B/KB/MB/GB>. "100MB" (default) = 104857600 bytes.
109
+ # Size rotation only; units use powers of 1024. A per-file threshold, not a total disk quota.
110
+ size_limit: str | int = "100MB"
111
+
112
+ # Options: <True, False>. True (default) uses a process-local listener; False writes in caller.
113
+ # No crash delivery guarantee; bootstrap always disables the queue.
114
+ use_queue: bool = True
115
+ # Options: <int >= 1>. 10_000 (default) queued records, excluding in-flight writes.
116
+ # Applies only with use_queue=True and active output sinks.
117
+ queue_capacity: int = 10_000
118
+ # Options: <"drop", "block", "sync_fallback">. "drop" (default) discards and counts on full;
119
+ # "block" waits or drops on timeout; "sync_fallback" writes in caller, risking delay/reordering.
120
+ queue_full_policy: QueueFullPolicy = "drop"
121
+ # Options: <float >= 0>. 0.1 (default) seconds to wait for space under "block", then drop/count.
122
+ # Zero does not wait; this is not a file I/O, handover or shutdown timeout.
123
+ queue_block_timeout: float = 0.1
124
+ # Options: <True, False>. False (default) stops propagation; True also uses parent loggers,
125
+ # potentially duplicating output. manage_root_logger=True forces False on owned loggers.
126
+ propagate: bool = False
127
+ # Options: <True, False>. False (default) leaves warnings unmanaged; True captures them as logs.
128
+ # Capture stays active across reconfiguration; disabling or shutdown restores prior state.
129
+ capture_warnings: bool = False
130
+ # Options: <True, False>. False (default) leaves root unmanaged; True captures records at root.
131
+ # Records stopped upstream are excluded; disabling or shutdown restores prior handlers/state.
132
+ manage_root_logger: bool = False
133
+ # Options: <True, False>. True (default) emits ISO timestamps with UTC timezone;
134
+ # False emits local ISO timestamps without a timezone offset.
135
+ timestamp_utc: bool = True
136
+
137
+ # Options: <str>. "system" (default) is the fallback for an empty or unset context trace ID.
138
+ # Updated only after configuration activation succeeds.
139
+ default_trace_id: str = "system"
140
+ # Options: <None, callable>. None (default) keeps merged fields; callable returns new fields.
141
+ # On failure, retain original fields and add _field_processor_error. See hook rules above.
142
+ field_processor: FieldProcessor | None = None
143
+ # Options: <callable>. _default_json_dumps (default) uses ensure_ascii=False and default=str.
144
+ # Custom encoders must return str; see hook rules above for invocation and retry behavior.
145
+ json_dumps: JsonDumps = _default_json_dumps
146
+
147
+ def normalized(self) -> LogConfig:
148
+ """Return a normalized copy without creating directories or activating logging.
149
+
150
+ Expand ~ in paths, lowercase format/rotation and enforce numeric minima.
151
+ Unknown format, rotation and queue_full_policy warn and fall back to
152
+ json, size and drop respectively. Nonconvertible numbers still raise
153
+ TypeError/ValueError. Level, size and directory validation is delegated
154
+ to the level parser, file handler and manager respectively.
155
+ """
156
+ log_dir = Path(self.log_dir).expanduser() if self.log_dir is not None else None
157
+ total_file = Path(self.total_file).expanduser() if self.total_file is not None else None
158
+ rotation: RotationMode = "daily" if str(self.rotation).lower() == "daily" else "size"
159
+ fmt: LogFormat = "text" if str(self.format).lower() == "text" else "json"
160
+ queue_full_policy: QueueFullPolicy = (
161
+ self.queue_full_policy
162
+ if self.queue_full_policy in {"drop", "block", "sync_fallback"}
163
+ else "drop"
164
+ )
165
+ normalized = replace(
166
+ self,
167
+ format=fmt,
168
+ log_dir=log_dir,
169
+ total_file=total_file,
170
+ rotation=rotation,
171
+ retention=max(0, int(self.retention)),
172
+ max_module_handlers=max(1, int(self.max_module_handlers)),
173
+ queue_capacity=max(1, int(self.queue_capacity)),
174
+ queue_full_policy=queue_full_policy,
175
+ queue_block_timeout=max(0.0, float(self.queue_block_timeout)),
176
+ )
177
+ for name, valid, allowed, risk in (
178
+ (
179
+ "format",
180
+ str(self.format).lower() in {"json", "text"},
181
+ "json, text",
182
+ "log output may not match the expected parser",
183
+ ),
184
+ (
185
+ "rotation",
186
+ str(self.rotation).lower() in {"daily", "size"},
187
+ "daily, size",
188
+ "rotation may not follow the intended policy",
189
+ ),
190
+ (
191
+ "queue_full_policy",
192
+ self.queue_full_policy in {"drop", "block", "sync_fallback"},
193
+ "drop, block, sync_fallback",
194
+ "records may be discarded when the queue is full",
195
+ ),
196
+ (
197
+ "retention",
198
+ int(self.retention) >= 0,
199
+ "an integer >= 0",
200
+ "disk usage may grow without the intended retention limit",
201
+ ),
202
+ (
203
+ "max_module_handlers",
204
+ int(self.max_module_handlers) >= 1,
205
+ "an integer >= 1",
206
+ "more logger names may be routed to the overflow file",
207
+ ),
208
+ (
209
+ "queue_capacity",
210
+ int(self.queue_capacity) >= 1,
211
+ "an integer >= 1",
212
+ "the queue may fill more often and trigger its full policy",
213
+ ),
214
+ (
215
+ "queue_block_timeout",
216
+ float(self.queue_block_timeout) >= 0,
217
+ "a number >= 0 (seconds)",
218
+ "queue insertion may time out immediately when full",
219
+ ),
220
+ ):
221
+ if not valid:
222
+ warn_config_fallback(
223
+ name,
224
+ getattr(self, name),
225
+ getattr(normalized, name),
226
+ allowed=allowed,
227
+ risk=risk,
228
+ )
229
+ return normalized
230
+
231
+
232
+ def config_from_env(
233
+ *,
234
+ app_name: str = "app",
235
+ default_log_dir: Path | str | None = None,
236
+ env_prefix: str = "CELLS_LOG",
237
+ base: LogConfig | None = None,
238
+ ) -> LogConfig:
239
+ """Return normalized environment overrides on base without activating logging.
240
+
241
+ Args:
242
+ app_name: Application name overriding base.app_name unless empty.
243
+ default_log_dir: Directory fallback taking precedence over base.log_dir.
244
+ env_prefix: Variable prefix; generated names are uppercased.
245
+ base: Baseline config; fields without environment overrides are retained.
246
+
247
+ Returns:
248
+ A normalized copy with applicable environment overrides.
249
+
250
+ Environment names use <PREFIX>_<NAME>. Supported names are LEVEL, FORMAT,
251
+ DIR, FILE, ENABLE_CONSOLE, ENABLE_MODULE_FILE, ENABLE_TOTAL_FILE, ROTATION,
252
+ RETENTION, SIZE_LIMIT, USE_QUEUE, QUEUE_CAPACITY, QUEUE_FULL_POLICY,
253
+ CAPTURE_WARNINGS and MANAGE_ROOT_LOGGER. Set other fields through base or
254
+ LogConfig. Booleans accept 1/true/yes/y/on and 0/false/no/n/off regardless of
255
+ case. Invalid booleans or integers warn and retain baseline values; empty
256
+ DIR/FILE retain path fallbacks. LEVEL is parsed by name, so numeric strings
257
+ are not converted to numeric levels. Pass custom integers via LogConfig.level.
258
+ """
259
+ cfg = base or LogConfig(app_name=app_name, log_dir=default_log_dir)
260
+
261
+ log_dir = env_path(
262
+ env_key(env_prefix, "DIR"),
263
+ Path(default_log_dir).expanduser() if default_log_dir else cfg.log_dir,
264
+ )
265
+ total_file = env_path(env_key(env_prefix, "FILE"), cfg.total_file)
266
+
267
+ return replace(
268
+ cfg,
269
+ app_name=app_name or cfg.app_name,
270
+ level=os.getenv(env_key(env_prefix, "LEVEL"), str(cfg.level)),
271
+ format=os.getenv(env_key(env_prefix, "FORMAT"), str(cfg.format)).lower(), # type: ignore[arg-type]
272
+ log_dir=log_dir,
273
+ total_file=total_file,
274
+ enable_console=env_bool(env_key(env_prefix, "ENABLE_CONSOLE"), cfg.enable_console),
275
+ enable_module_file=env_bool(
276
+ env_key(env_prefix, "ENABLE_MODULE_FILE"), cfg.enable_module_file
277
+ ),
278
+ enable_total_file=env_bool(env_key(env_prefix, "ENABLE_TOTAL_FILE"), cfg.enable_total_file),
279
+ rotation=os.getenv(env_key(env_prefix, "ROTATION"), str(cfg.rotation)).lower(), # type: ignore[arg-type]
280
+ retention=env_int(env_key(env_prefix, "RETENTION"), cfg.retention),
281
+ size_limit=env_size(env_key(env_prefix, "SIZE_LIMIT"), cfg.size_limit),
282
+ use_queue=env_bool(env_key(env_prefix, "USE_QUEUE"), cfg.use_queue),
283
+ queue_capacity=env_int(env_key(env_prefix, "QUEUE_CAPACITY"), cfg.queue_capacity),
284
+ queue_full_policy=os.getenv(
285
+ env_key(env_prefix, "QUEUE_FULL_POLICY"),
286
+ cfg.queue_full_policy,
287
+ ).lower(), # type: ignore[arg-type]
288
+ capture_warnings=env_bool(env_key(env_prefix, "CAPTURE_WARNINGS"), cfg.capture_warnings),
289
+ manage_root_logger=env_bool(
290
+ env_key(env_prefix, "MANAGE_ROOT_LOGGER"),
291
+ cfg.manage_root_logger,
292
+ ),
293
+ ).normalized()
cells/log/context.py ADDED
@@ -0,0 +1,67 @@
1
+ """Context propagation for structured logs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextvars import ContextVar, Token
6
+ from typing import Any
7
+
8
+ _trace_id_var: ContextVar[str | None] = ContextVar("cells_log_trace_id", default=None)
9
+ _context_fields_var: ContextVar[dict[str, Any] | None] = ContextVar(
10
+ "cells_log_context_fields",
11
+ default=None,
12
+ )
13
+ _default_trace_id = "system"
14
+
15
+
16
+ def set_default_trace_id(default_trace_id: str) -> None:
17
+ """Set the process-wide fallback used when a context has no trace id."""
18
+ global _default_trace_id
19
+ _default_trace_id = default_trace_id
20
+
21
+
22
+ def get_default_trace_id() -> str:
23
+ """Return the process fallback independently of the calling context."""
24
+ return _default_trace_id
25
+
26
+
27
+ def get_trace_id() -> str:
28
+ """Return the context's nonempty trace ID or the last activated process fallback."""
29
+ return _trace_id_var.get() or _default_trace_id
30
+
31
+
32
+ def set_trace_id(trace_id: str) -> Token[str | None]:
33
+ """Set the current context's trace ID and return a token to restore its old value.
34
+
35
+ Call reset_trace_id(token) in finally when the request or task ends; reset
36
+ nested assignments in reverse order. ContextVar isolates async task assignments;
37
+ callers manage propagation across threads. An empty string makes get_trace_id()
38
+ use the configured fallback.
39
+ """
40
+ return _trace_id_var.set(trace_id)
41
+
42
+
43
+ def reset_trace_id(token: Token[str | None]) -> None:
44
+ """Restore the prior trace ID using a token from this context exactly once."""
45
+ _trace_id_var.reset(token)
46
+
47
+
48
+ def bind_log_context(**fields: Any) -> Token[dict[str, Any] | None]:
49
+ """Merge request or task fields and return a reset token; new values win.
50
+
51
+ Restore with reset_log_context(token) in finally. Only the field dictionary
52
+ is copied; nested objects remain shared. Fields from logger.bind() and the
53
+ individual log call take precedence over these context fields.
54
+ """
55
+ current = dict(_context_fields_var.get() or {})
56
+ current.update(fields)
57
+ return _context_fields_var.set(current)
58
+
59
+
60
+ def reset_log_context(token: Token[dict[str, Any] | None]) -> None:
61
+ """Restore prior fields using the bind_log_context token once in its original context."""
62
+ _context_fields_var.reset(token)
63
+
64
+
65
+ def get_log_context() -> dict[str, Any]:
66
+ """Return a shallow copy whose key changes do not affect the context dictionary."""
67
+ return dict(_context_fields_var.get() or {})
cells/log/env.py ADDED
@@ -0,0 +1,131 @@
1
+ """Environment parsing utilities for cells-log."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ _TRUE_VALUES = {"1", "true", "yes", "y", "on"}
11
+ _FALSE_VALUES = {"0", "false", "no", "n", "off"}
12
+
13
+
14
+ def warn_config_fallback(name: str, value: Any, fallback: Any, *, allowed: str, risk: str) -> None:
15
+ """Report invalid configuration risks, accepted values and the actual fallback."""
16
+ try:
17
+ sys.stderr.write(
18
+ f"cells-log: WARNING {name}={value!r} is invalid; "
19
+ f"the effective value is {fallback!r}, so {risk}. "
20
+ f"Valid values: {allowed}.\n"
21
+ )
22
+ except Exception:
23
+ pass # A diagnostic failure must not prevent the configured fallback.
24
+
25
+
26
+ def env_key(prefix: str, key: str) -> str:
27
+ """Build an uppercase environment variable name such as CELLS_LOG_QUEUE_CAPACITY."""
28
+ return f"{prefix}_{key}".upper()
29
+
30
+
31
+ def env_bool(name: str, default: bool) -> bool:
32
+ """Read a boolean, using the default when unset and warning on invalid values."""
33
+ raw = os.getenv(name)
34
+ if raw is None:
35
+ return default
36
+
37
+ value = raw.strip().lower()
38
+ if value in _TRUE_VALUES:
39
+ return True
40
+ if value in _FALSE_VALUES:
41
+ return False
42
+ warn_config_fallback(
43
+ name,
44
+ raw,
45
+ default,
46
+ allowed="true: 1, true, yes, y, on; false: 0, false, no, n, off (case-insensitive)",
47
+ risk="the feature state may differ from the intended setting",
48
+ )
49
+ return default
50
+
51
+
52
+ def env_int(name: str, default: int) -> int:
53
+ """Read an integer, warning and using the default if invalid; do not check ranges."""
54
+ raw = os.getenv(name)
55
+ if raw is None:
56
+ return default
57
+ try:
58
+ return int(raw.strip())
59
+ except (TypeError, ValueError): # fmt: skip
60
+ warn_config_fallback(
61
+ name,
62
+ raw,
63
+ default,
64
+ allowed="an integer",
65
+ risk="the numeric setting may differ from the intended limit",
66
+ )
67
+ return default
68
+
69
+
70
+ def env_path(name: str, default: Path | None = None) -> Path | None:
71
+ """Expand ~ in a nonblank path or retain the default, without creating directories."""
72
+ raw = os.getenv(name)
73
+ if raw is None or not raw.strip():
74
+ return default
75
+ return Path(raw.strip()).expanduser()
76
+
77
+
78
+ def parse_size_limit(raw_value: str | int) -> int:
79
+ """Parse integer bytes or B/KB/MB/GB strings using powers of 1024.
80
+
81
+ Ignore case and surrounding whitespace. Clamp the numeric part to at least 1;
82
+ fractions are unsupported. For example, 0MB becomes 1MB. Unparseable strings
83
+ warn and fall back to 100MB.
84
+ """
85
+ if isinstance(raw_value, int):
86
+ return max(1, raw_value)
87
+
88
+ value = str(raw_value).strip().upper()
89
+ units = [
90
+ ("GB", 1024 * 1024 * 1024),
91
+ ("MB", 1024 * 1024),
92
+ ("KB", 1024),
93
+ ("B", 1),
94
+ ]
95
+
96
+ for unit, multiplier in units:
97
+ if value.endswith(unit):
98
+ number = value[: -len(unit)].strip()
99
+ try:
100
+ return max(1, int(number)) * multiplier
101
+ except ValueError:
102
+ warn_config_fallback(
103
+ "size_limit",
104
+ raw_value,
105
+ "100MB",
106
+ allowed="an integer byte count or an integer followed by B, KB, MB, GB",
107
+ risk="files may rotate at an unintended size threshold",
108
+ )
109
+ return 100 * 1024 * 1024
110
+
111
+ try:
112
+ return max(1, int(value))
113
+ except ValueError:
114
+ warn_config_fallback(
115
+ "size_limit",
116
+ raw_value,
117
+ "100MB",
118
+ allowed="an integer byte count or an integer followed by B, KB, MB, GB",
119
+ risk="files may rotate at an unintended size threshold",
120
+ )
121
+ return 100 * 1024 * 1024
122
+
123
+
124
+ def env_size(name: str, default: str | int) -> str | int:
125
+ """Read the raw size string; parsing is deferred until a size file handler is created."""
126
+ return os.getenv(name, str(default))
127
+
128
+
129
+ def coalesce(value: Any, fallback: Any) -> Any:
130
+ """Replace only None with the fallback, preserving False, zero and empty strings."""
131
+ return fallback if value is None else value