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.
- {codex_core-0.3.0 → codex_core-0.4.0}/PKG-INFO +1 -1
- {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/common/__init__.py +4 -1
- {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/common/log_context.py +46 -12
- codex_core-0.4.0/src/codex_core/common/loguru_setup.py +307 -0
- codex_core-0.4.0/tests/unit/common/test_log_context.py +98 -0
- codex_core-0.4.0/tests/unit/common/test_loguru_setup.py +221 -0
- codex_core-0.3.0/src/codex_core/common/loguru_setup.py +0 -343
- codex_core-0.3.0/tests/unit/common/test_log_context.py +0 -53
- codex_core-0.3.0/tests/unit/common/test_loguru_setup.py +0 -140
- {codex_core-0.3.0 → codex_core-0.4.0}/.github/workflows/ci.yml +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/.github/workflows/docs.yml +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/.github/workflows/publish.yml +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/.gitignore +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/.pre-commit-config.yaml +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/.python-version +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/CHANGELOG.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/README.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/changelog.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/README.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/api/common.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/api/core.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/api/dev/check_runner.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/api/dev/index.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/api/dev/project_tree.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/api/dev/static_compiler.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/api/index.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/api/settings.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/architecture/README.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/architecture/platform/common.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/architecture/platform/core.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/architecture/platform/dev.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/architecture/platform/settings.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/en/tasks/getting_started.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/evolution/roadmap.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/index.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/planning/python_version_policy.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/ru/README.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/ru/architecture/README.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/ru/architecture/platform/common.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/ru/architecture/platform/core.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/ru/architecture/platform/dev.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/ru/architecture/platform/settings.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/ru/tasks/getting_started.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/docs/stylesheets/extra.css +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/mkdocs.yml +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/project_structure.txt +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/pyproject.toml +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/__init__.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/common/phone.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/common/text.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/core/__init__.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/core/base_dto.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/core/exceptions.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/core/pii.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/dev/__init__.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/dev/check_runner.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/dev/project_tree.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/dev/static_compiler/__init__.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/dev/static_compiler/compiler.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/dev/static_compiler/css.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/dev/static_compiler/js.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/py.typed +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/settings/__init__.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/src/codex_core/settings/base.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tests/conftest.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tests/integration/__init__.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tests/integration/conftest.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tests/integration/test_settings_integration.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/__init__.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/common/__init__.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/common/test_phone.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/common/test_text.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/conftest.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/core/__init__.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/core/test_exceptions.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/core/test_pii.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/dev/__init__.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/dev/test_check_runner.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/dev/test_static_compiler.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/settings/__init__.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tests/unit/settings/test_settings.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tools/__init__.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tools/dev/README.md +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tools/dev/__init__.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tools/dev/check.py +0 -0
- {codex_core-0.3.0 → codex_core-0.4.0}/tools/dev/generate_project_tree.py +0 -0
- {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
|
+
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
|
|
1
|
+
"""Structured logging context: per-request contextvars and per-task wrappers.
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
+
)
|