codex-core 0.1.0__tar.gz → 0.1.1__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 (66) hide show
  1. {codex_core-0.1.0 → codex_core-0.1.1}/.gitignore +3 -0
  2. {codex_core-0.1.0 → codex_core-0.1.1}/CHANGELOG.md +13 -0
  3. {codex_core-0.1.0 → codex_core-0.1.1}/PKG-INFO +12 -2
  4. codex_core-0.1.1/README.md +38 -0
  5. {codex_core-0.1.0 → codex_core-0.1.1}/pyproject.toml +1 -0
  6. codex_core-0.1.1/src/codex_core/common/log_context.py +140 -0
  7. codex_core-0.1.1/src/codex_core/common/loguru_setup.py +345 -0
  8. codex_core-0.1.1/src/codex_core/common/phone.py +75 -0
  9. codex_core-0.1.1/src/codex_core/common/text.py +233 -0
  10. codex_core-0.1.1/src/codex_core/core/base_dto.py +95 -0
  11. codex_core-0.1.1/src/codex_core/core/exceptions.py +50 -0
  12. codex_core-0.1.1/src/codex_core/core/pii.py +146 -0
  13. codex_core-0.1.1/src/codex_core/settings/base.py +150 -0
  14. codex_core-0.1.0/README.md +0 -30
  15. codex_core-0.1.0/src/codex_core/common/log_context.py +0 -55
  16. codex_core-0.1.0/src/codex_core/common/loguru_setup.py +0 -192
  17. codex_core-0.1.0/src/codex_core/common/phone.py +0 -40
  18. codex_core-0.1.0/src/codex_core/common/text.py +0 -139
  19. codex_core-0.1.0/src/codex_core/core/base_dto.py +0 -33
  20. codex_core-0.1.0/src/codex_core/core/exceptions.py +0 -7
  21. codex_core-0.1.0/src/codex_core/core/pii.py +0 -44
  22. codex_core-0.1.0/src/codex_core/settings/base.py +0 -82
  23. {codex_core-0.1.0 → codex_core-0.1.1}/.github/workflows/ci.yml +0 -0
  24. {codex_core-0.1.0 → codex_core-0.1.1}/.github/workflows/docs.yml +0 -0
  25. {codex_core-0.1.0 → codex_core-0.1.1}/.github/workflows/publish.yml +0 -0
  26. {codex_core-0.1.0 → codex_core-0.1.1}/.pre-commit-config.yaml +0 -0
  27. {codex_core-0.1.0 → codex_core-0.1.1}/docs/api/common.md +0 -0
  28. {codex_core-0.1.0 → codex_core-0.1.1}/docs/api/core.md +0 -0
  29. {codex_core-0.1.0 → codex_core-0.1.1}/docs/api/index.md +0 -0
  30. {codex_core-0.1.0 → codex_core-0.1.1}/docs/api/settings.md +0 -0
  31. {codex_core-0.1.0 → codex_core-0.1.1}/docs/changelog.md +0 -0
  32. {codex_core-0.1.0 → codex_core-0.1.1}/docs/en_EN/README.md +0 -0
  33. {codex_core-0.1.0 → codex_core-0.1.1}/docs/en_EN/architecture/README.md +0 -0
  34. {codex_core-0.1.0 → codex_core-0.1.1}/docs/en_EN/architecture/platform/common.md +0 -0
  35. {codex_core-0.1.0 → codex_core-0.1.1}/docs/en_EN/architecture/platform/core.md +0 -0
  36. {codex_core-0.1.0 → codex_core-0.1.1}/docs/en_EN/architecture/platform/settings.md +0 -0
  37. {codex_core-0.1.0 → codex_core-0.1.1}/docs/en_EN/guide/getting_started.md +0 -0
  38. {codex_core-0.1.0 → codex_core-0.1.1}/docs/evolution/roadmap.md +0 -0
  39. {codex_core-0.1.0 → codex_core-0.1.1}/docs/index.md +0 -0
  40. {codex_core-0.1.0 → codex_core-0.1.1}/docs/ru_RU/README.md +0 -0
  41. {codex_core-0.1.0 → codex_core-0.1.1}/docs/ru_RU/architecture/README.md +0 -0
  42. {codex_core-0.1.0 → codex_core-0.1.1}/docs/ru_RU/architecture/platform/common.md +0 -0
  43. {codex_core-0.1.0 → codex_core-0.1.1}/docs/ru_RU/architecture/platform/core.md +0 -0
  44. {codex_core-0.1.0 → codex_core-0.1.1}/docs/ru_RU/architecture/platform/settings.md +0 -0
  45. {codex_core-0.1.0 → codex_core-0.1.1}/docs/ru_RU/guide/getting_started.md +0 -0
  46. {codex_core-0.1.0 → codex_core-0.1.1}/mkdocs.yml +0 -0
  47. {codex_core-0.1.0 → codex_core-0.1.1}/src/codex_core/__init__.py +0 -0
  48. {codex_core-0.1.0 → codex_core-0.1.1}/src/codex_core/common/__init__.py +0 -0
  49. {codex_core-0.1.0 → codex_core-0.1.1}/src/codex_core/core/__init__.py +0 -0
  50. {codex_core-0.1.0 → codex_core-0.1.1}/src/codex_core/settings/__init__.py +0 -0
  51. {codex_core-0.1.0 → codex_core-0.1.1}/tests/conftest.py +0 -0
  52. {codex_core-0.1.0 → codex_core-0.1.1}/tests/integration/__init__.py +0 -0
  53. {codex_core-0.1.0 → codex_core-0.1.1}/tests/integration/test_settings_integration.py +0 -0
  54. {codex_core-0.1.0 → codex_core-0.1.1}/tests/unit/__init__.py +0 -0
  55. {codex_core-0.1.0 → codex_core-0.1.1}/tests/unit/common/__init__.py +0 -0
  56. {codex_core-0.1.0 → codex_core-0.1.1}/tests/unit/common/test_phone.py +0 -0
  57. {codex_core-0.1.0 → codex_core-0.1.1}/tests/unit/common/test_text.py +0 -0
  58. {codex_core-0.1.0 → codex_core-0.1.1}/tests/unit/core/__init__.py +0 -0
  59. {codex_core-0.1.0 → codex_core-0.1.1}/tests/unit/core/test_pii.py +0 -0
  60. {codex_core-0.1.0 → codex_core-0.1.1}/tests/unit/settings/__init__.py +0 -0
  61. {codex_core-0.1.0 → codex_core-0.1.1}/tests/unit/settings/test_settings.py +0 -0
  62. {codex_core-0.1.0 → codex_core-0.1.1}/tools/__init__.py +0 -0
  63. {codex_core-0.1.0 → codex_core-0.1.1}/tools/dev/README.md +0 -0
  64. {codex_core-0.1.0 → codex_core-0.1.1}/tools/dev/__init__.py +0 -0
  65. {codex_core-0.1.0 → codex_core-0.1.1}/tools/dev/check.py +0 -0
  66. {codex_core-0.1.0 → codex_core-0.1.1}/tools/dev/generate_project_tree.py +0 -0
@@ -194,6 +194,9 @@ gen/
194
194
  .cursorignore
195
195
  .cursorindexingignore
196
196
 
197
+ # Claude Code
198
+ .claude/
199
+
197
200
  # Marimo
198
201
  marimo/_static/
199
202
  marimo/_lsp/
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.1] - 2025-02-12
9
+
10
+ ### Added
11
+ - **Declarative PII Registry**: Introduced `PIIRegistry` class in `pii.py` for explicit sensitive field name tracking, complementing the heuristic keyword-based matching.
12
+ - **Enhanced Documentation**:
13
+ - Comprehensive Google-style docstrings added to all core modules: `log_context`, `loguru_setup`, `phone`, `text`, `base_dto`, `exceptions`, and `settings/base`.
14
+ - Added localized (EN/RU) documentation links and PyPI/License badges to `README.md`.
15
+ - **Dependency Management**: Added `loguru` as an explicit optional dependency in `pyproject.toml`.
16
+
17
+ ### Changed
18
+ - **PII Masking Logic**: Refined `is_pii_field` and `mask_value` to prioritize explicit registry matches over heuristic keyword search.
19
+ - **Project Configuration**: Updated `.gitignore` to include `.claude/` for modern AI tool support.
20
+
8
21
  ## [0.1.0] - 2024-05-24
9
22
 
10
23
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-core
3
- Version: 0.1.0
3
+ Version: 0.1.1
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/
@@ -35,14 +35,24 @@ Requires-Dist: mkdocs-include-markdown-plugin; extra == 'docs'
35
35
  Requires-Dist: mkdocs-material>=9.0; extra == 'docs'
36
36
  Requires-Dist: mkdocs>=1.5; extra == 'docs'
37
37
  Requires-Dist: mkdocstrings[python]>=0.24; extra == 'docs'
38
+ Provides-Extra: loguru
39
+ Requires-Dist: loguru>=0.7.0; extra == 'loguru'
38
40
  Description-Content-Type: text/markdown
39
41
 
40
42
  # codex-core
41
43
 
42
44
  **Core utilities, schemas, and settings for the Codex WaaS toolkit.**
43
45
 
46
+ [![PyPI](https://img.shields.io/pypi/v/codex-core)](https://pypi.org/project/codex-core/)
47
+ [![Python](https://img.shields.io/pypi/pyversions/codex-core)](https://pypi.org/project/codex-core/)
48
+ [![License](https://img.shields.io/badge/license-Apache--2.0-green)](https://github.com/codexdlc/codex-core/blob/main/LICENSE)
49
+ [![Documentation](https://img.shields.io/badge/docs-codexdlc.github.io-blue)](https://codexdlc.github.io/codex-core/)
50
+
44
51
  This library provides the foundational building blocks used by all other Codex tools. It focuses on Pydantic-based data models, structured logging, and configuration management.
45
52
 
53
+ > **Documentation:**
54
+ > [EN](https://codexdlc.github.io/codex-core/en_EN/) · [RU](https://codexdlc.github.io/codex-core/ru_RU/) · [API Reference](https://codexdlc.github.io/codex-core/api/) · [Changelog](https://codexdlc.github.io/codex-core/changelog/)
55
+
46
56
  ## 🚀 Key Features
47
57
 
48
58
  * **Core Interfaces**: Base classes and protocols for Codex components.
@@ -66,4 +76,4 @@ logger.info("Codex Core is ready!")
66
76
  ```
67
77
 
68
78
  ---
69
- *Part of the [Codex WaaS](https://github.com/codexdlc) ecosystem.*
79
+ *Part of the [Codex WaaS](https://github.com/codexdlc) ecosystem. · [EN Docs](https://codexdlc.github.io/codex-core/en_EN/) · [RU Docs](https://codexdlc.github.io/codex-core/ru_RU/) · [API](https://codexdlc.github.io/codex-core/api/) · [Changelog](https://codexdlc.github.io/codex-core/changelog/) · [Source](https://github.com/codexdlc/codex-core)*
@@ -0,0 +1,38 @@
1
+ # codex-core
2
+
3
+ **Core utilities, schemas, and settings for the Codex WaaS toolkit.**
4
+
5
+ [![PyPI](https://img.shields.io/pypi/v/codex-core)](https://pypi.org/project/codex-core/)
6
+ [![Python](https://img.shields.io/pypi/pyversions/codex-core)](https://pypi.org/project/codex-core/)
7
+ [![License](https://img.shields.io/badge/license-Apache--2.0-green)](https://github.com/codexdlc/codex-core/blob/main/LICENSE)
8
+ [![Documentation](https://img.shields.io/badge/docs-codexdlc.github.io-blue)](https://codexdlc.github.io/codex-core/)
9
+
10
+ This library provides the foundational building blocks used by all other Codex tools. It focuses on Pydantic-based data models, structured logging, and configuration management.
11
+
12
+ > **Documentation:**
13
+ > [EN](https://codexdlc.github.io/codex-core/en_EN/) · [RU](https://codexdlc.github.io/codex-core/ru_RU/) · [API Reference](https://codexdlc.github.io/codex-core/api/) · [Changelog](https://codexdlc.github.io/codex-core/changelog/)
14
+
15
+ ## 🚀 Key Features
16
+
17
+ * **Core Interfaces**: Base classes and protocols for Codex components.
18
+ * **Common Utilities**: Logger setup (Loguru), phone number validation, text processing, and caching.
19
+ * **Schemas**: Shared Pydantic models for cross-service communication.
20
+ * **Settings**: Modern configuration management using `pydantic-settings`.
21
+
22
+ ## 📦 Installation
23
+
24
+ ```bash
25
+ pip install codex-core
26
+ ```
27
+
28
+ ## 🛠️ Quick Start
29
+
30
+ ```python
31
+ from codex_tools.common.logger import setup_logger
32
+
33
+ logger = setup_logger("my-app")
34
+ logger.info("Codex Core is ready!")
35
+ ```
36
+
37
+ ---
38
+ *Part of the [Codex WaaS](https://github.com/codexdlc) ecosystem. · [EN Docs](https://codexdlc.github.io/codex-core/en_EN/) · [RU Docs](https://codexdlc.github.io/codex-core/ru_RU/) · [API](https://codexdlc.github.io/codex-core/api/) · [Changelog](https://codexdlc.github.io/codex-core/changelog/) · [Source](https://github.com/codexdlc/codex-core)*
@@ -33,6 +33,7 @@ Changelog = "https://github.com/codexdlc/codex-core/blob/main/CHANGELOG.md"
33
33
  Issues = "https://github.com/codexdlc/codex-core/issues"
34
34
 
35
35
  [project.optional-dependencies]
36
+ loguru = ["loguru>=0.7.0"]
36
37
  dev = [
37
38
  "pytest>=8.0",
38
39
  "pytest-asyncio>=0.23",
@@ -0,0 +1,140 @@
1
+ """Structured logging context bound to a named task or worker.
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).
7
+
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.
12
+
13
+ Example:
14
+ ```python
15
+ from codex_core.common.log_context import TaskLogContext
16
+
17
+ log = TaskLogContext("send_booking_notification", worker="notification_worker")
18
+ log.info("Processing appointment", extra={"appointment_id": 123})
19
+ # LogRecord contains: task="send_booking_notification",
20
+ # worker="notification_worker", appointment_id=123
21
+ ```
22
+ """
23
+
24
+ import logging
25
+ from typing import Any
26
+
27
+
28
+ class TaskLogContext:
29
+ """Structured logging adapter that binds context fields to every record.
30
+
31
+ Wraps a standard ``logging.Logger`` and automatically merges a
32
+ fixed ``_base_extra`` dict (built at construction time) with any
33
+ per-call ``extra`` dict before forwarding the record. This
34
+ eliminates repetitive ``extra={"task": ...}`` boilerplate in
35
+ worker loops.
36
+
37
+ The class is **stateful** in the sense that ``_base_extra`` is
38
+ set once at construction and shared across all log calls. It is
39
+ **not** thread-safe to mutate ``_base_extra`` after construction;
40
+ create a new instance per task if context must change.
41
+
42
+ Args:
43
+ task_name: Logical name of the current operation
44
+ (e.g. ``"send_booking_notification"``). Stored as
45
+ ``task`` in every log record's ``extra``.
46
+ logger_name: Name passed to ``logging.getLogger()``.
47
+ Defaults to *task_name* when omitted.
48
+ **extra: Arbitrary keyword arguments added to ``_base_extra``
49
+ alongside ``task`` (e.g. ``worker="notification_worker"``).
50
+
51
+ Example:
52
+ ```python
53
+ log = TaskLogContext(
54
+ "slot_calculation",
55
+ logger_name="codex.booking",
56
+ worker="slot_worker",
57
+ tenant_id=42,
58
+ )
59
+ log.debug("Starting slot search")
60
+ log.error("Slot not found", extra={"slot_id": 7})
61
+ ```
62
+ """
63
+
64
+ def __init__(self, task_name: str, logger_name: str | None = None, **extra: Any) -> None:
65
+ self._logger = logging.getLogger(logger_name or task_name)
66
+ self._base_extra = {"task": task_name, **extra}
67
+
68
+ def _merge_extra(self, extra: dict[str, Any] | None) -> dict[str, Any]:
69
+ """Merge per-call extra fields with the base context.
70
+
71
+ Args:
72
+ extra: Optional per-call fields. Keys in *extra* take
73
+ precedence over identically named keys in
74
+ ``_base_extra``.
75
+
76
+ Returns:
77
+ A new ``dict`` containing the merged fields.
78
+ """
79
+ if extra:
80
+ return {**self._base_extra, **extra}
81
+ return self._base_extra
82
+
83
+ def debug(self, msg: str, *args: Any, extra: dict[str, Any] | None = None, **kwargs: Any) -> None:
84
+ """Emit a ``DEBUG`` record enriched with the bound context fields.
85
+
86
+ Args:
87
+ msg: Log message format string.
88
+ *args: Positional arguments passed to ``Logger.debug``.
89
+ extra: Per-call structured fields merged with ``_base_extra``.
90
+ **kwargs: Additional keyword arguments forwarded to ``Logger.debug``.
91
+ """
92
+ self._logger.debug(msg, *args, extra=self._merge_extra(extra), **kwargs)
93
+
94
+ def info(self, msg: str, *args: Any, extra: dict[str, Any] | None = None, **kwargs: Any) -> None:
95
+ """Emit an ``INFO`` record enriched with the bound context fields.
96
+
97
+ Args:
98
+ msg: Log message format string.
99
+ *args: Positional arguments passed to ``Logger.info``.
100
+ extra: Per-call structured fields merged with ``_base_extra``.
101
+ **kwargs: Additional keyword arguments forwarded to ``Logger.info``.
102
+ """
103
+ self._logger.info(msg, *args, extra=self._merge_extra(extra), **kwargs)
104
+
105
+ def warning(self, msg: str, *args: Any, extra: dict[str, Any] | None = None, **kwargs: Any) -> None:
106
+ """Emit a ``WARNING`` record enriched with the bound context fields.
107
+
108
+ Args:
109
+ msg: Log message format string.
110
+ *args: Positional arguments passed to ``Logger.warning``.
111
+ extra: Per-call structured fields merged with ``_base_extra``.
112
+ **kwargs: Additional keyword arguments forwarded to ``Logger.warning``.
113
+ """
114
+ self._logger.warning(msg, *args, extra=self._merge_extra(extra), **kwargs)
115
+
116
+ def error(self, msg: str, *args: Any, extra: dict[str, Any] | None = None, **kwargs: Any) -> None:
117
+ """Emit an ``ERROR`` record enriched with the bound context fields.
118
+
119
+ Args:
120
+ msg: Log message format string.
121
+ *args: Positional arguments passed to ``Logger.error``.
122
+ extra: Per-call structured fields merged with ``_base_extra``.
123
+ **kwargs: Additional keyword arguments forwarded to ``Logger.error``.
124
+ """
125
+ self._logger.error(msg, *args, extra=self._merge_extra(extra), **kwargs)
126
+
127
+ def exception(self, msg: str, *args: Any, extra: dict[str, Any] | None = None, **kwargs: Any) -> None:
128
+ """Emit an ``ERROR`` record with exception traceback and bound context.
129
+
130
+ Equivalent to :meth:`error` but always captures the current
131
+ exception info (``exc_info=True`` is implicit). Call inside an
132
+ ``except`` block.
133
+
134
+ Args:
135
+ msg: Log message format string.
136
+ *args: Positional arguments passed to ``Logger.exception``.
137
+ extra: Per-call structured fields merged with ``_base_extra``.
138
+ **kwargs: Additional keyword arguments forwarded to ``Logger.exception``.
139
+ """
140
+ self._logger.exception(msg, *args, extra=self._merge_extra(extra), **kwargs)
@@ -0,0 +1,345 @@
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
+ Three configurable sinks are created by both setup functions:
9
+
10
+ 1. **stdout** — colourised, human-readable format for local development.
11
+ 2. **debug.log** — rotating plain-text file, ``enqueue=True`` for
12
+ async-safe writing.
13
+ 3. **errors.json** — rotating JSON-serialised file capturing ``ERROR``
14
+ and above; suitable for ingestion by ELK / Loki pipelines.
15
+
16
+ Standard-library ``logging`` records are bridged via
17
+ :class:`InterceptHandler` so that third-party libraries (SQLAlchemy,
18
+ httpx, aiogram, etc.) are automatically captured by Loguru.
19
+
20
+ Availability:
21
+ ``loguru`` is an **optional** dependency. Both :func:`setup_logging`
22
+ and :func:`setup_universal_logging` raise :exc:`ImportError` with an
23
+ actionable message when ``loguru`` is not installed.
24
+ """
25
+
26
+ import logging
27
+ import sys
28
+ from pathlib import Path
29
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
30
+
31
+ try:
32
+ from loguru import logger
33
+ except ImportError:
34
+ logger = None # type: ignore
35
+
36
+
37
+ if TYPE_CHECKING:
38
+ from types import FrameType
39
+
40
+
41
+ class InterceptHandler(logging.Handler):
42
+ """Bridge standard-library ``logging`` records to the Loguru sink.
43
+
44
+ Install this handler on the root logger (or any named logger) to
45
+ forward all ``logging``-based records into Loguru transparently.
46
+ The handler resolves the correct call-stack depth so that Loguru
47
+ reports the *original* call site rather than the handler frame.
48
+
49
+ This class is stateless and thread-safe; a single instance may be
50
+ shared across all intercepted loggers.
51
+
52
+ Example:
53
+ ```python
54
+ import logging
55
+ from codex_core.common.loguru_setup import InterceptHandler
56
+
57
+ logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
58
+ ```
59
+ """
60
+
61
+ def emit(self, record: logging.LogRecord) -> None:
62
+ """Forward a single ``logging.LogRecord`` to the active Loguru logger.
63
+
64
+ Resolves the Loguru level name from the record's level name,
65
+ falls back to the integer level when the name is unknown, then
66
+ walks up the call stack to find the frame that originally issued
67
+ the log call — ensuring Loguru displays the correct source
68
+ location instead of the handler internals.
69
+
70
+ Args:
71
+ record: The log record produced by the standard-library
72
+ logging infrastructure.
73
+
74
+ Note:
75
+ If ``loguru`` is not installed (``logger is None``), this
76
+ method returns silently to avoid masking the original
77
+ ``ImportError``.
78
+ """
79
+ if logger is None:
80
+ return
81
+
82
+ level: str | int
83
+ try:
84
+ level = logger.level(record.levelname).name
85
+ except ValueError:
86
+ level = record.levelno
87
+
88
+ frame: FrameType | None = logging.currentframe()
89
+ depth = 6
90
+ while frame and frame.f_code.co_filename == logging.__file__:
91
+ frame = frame.f_back
92
+ depth += 1
93
+
94
+ logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
95
+
96
+
97
+ def setup_universal_logging(
98
+ log_dir: Path,
99
+ service_name: str = "App",
100
+ console_level: str = "INFO",
101
+ file_level: str = "DEBUG",
102
+ rotation: str = "10 MB",
103
+ is_debug: bool = False,
104
+ ) -> None:
105
+ """Configure Loguru with three sinks and standard-library interception.
106
+
107
+ Intended for applications that do not use
108
+ :class:`~codex_core.settings.BaseCommonSettings` but still want the
109
+ full codex_core logging stack. Callers supply raw configuration
110
+ values directly rather than a settings object.
111
+
112
+ Sink layout after the call:
113
+
114
+ - **stdout** — colourised, ``console_level`` threshold.
115
+ - **<log_dir>/debug.log** — rotating plain text, ``file_level``
116
+ threshold, ``backtrace`` / ``diagnose`` enabled when
117
+ ``is_debug=True``.
118
+ - **<log_dir>/errors.json** — rotating JSON, ``ERROR`` threshold,
119
+ async-enqueued.
120
+ - **Root logger** — intercepted via :class:`InterceptHandler`.
121
+
122
+ Args:
123
+ log_dir: Directory where log files are created. Created
124
+ recursively if it does not exist.
125
+ service_name: Label embedded in the console format string to
126
+ distinguish output from multiple co-running services.
127
+ console_level: Minimum level for stdout output (e.g. ``"INFO"``).
128
+ file_level: Minimum level for the debug log file
129
+ (e.g. ``"DEBUG"``).
130
+ rotation: Loguru rotation threshold string (e.g. ``"10 MB"`` or
131
+ ``"1 day"``).
132
+ is_debug: When ``True``, enables full ``backtrace`` and
133
+ ``diagnose`` output in the debug file sink.
134
+
135
+ Raises:
136
+ ImportError: If ``loguru`` is not installed in the environment.
137
+
138
+ Example:
139
+ ```python
140
+ from pathlib import Path
141
+ from codex_core.common.loguru_setup import setup_universal_logging
142
+
143
+ setup_universal_logging(
144
+ log_dir=Path("/var/log/myapp"),
145
+ service_name="booking-worker",
146
+ is_debug=True,
147
+ )
148
+ ```
149
+ """
150
+ if logger is None:
151
+ raise ImportError(
152
+ "loguru is not installed. Please install it manually: "
153
+ "pip install loguru"
154
+ )
155
+
156
+ logger.remove()
157
+ log_dir.mkdir(parents=True, exist_ok=True)
158
+
159
+ # 1. Console output
160
+ logger.add(
161
+ sink=sys.stdout,
162
+ level=console_level,
163
+ colorize=True,
164
+ format=(
165
+ "<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
166
+ "<level>{level: <8}</level> | "
167
+ f"<magenta>{service_name}</magenta> | "
168
+ "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
169
+ "<level>{message}</level>"
170
+ ),
171
+ )
172
+
173
+ # 2. Debug file
174
+ logger.add(
175
+ sink=str(log_dir / "debug.log"),
176
+ level=file_level,
177
+ rotation=rotation,
178
+ compression="zip",
179
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
180
+ enqueue=True,
181
+ backtrace=is_debug,
182
+ diagnose=is_debug,
183
+ )
184
+
185
+ # 3. Errors file (JSON)
186
+ logger.add(
187
+ sink=str(log_dir / "errors.json"),
188
+ level="ERROR",
189
+ serialize=True,
190
+ rotation=rotation,
191
+ compression="zip",
192
+ enqueue=True,
193
+ )
194
+
195
+ # 4. Intercept standard logging
196
+ logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
197
+
198
+ logger.info(f"Loguru setup complete for {service_name}. Logs: {log_dir}")
199
+
200
+
201
+ @runtime_checkable
202
+ class LoggingSettingsProtocol(Protocol):
203
+ """Structural protocol describing the logging-related subset of settings.
204
+
205
+ Any settings object that exposes these five attributes satisfies this
206
+ protocol and can be passed to :func:`setup_logging` without explicit
207
+ inheritance. :class:`~codex_core.settings.BaseCommonSettings`
208
+ satisfies this protocol out of the box when its subclass adds the
209
+ required logging fields.
210
+
211
+ Attributes:
212
+ log_level_console: Minimum Loguru level for stdout
213
+ (e.g. ``"INFO"``).
214
+ log_level_file: Minimum Loguru level for the rotating debug
215
+ file (e.g. ``"DEBUG"``).
216
+ log_rotation: Loguru rotation threshold
217
+ (e.g. ``"10 MB"`` or ``"1 day"``).
218
+ log_dir: Base directory for log files. A service-name
219
+ subdirectory is appended automatically by
220
+ :func:`setup_logging`.
221
+ debug: When ``True``, enables ``backtrace`` and ``diagnose``
222
+ in the file sink.
223
+ """
224
+
225
+ log_level_console: str
226
+ log_level_file: str
227
+ log_rotation: str
228
+ log_dir: str
229
+ debug: bool
230
+
231
+
232
+ def setup_logging(
233
+ settings: LoggingSettingsProtocol,
234
+ service_name: str,
235
+ intercept_loggers: list[str] | None = None,
236
+ log_levels: dict[str, int] | None = None,
237
+ ) -> None:
238
+ """Configure Loguru from a settings object conforming to :class:`LoggingSettingsProtocol`.
239
+
240
+ Preferred entry-point for applications that use
241
+ :class:`~codex_core.settings.BaseCommonSettings`. Configuration
242
+ is read from *settings* rather than raw arguments, enabling
243
+ environment-driven log levels without code changes.
244
+
245
+ PII masking is **not** handled here; it is the responsibility of
246
+ :class:`~codex_core.core.base_dto.BaseDTO.__repr__` at the DTO
247
+ level. Do not log raw user input through this logger.
248
+
249
+ Side effects:
250
+ - Removes all existing Loguru handlers (``logger.remove()``).
251
+ - Creates ``<settings.log_dir>/<service_name>/`` directory tree.
252
+ - Replaces the root ``logging`` handler with
253
+ :class:`InterceptHandler`.
254
+ - Optionally replaces handlers on loggers listed in
255
+ *intercept_loggers*.
256
+ - Optionally sets log levels on loggers listed in *log_levels*.
257
+
258
+ Args:
259
+ settings: Any object satisfying :class:`LoggingSettingsProtocol`.
260
+ Typically a subclass of
261
+ :class:`~codex_core.settings.BaseCommonSettings`.
262
+ service_name: Identifies the service in log output and is used
263
+ as the leaf directory name under ``settings.log_dir``.
264
+ intercept_loggers: Optional list of logger names whose handlers
265
+ should be replaced with :class:`InterceptHandler`
266
+ (e.g. ``["aiogram", "sqlalchemy.engine"]``).
267
+ log_levels: Optional mapping of ``{logger_name: level_int}``
268
+ for silencing verbose third-party libraries
269
+ (e.g. ``{"httpx": logging.WARNING}``).
270
+
271
+ Raises:
272
+ ImportError: If ``loguru`` is not installed in the environment.
273
+
274
+ Example:
275
+ ```python
276
+ import logging
277
+ from codex_core.common.loguru_setup import setup_logging
278
+
279
+ setup_logging(
280
+ settings=app_settings,
281
+ service_name="api",
282
+ intercept_loggers=["uvicorn", "sqlalchemy.engine"],
283
+ log_levels={"httpx": logging.WARNING},
284
+ )
285
+ ```
286
+ """
287
+ if logger is None:
288
+ raise ImportError(
289
+ "loguru is not installed. Please install it manually: "
290
+ "pip install loguru"
291
+ )
292
+
293
+ logger.remove()
294
+
295
+ log_dir = Path(settings.log_dir) / service_name
296
+ log_dir.mkdir(parents=True, exist_ok=True)
297
+
298
+ # Console
299
+ logger.add(
300
+ sink=sys.stdout,
301
+ level=settings.log_level_console,
302
+ colorize=True,
303
+ format=(
304
+ "<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
305
+ "<level>{level: <8}</level> | "
306
+ f"<magenta>{service_name}</magenta> | "
307
+ "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
308
+ "<level>{message}</level>"
309
+ ),
310
+ )
311
+
312
+ # Debug file
313
+ logger.add(
314
+ sink=str(log_dir / "debug.log"),
315
+ level=settings.log_level_file,
316
+ rotation=settings.log_rotation,
317
+ compression="zip",
318
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
319
+ enqueue=True,
320
+ backtrace=settings.debug,
321
+ diagnose=settings.debug,
322
+ )
323
+
324
+ # Errors file (JSON)
325
+ logger.add(
326
+ sink=str(log_dir / "errors.json"),
327
+ level="ERROR",
328
+ serialize=True,
329
+ rotation=settings.log_rotation,
330
+ compression="zip",
331
+ enqueue=True,
332
+ )
333
+
334
+ # Intercept standard logging
335
+ logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
336
+
337
+ # Intercept specified loggers
338
+ if intercept_loggers:
339
+ for name in intercept_loggers:
340
+ logging.getLogger(name).handlers = [InterceptHandler()]
341
+
342
+ # Set levels for noisy libraries
343
+ if log_levels:
344
+ for name, level in log_levels.items():
345
+ logging.getLogger(name).setLevel(level)
@@ -0,0 +1,75 @@
1
+ """Utilities for normalizing phone numbers to a canonical digit-only format.
2
+
3
+ Framework-agnostic (zero Django / framework dependencies). Suitable
4
+ for use in any Python 3.10+ environment including async workers.
5
+
6
+ The module intentionally does not validate that the resulting number is
7
+ reachable (no libphonenumber dependency); it only ensures a consistent
8
+ digit-only representation that can be safely stored, compared, and
9
+ passed to SMS / telephony APIs.
10
+ """
11
+
12
+
13
+ def normalize_phone(phone: str, default_country: str = "49") -> str:
14
+ """Normalize a phone number to a digit-only international string.
15
+
16
+ Handles the three most common input variants encountered in
17
+ European / German-locale data:
18
+
19
+ 1. **International ``+`` prefix** — ``+49 151 1234567``
20
+ 2. **International ``00`` prefix** — ``0049 151 1234567``
21
+ 3. **Local ``0`` prefix** — ``0151 1234567`` (expanded using
22
+ *default_country*)
23
+ 4. **Already normalized** — ``491511234567`` (returned as-is)
24
+
25
+ Non-digit, non-plus characters (spaces, hyphens, parentheses) are
26
+ stripped before prefix detection.
27
+
28
+ Args:
29
+ phone: Raw phone string in any common format. Empty string or
30
+ strings containing no digits/plus return ``""``.
31
+ default_country: ITU-T country code (digits only, no ``+``)
32
+ prepended when a local ``0``-prefix number is detected.
33
+ Defaults to ``"49"`` (Germany).
34
+
35
+ Returns:
36
+ Digit-only string representing the international phone number,
37
+ or an empty string if the input is blank or contains no
38
+ recognizable digits.
39
+
40
+ Note:
41
+ No length or reachability validation is performed. The caller
42
+ is responsible for validating that the result conforms to the
43
+ expected E.164 length for the target country.
44
+
45
+ Example:
46
+ ```python
47
+ normalize_phone("0151 1234567") # → "491511234567"
48
+ normalize_phone("+49 151 1234567") # → "491511234567"
49
+ normalize_phone("0049 151 1234567") # → "491511234567"
50
+ normalize_phone("+1-800-555-0100", "1") # → "18005550100"
51
+ normalize_phone("") # → ""
52
+ ```
53
+ """
54
+ if not phone:
55
+ return ""
56
+
57
+ # Keep only digits and plus (if present at start)
58
+ cleaned = "".join(c for c in phone if c.isdigit() or c == "+")
59
+ if not cleaned:
60
+ return ""
61
+
62
+ # 1. Already has '+' -> remove it and return
63
+ if cleaned.startswith("+"):
64
+ return cleaned.replace("+", "")
65
+
66
+ # 2. Starts with '00' (European international format)
67
+ if cleaned.startswith("00"):
68
+ return cleaned[2:]
69
+
70
+ # 3. Starts with a single '0' (local format, e.g. German)
71
+ if cleaned.startswith("0"):
72
+ return default_country + cleaned[1:]
73
+
74
+ # 4. Already normalized number without plus (e.g. 49151...)
75
+ return cleaned