codex-core 0.3.0__tar.gz → 0.4.0__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.
Files changed (87) hide show
  1. {codex_core-0.3.0 → codex_core-0.4.0}/PKG-INFO +1 -1
  2. {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/common/__init__.py +4 -1
  3. {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/common/log_context.py +46 -12
  4. codex_core-0.4.0/src/codex_core/common/loguru_setup.py +307 -0
  5. codex_core-0.4.0/tests/unit/common/test_log_context.py +98 -0
  6. codex_core-0.4.0/tests/unit/common/test_loguru_setup.py +221 -0
  7. codex_core-0.3.0/src/codex_core/common/loguru_setup.py +0 -343
  8. codex_core-0.3.0/tests/unit/common/test_log_context.py +0 -53
  9. codex_core-0.3.0/tests/unit/common/test_loguru_setup.py +0 -140
  10. {codex_core-0.3.0 → codex_core-0.4.0}/.github/workflows/ci.yml +0 -0
  11. {codex_core-0.3.0 → codex_core-0.4.0}/.github/workflows/docs.yml +0 -0
  12. {codex_core-0.3.0 → codex_core-0.4.0}/.github/workflows/publish.yml +0 -0
  13. {codex_core-0.3.0 → codex_core-0.4.0}/.gitignore +0 -0
  14. {codex_core-0.3.0 → codex_core-0.4.0}/.pre-commit-config.yaml +0 -0
  15. {codex_core-0.3.0 → codex_core-0.4.0}/.python-version +0 -0
  16. {codex_core-0.3.0 → codex_core-0.4.0}/CHANGELOG.md +0 -0
  17. {codex_core-0.3.0 → codex_core-0.4.0}/README.md +0 -0
  18. {codex_core-0.3.0 → codex_core-0.4.0}/docs/changelog.md +0 -0
  19. {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/README.md +0 -0
  20. {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/api/common.md +0 -0
  21. {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/api/core.md +0 -0
  22. {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/api/dev/check_runner.md +0 -0
  23. {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/api/dev/index.md +0 -0
  24. {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/api/dev/project_tree.md +0 -0
  25. {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/api/dev/static_compiler.md +0 -0
  26. {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/api/index.md +0 -0
  27. {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/api/settings.md +0 -0
  28. {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/architecture/README.md +0 -0
  29. {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/architecture/platform/common.md +0 -0
  30. {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/architecture/platform/core.md +0 -0
  31. {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/architecture/platform/dev.md +0 -0
  32. {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/architecture/platform/settings.md +0 -0
  33. {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/tasks/getting_started.md +0 -0
  34. {codex_core-0.3.0 → codex_core-0.4.0}/docs/evolution/roadmap.md +0 -0
  35. {codex_core-0.3.0 → codex_core-0.4.0}/docs/index.md +0 -0
  36. {codex_core-0.3.0 → codex_core-0.4.0}/docs/planning/python_version_policy.md +0 -0
  37. {codex_core-0.3.0 → codex_core-0.4.0}/docs/ru/README.md +0 -0
  38. {codex_core-0.3.0 → codex_core-0.4.0}/docs/ru/architecture/README.md +0 -0
  39. {codex_core-0.3.0 → codex_core-0.4.0}/docs/ru/architecture/platform/common.md +0 -0
  40. {codex_core-0.3.0 → codex_core-0.4.0}/docs/ru/architecture/platform/core.md +0 -0
  41. {codex_core-0.3.0 → codex_core-0.4.0}/docs/ru/architecture/platform/dev.md +0 -0
  42. {codex_core-0.3.0 → codex_core-0.4.0}/docs/ru/architecture/platform/settings.md +0 -0
  43. {codex_core-0.3.0 → codex_core-0.4.0}/docs/ru/tasks/getting_started.md +0 -0
  44. {codex_core-0.3.0 → codex_core-0.4.0}/docs/stylesheets/extra.css +0 -0
  45. {codex_core-0.3.0 → codex_core-0.4.0}/mkdocs.yml +0 -0
  46. {codex_core-0.3.0 → codex_core-0.4.0}/project_structure.txt +0 -0
  47. {codex_core-0.3.0 → codex_core-0.4.0}/pyproject.toml +0 -0
  48. {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/__init__.py +0 -0
  49. {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/common/phone.py +0 -0
  50. {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/common/text.py +0 -0
  51. {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/core/__init__.py +0 -0
  52. {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/core/base_dto.py +0 -0
  53. {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/core/exceptions.py +0 -0
  54. {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/core/pii.py +0 -0
  55. {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/dev/__init__.py +0 -0
  56. {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/dev/check_runner.py +0 -0
  57. {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/dev/project_tree.py +0 -0
  58. {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/dev/static_compiler/__init__.py +0 -0
  59. {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/dev/static_compiler/compiler.py +0 -0
  60. {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/dev/static_compiler/css.py +0 -0
  61. {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/dev/static_compiler/js.py +0 -0
  62. {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/py.typed +0 -0
  63. {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/settings/__init__.py +0 -0
  64. {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/settings/base.py +0 -0
  65. {codex_core-0.3.0 → codex_core-0.4.0}/tests/conftest.py +0 -0
  66. {codex_core-0.3.0 → codex_core-0.4.0}/tests/integration/__init__.py +0 -0
  67. {codex_core-0.3.0 → codex_core-0.4.0}/tests/integration/conftest.py +0 -0
  68. {codex_core-0.3.0 → codex_core-0.4.0}/tests/integration/test_settings_integration.py +0 -0
  69. {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/__init__.py +0 -0
  70. {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/common/__init__.py +0 -0
  71. {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/common/test_phone.py +0 -0
  72. {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/common/test_text.py +0 -0
  73. {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/conftest.py +0 -0
  74. {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/core/__init__.py +0 -0
  75. {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/core/test_exceptions.py +0 -0
  76. {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/core/test_pii.py +0 -0
  77. {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/dev/__init__.py +0 -0
  78. {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/dev/test_check_runner.py +0 -0
  79. {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/dev/test_static_compiler.py +0 -0
  80. {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/settings/__init__.py +0 -0
  81. {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/settings/test_settings.py +0 -0
  82. {codex_core-0.3.0 → codex_core-0.4.0}/tools/__init__.py +0 -0
  83. {codex_core-0.3.0 → codex_core-0.4.0}/tools/dev/README.md +0 -0
  84. {codex_core-0.3.0 → codex_core-0.4.0}/tools/dev/__init__.py +0 -0
  85. {codex_core-0.3.0 → codex_core-0.4.0}/tools/dev/check.py +0 -0
  86. {codex_core-0.3.0 → codex_core-0.4.0}/tools/dev/generate_project_tree.py +0 -0
  87. {codex_core-0.3.0 → codex_core-0.4.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-core
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Core utilities, schemas and settings for Codex WaaS toolkit
5
5
  Project-URL: Homepage, https://github.com/codexdlc/codex-core
6
6
  Project-URL: Documentation, https://codexdlc.github.io/codex-core/
@@ -1,6 +1,6 @@
1
1
  """Common utilities: logging, caching, phone normalization, text processing."""
2
2
 
3
- from .log_context import TaskLogContext
3
+ from .log_context import TaskLogContext, clear_log_context, get_log_context, set_log_context
4
4
  from .loguru_setup import (
5
5
  InterceptHandler,
6
6
  LoggingSettingsProtocol,
@@ -12,6 +12,9 @@ from .text import clean_string, normalize_name, sanitize_for_sms, transliterate
12
12
 
13
13
  __all__ = [
14
14
  "TaskLogContext",
15
+ "clear_log_context",
16
+ "get_log_context",
17
+ "set_log_context",
15
18
  "InterceptHandler",
16
19
  "LoggingSettingsProtocol",
17
20
  "setup_logging",
@@ -1,29 +1,63 @@
1
- """Structured logging context bound to a named task or worker.
1
+ """Structured logging context: per-request contextvars and per-task wrappers.
2
2
 
3
- Provides :class:`TaskLogContext` — a thin, stateful wrapper around
4
- ``logging.Logger`` that automatically enriches every log record with
5
- a fixed set of structured fields (task name, worker name, arbitrary
6
- extras).
3
+ Two complementary mechanisms:
7
4
 
8
- This module intentionally avoids any dependency on Loguru so that it
9
- works with any log sink: standard-library ``logging``, Loguru (via
10
- :class:`~codex_core.common.loguru_setup.InterceptHandler`), JSON
11
- formatters, ELK, or Loki.
5
+ 1. **contextvars-based context** — ``set_log_context`` / ``clear_log_context`` /
6
+ ``get_log_context``. Designed for async request/task scoping: set fields once
7
+ at the start of a request or worker task, and every ``logger.*()`` call in that
8
+ async context automatically includes them via the Loguru patcher configured in
9
+ :func:`~codex_core.common.loguru_setup.setup_logging`.
12
10
 
13
- Example:
11
+ 2. **TaskLogContext** — a stateful wrapper around ``logging.Logger`` for code that
12
+ uses the standard library directly (see class docstring for details).
13
+
14
+ Example (contextvars):
15
+ ```python
16
+ from codex_core.common.log_context import set_log_context, clear_log_context
17
+
18
+ set_log_context(request_id="abc-123", char_id=42)
19
+ logger.info("CombatMoveSubmitted") # extra contains request_id, char_id
20
+ clear_log_context()
21
+ ```
22
+
23
+ Example (TaskLogContext):
14
24
  ```python
15
25
  from codex_core.common.log_context import TaskLogContext
16
26
 
17
27
  log = TaskLogContext("send_booking_notification", worker="notification_worker")
18
28
  log.info("Processing appointment", extra={"appointment_id": 123})
19
- # LogRecord contains: task="send_booking_notification",
20
- # worker="notification_worker", appointment_id=123
21
29
  ```
22
30
  """
23
31
 
32
+ import contextvars
24
33
  import logging
25
34
  from typing import Any
26
35
 
36
+ # ---------------------------------------------------------------------------
37
+ # Async-safe contextvars log context
38
+ # ---------------------------------------------------------------------------
39
+
40
+ _log_context: contextvars.ContextVar[dict[str, Any]] = contextvars.ContextVar(
41
+ "log_context", default={}
42
+ )
43
+
44
+
45
+ def set_log_context(**kwargs: Any) -> None:
46
+ """Merge *kwargs* into the current async-local log context."""
47
+ current = _log_context.get().copy()
48
+ current.update(kwargs)
49
+ _log_context.set(current)
50
+
51
+
52
+ def clear_log_context() -> None:
53
+ """Reset the async-local log context to empty."""
54
+ _log_context.set({})
55
+
56
+
57
+ def get_log_context() -> dict[str, Any]:
58
+ """Return a shallow copy of the current async-local log context."""
59
+ return _log_context.get().copy()
60
+
27
61
 
28
62
  class TaskLogContext:
29
63
  """Structured logging adapter that binds context fields to every record.
@@ -0,0 +1,307 @@
1
+ """Application-level Loguru configuration helpers (optional dependency).
2
+
3
+ Provides opinionated, zero-boilerplate Loguru setup for codex_tools
4
+ applications. The codex_core *library* itself never calls these
5
+ helpers; it uses the standard ``logging`` module exclusively so that
6
+ consumers retain full control over their log infrastructure.
7
+
8
+ **Dev mode** (``debug=True``): three sinks — colourised stdout, rotating
9
+ plain-text ``debug.log``, and JSON ``errors.json``.
10
+
11
+ **Prod mode** (``debug=False``): a single JSON-serialised stdout sink
12
+ designed for ingestion by Grafana Alloy / Loki / ELK. File sinks are
13
+ omitted because container runtimes capture stdout natively.
14
+
15
+ Both modes inject a **patcher** that enriches every log record with:
16
+
17
+ - ``service`` — the service name passed to the setup function.
18
+ - Any fields set via :func:`~codex_core.common.log_context.set_log_context`
19
+ (request_id, char_id, correlation_id, etc.).
20
+
21
+ A built-in **healthcheck filter** suppresses records whose ``extra``
22
+ contains ``healthcheck=True``, preventing ``/health`` endpoint noise
23
+ from reaching log aggregators.
24
+
25
+ Standard-library ``logging`` records are bridged via
26
+ :class:`InterceptHandler` so that third-party libraries (SQLAlchemy,
27
+ httpx, aiogram, etc.) are automatically captured by Loguru.
28
+
29
+ Availability:
30
+ ``loguru`` is an **optional** dependency. Both :func:`setup_logging`
31
+ and :func:`setup_universal_logging` raise :exc:`ImportError` with an
32
+ actionable message when ``loguru`` is not installed.
33
+ """
34
+
35
+ import logging
36
+ import sys
37
+ from pathlib import Path
38
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
39
+
40
+ from .log_context import get_log_context
41
+
42
+ if TYPE_CHECKING:
43
+ from types import FrameType
44
+
45
+ from loguru import Logger
46
+
47
+ logger: "Logger | None"
48
+ try:
49
+ from loguru import logger as _loguru_logger
50
+ except ImportError:
51
+ logger = None
52
+ else:
53
+ logger = _loguru_logger
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Helpers
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ def _make_context_patcher(service_name: str) -> Any:
62
+ """Return a Loguru patcher that injects service name and contextvars."""
63
+
64
+ def _patcher(record: dict[str, Any]) -> None:
65
+ record["extra"]["service"] = service_name
66
+ record["extra"].update(get_log_context())
67
+
68
+ return _patcher
69
+
70
+
71
+ def _healthcheck_filter(record: dict[str, Any]) -> bool:
72
+ """Suppress log records tagged with ``healthcheck=True``."""
73
+ return not record["extra"].get("healthcheck", False)
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # InterceptHandler
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ class InterceptHandler(logging.Handler):
82
+ """Bridge standard-library ``logging`` records to the Loguru sink.
83
+
84
+ Install this handler on the root logger (or any named logger) to
85
+ forward all ``logging``-based records into Loguru transparently.
86
+ The handler resolves the correct call-stack depth so that Loguru
87
+ reports the *original* call site rather than the handler frame.
88
+
89
+ This class is stateless and thread-safe; a single instance may be
90
+ shared across all intercepted loggers.
91
+
92
+ Example:
93
+ ```python
94
+ import logging
95
+ from codex_core.common.loguru_setup import InterceptHandler
96
+
97
+ logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
98
+ ```
99
+ """
100
+
101
+ def emit(self, record: logging.LogRecord) -> None:
102
+ if logger is None:
103
+ return
104
+
105
+ level: str | int
106
+ try:
107
+ level = logger.level(record.levelname).name
108
+ except ValueError:
109
+ level = record.levelno
110
+
111
+ frame: FrameType | None = logging.currentframe()
112
+ depth = 6
113
+ while frame and frame.f_code.co_filename == logging.__file__:
114
+ frame = frame.f_back
115
+ depth += 1
116
+
117
+ logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # setup_universal_logging (raw-args variant)
122
+ # ---------------------------------------------------------------------------
123
+
124
+ _CONSOLE_FORMAT = (
125
+ "<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
126
+ "<level>{level: <8}</level> | "
127
+ "<magenta>{extra[service]}</magenta> | "
128
+ "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
129
+ "<level>{message}</level>"
130
+ )
131
+
132
+ _FILE_FORMAT = "{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}"
133
+
134
+
135
+ def setup_universal_logging(
136
+ log_dir: Path,
137
+ service_name: str = "App",
138
+ console_level: str = "INFO",
139
+ file_level: str = "DEBUG",
140
+ rotation: str = "10 MB",
141
+ is_debug: bool = False,
142
+ ) -> None:
143
+ """Configure Loguru with three sinks and standard-library interception.
144
+
145
+ Intended for applications that do not use
146
+ :class:`~codex_core.settings.BaseCommonSettings` but still want the
147
+ full codex_core logging stack. Callers supply raw configuration
148
+ values directly rather than a settings object.
149
+
150
+ Args:
151
+ log_dir: Directory where log files are created.
152
+ service_name: Label embedded in the console format string.
153
+ console_level: Minimum level for stdout output.
154
+ file_level: Minimum level for the debug log file.
155
+ rotation: Loguru rotation threshold string.
156
+ is_debug: When ``True``, enables ``backtrace`` and ``diagnose``.
157
+
158
+ Raises:
159
+ ImportError: If ``loguru`` is not installed.
160
+ """
161
+ if logger is None:
162
+ raise ImportError("loguru is not installed. Please install it manually: pip install loguru")
163
+
164
+ logger.remove()
165
+ log_dir.mkdir(parents=True, exist_ok=True)
166
+
167
+ logger.configure(patcher=_make_context_patcher(service_name))
168
+
169
+ logger.add(
170
+ sink=sys.stdout,
171
+ level=console_level,
172
+ colorize=True,
173
+ filter=_healthcheck_filter,
174
+ format=_CONSOLE_FORMAT,
175
+ )
176
+
177
+ logger.add(
178
+ sink=str(log_dir / "debug.log"),
179
+ level=file_level,
180
+ rotation=rotation,
181
+ compression="zip",
182
+ format=_FILE_FORMAT,
183
+ enqueue=True,
184
+ backtrace=is_debug,
185
+ diagnose=is_debug,
186
+ )
187
+
188
+ logger.add(
189
+ sink=str(log_dir / "errors.json"),
190
+ level="ERROR",
191
+ serialize=True,
192
+ rotation=rotation,
193
+ compression="zip",
194
+ enqueue=True,
195
+ )
196
+
197
+ logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
198
+
199
+ logger.info("LoguruSetupComplete log_dir={}", log_dir)
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # LoggingSettingsProtocol
204
+ # ---------------------------------------------------------------------------
205
+
206
+
207
+ @runtime_checkable
208
+ class LoggingSettingsProtocol(Protocol):
209
+ """Structural protocol for the logging-related subset of settings."""
210
+
211
+ log_level_console: str
212
+ log_level_file: str
213
+ log_rotation: str
214
+ log_dir: str
215
+ debug: bool
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # setup_logging (settings-based variant)
220
+ # ---------------------------------------------------------------------------
221
+
222
+
223
+ def setup_logging(
224
+ settings: LoggingSettingsProtocol,
225
+ service_name: str,
226
+ intercept_loggers: list[str] | None = None,
227
+ log_levels: dict[str, int] | None = None,
228
+ ) -> None:
229
+ """Configure Loguru from a settings object.
230
+
231
+ **Dev** (``settings.debug is True``): colourised console + file sinks.
232
+ **Prod** (``settings.debug is False``): JSON-serialised stdout only.
233
+
234
+ Both modes apply a patcher (service name + contextvars) and a
235
+ healthcheck filter.
236
+
237
+ Args:
238
+ settings: Object satisfying :class:`LoggingSettingsProtocol`.
239
+ service_name: Identifies the service in log output.
240
+ intercept_loggers: Logger names whose handlers are replaced
241
+ with :class:`InterceptHandler`.
242
+ log_levels: ``{logger_name: level_int}`` for silencing noisy
243
+ libraries.
244
+
245
+ Raises:
246
+ ImportError: If ``loguru`` is not installed.
247
+ """
248
+ if logger is None:
249
+ raise ImportError("loguru is not installed. Please install it manually: pip install loguru")
250
+
251
+ logger.remove()
252
+
253
+ logger.configure(patcher=_make_context_patcher(service_name))
254
+
255
+ if settings.debug:
256
+ # ── Dev: colourised console + file sinks ──
257
+ log_dir = Path(settings.log_dir) / service_name
258
+ log_dir.mkdir(parents=True, exist_ok=True)
259
+
260
+ logger.add(
261
+ sink=sys.stdout,
262
+ level=settings.log_level_console,
263
+ colorize=True,
264
+ filter=_healthcheck_filter,
265
+ format=_CONSOLE_FORMAT,
266
+ )
267
+
268
+ logger.add(
269
+ sink=str(log_dir / "debug.log"),
270
+ level=settings.log_level_file,
271
+ rotation=settings.log_rotation,
272
+ compression="zip",
273
+ format=_FILE_FORMAT,
274
+ enqueue=True,
275
+ backtrace=True,
276
+ diagnose=True,
277
+ )
278
+
279
+ logger.add(
280
+ sink=str(log_dir / "errors.json"),
281
+ level="ERROR",
282
+ serialize=True,
283
+ rotation=settings.log_rotation,
284
+ compression="zip",
285
+ enqueue=True,
286
+ )
287
+ else:
288
+ # ── Prod: JSON stdout only (Alloy / Loki / ELK ingestion) ──
289
+ logger.add(
290
+ sink=sys.stdout,
291
+ level=settings.log_level_console,
292
+ serialize=True,
293
+ colorize=False,
294
+ filter=_healthcheck_filter,
295
+ enqueue=True,
296
+ )
297
+
298
+ # Intercept standard logging
299
+ logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
300
+
301
+ if intercept_loggers:
302
+ for name in intercept_loggers:
303
+ logging.getLogger(name).handlers = [InterceptHandler()]
304
+
305
+ if log_levels:
306
+ for name, level in log_levels.items():
307
+ logging.getLogger(name).setLevel(level)
@@ -0,0 +1,98 @@
1
+ from unittest.mock import MagicMock, patch
2
+
3
+ import pytest
4
+
5
+ from codex_core.common.log_context import (
6
+ TaskLogContext,
7
+ clear_log_context,
8
+ get_log_context,
9
+ set_log_context,
10
+ )
11
+
12
+ pytestmark = pytest.mark.unit
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # contextvars functions
17
+ # ---------------------------------------------------------------------------
18
+
19
+
20
+ class TestContextVars:
21
+ def setup_method(self):
22
+ clear_log_context()
23
+
24
+ def teardown_method(self):
25
+ clear_log_context()
26
+
27
+ def test_empty_by_default(self):
28
+ assert get_log_context() == {}
29
+
30
+ def test_set_and_get(self):
31
+ set_log_context(request_id="abc-123", char_id=42)
32
+ ctx = get_log_context()
33
+ assert ctx == {"request_id": "abc-123", "char_id": 42}
34
+
35
+ def test_set_merges_incrementally(self):
36
+ set_log_context(request_id="abc")
37
+ set_log_context(char_id=42)
38
+ assert get_log_context() == {"request_id": "abc", "char_id": 42}
39
+
40
+ def test_set_overwrites_existing_key(self):
41
+ set_log_context(request_id="old")
42
+ set_log_context(request_id="new")
43
+ assert get_log_context() == {"request_id": "new"}
44
+
45
+ def test_clear_resets_to_empty(self):
46
+ set_log_context(request_id="abc", char_id=42)
47
+ clear_log_context()
48
+ assert get_log_context() == {}
49
+
50
+ def test_get_returns_copy(self):
51
+ set_log_context(request_id="abc")
52
+ ctx = get_log_context()
53
+ ctx["injected"] = True
54
+ assert "injected" not in get_log_context()
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # TaskLogContext (existing tests preserved)
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ def test_task_log_context_initialization():
63
+ ctx = TaskLogContext("my_task", worker="worker_1")
64
+ assert ctx._base_extra == {"task": "my_task", "worker": "worker_1"}
65
+ assert ctx._logger.name == "my_task"
66
+
67
+ ctx_with_custom_logger = TaskLogContext("my_task", logger_name="custom.logger", worker="worker_2")
68
+ assert ctx_with_custom_logger._base_extra == {"task": "my_task", "worker": "worker_2"}
69
+ assert ctx_with_custom_logger._logger.name == "custom.logger"
70
+
71
+
72
+ @patch("logging.getLogger")
73
+ def test_task_log_context_methods(mock_get_logger):
74
+ mock_logger = MagicMock()
75
+ mock_get_logger.return_value = mock_logger
76
+
77
+ ctx = TaskLogContext("test_task", worker="test_worker")
78
+
79
+ ctx.debug("debug message", extra={"key": "val"})
80
+ mock_logger.debug.assert_called_once_with(
81
+ "debug message", extra={"task": "test_task", "worker": "test_worker", "key": "val"}
82
+ )
83
+
84
+ ctx.info("info message")
85
+ mock_logger.info.assert_called_once_with("info message", extra={"task": "test_task", "worker": "test_worker"})
86
+
87
+ ctx.warning("warning message")
88
+ mock_logger.warning.assert_called_once_with("warning message", extra={"task": "test_task", "worker": "test_worker"})
89
+
90
+ ctx.error("error message", exc_info=True)
91
+ mock_logger.error.assert_called_once_with(
92
+ "error message", extra={"task": "test_task", "worker": "test_worker"}, exc_info=True
93
+ )
94
+
95
+ ctx.exception("exception message")
96
+ mock_logger.exception.assert_called_once_with(
97
+ "exception message", extra={"task": "test_task", "worker": "test_worker"}
98
+ )