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 +47 -0
- cells/log/config.py +293 -0
- cells/log/context.py +67 -0
- cells/log/env.py +131 -0
- cells/log/formatters.py +157 -0
- cells/log/handlers.py +279 -0
- cells/log/levels.py +37 -0
- cells/log/logger.py +140 -0
- cells/log/manager.py +577 -0
- cells/log/timings.py +208 -0
- cells/log/version.py +1 -0
- cells_log-0.2.2.dist-info/METADATA +331 -0
- cells_log-0.2.2.dist-info/RECORD +15 -0
- cells_log-0.2.2.dist-info/WHEEL +5 -0
- cells_log-0.2.2.dist-info/top_level.txt +1 -0
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
|