simple-module-hosting 0.0.1__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.
Files changed (65) hide show
  1. simple_module_hosting/__init__.py +7 -0
  2. simple_module_hosting/_error_handlers.py +54 -0
  3. simple_module_hosting/_hydrate_step.py +39 -0
  4. simple_module_hosting/_inertia_setup.py +73 -0
  5. simple_module_hosting/_inertia_shared.py +61 -0
  6. simple_module_hosting/_observability.py +108 -0
  7. simple_module_hosting/_phase_helpers.py +160 -0
  8. simple_module_hosting/app_builder.py +281 -0
  9. simple_module_hosting/bootstrap_settings.py +55 -0
  10. simple_module_hosting/cli.py +292 -0
  11. simple_module_hosting/health.py +79 -0
  12. simple_module_hosting/host_settings.py +33 -0
  13. simple_module_hosting/i18n_deps.py +25 -0
  14. simple_module_hosting/i18n_manifest.py +202 -0
  15. simple_module_hosting/i18n_middleware.py +95 -0
  16. simple_module_hosting/inertia_deps.py +27 -0
  17. simple_module_hosting/inertia_utils.py +31 -0
  18. simple_module_hosting/logging.py +91 -0
  19. simple_module_hosting/manifest.py +250 -0
  20. simple_module_hosting/middleware.py +272 -0
  21. simple_module_hosting/migrations.py +65 -0
  22. simple_module_hosting/permissions.py +75 -0
  23. simple_module_hosting/py.typed +0 -0
  24. simple_module_hosting/redirects.py +45 -0
  25. simple_module_hosting/scaffolding.py +294 -0
  26. simple_module_hosting/settings.py +10 -0
  27. simple_module_hosting/templates/host/.env.example +20 -0
  28. simple_module_hosting/templates/host/.gitignore +19 -0
  29. simple_module_hosting/templates/host/Makefile +24 -0
  30. simple_module_hosting/templates/host/README.md.tpl +59 -0
  31. simple_module_hosting/templates/host/alembic.ini +36 -0
  32. simple_module_hosting/templates/host/client_app/app.tsx +16 -0
  33. simple_module_hosting/templates/host/client_app/main.tsx +2 -0
  34. simple_module_hosting/templates/host/client_app/package.json.tpl +23 -0
  35. simple_module_hosting/templates/host/client_app/pages/Error.tsx +13 -0
  36. simple_module_hosting/templates/host/client_app/pages.ts +47 -0
  37. simple_module_hosting/templates/host/client_app/styles.css +7 -0
  38. simple_module_hosting/templates/host/client_app/tsconfig.json +16 -0
  39. simple_module_hosting/templates/host/client_app/vite.config.ts +39 -0
  40. simple_module_hosting/templates/host/main.py +27 -0
  41. simple_module_hosting/templates/host/migrations/env.py +80 -0
  42. simple_module_hosting/templates/host/migrations/script.py.mako +26 -0
  43. simple_module_hosting/templates/host/migrations/versions/.gitkeep +1 -0
  44. simple_module_hosting/templates/host/pyproject.toml.tpl +17 -0
  45. simple_module_hosting/templates/host/templates/index.html +12 -0
  46. simple_module_hosting/templates/module/.github/workflows/ci.yml +32 -0
  47. simple_module_hosting/templates/module/.github/workflows/publish.yml.tpl +52 -0
  48. simple_module_hosting/templates/module/.gitignore +14 -0
  49. simple_module_hosting/templates/module/README.md.tpl +82 -0
  50. simple_module_hosting/templates/module/__PACKAGE__/__init__.py +0 -0
  51. simple_module_hosting/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  52. simple_module_hosting/templates/module/__PACKAGE__/endpoints/api.py.tpl +11 -0
  53. simple_module_hosting/templates/module/__PACKAGE__/module.py.tpl +46 -0
  54. simple_module_hosting/templates/module/__PACKAGE__/pages/.gitkeep +1 -0
  55. simple_module_hosting/templates/module/__PACKAGE__/services.py.tpl +22 -0
  56. simple_module_hosting/templates/module/package.json.tpl +16 -0
  57. simple_module_hosting/templates/module/pyproject.toml.tpl +39 -0
  58. simple_module_hosting/templates/module/tests/__init__.py +0 -0
  59. simple_module_hosting/templates/module/tests/test_module.py.tpl +27 -0
  60. simple_module_hosting/templates/module/tsconfig.json.tpl +11 -0
  61. simple_module_hosting-0.0.1.dist-info/METADATA +93 -0
  62. simple_module_hosting-0.0.1.dist-info/RECORD +65 -0
  63. simple_module_hosting-0.0.1.dist-info/WHEEL +4 -0
  64. simple_module_hosting-0.0.1.dist-info/entry_points.txt +3 -0
  65. simple_module_hosting-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,79 @@
1
+ """Health check endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+
8
+ from fastapi import APIRouter, Request
9
+ from simple_module_core.health import HealthCheckResult, HealthRegistry, HealthStatus
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ router = APIRouter(tags=["Health"])
14
+
15
+ _KEY_STATUS = "status"
16
+ _KEY_CHECKS = "checks"
17
+ _KEY_MIGRATION = "migration"
18
+ _KEY_DETAIL = "detail"
19
+ _STATUS_HEALTHY = "healthy"
20
+ _STATUS_ALIVE = "alive"
21
+
22
+ # Severity ordering for aggregation: worst status wins
23
+ _STATUS_SEVERITY = {
24
+ HealthStatus.HEALTHY: 0,
25
+ HealthStatus.DEGRADED: 1,
26
+ HealthStatus.UNHEALTHY: 2,
27
+ }
28
+
29
+
30
+ @router.get("/health")
31
+ async def health(request: Request) -> dict:
32
+ migration = getattr(request.app.state, "migration", None)
33
+ return {
34
+ _KEY_STATUS: _STATUS_HEALTHY,
35
+ _KEY_MIGRATION: migration,
36
+ }
37
+
38
+
39
+ @router.get("/health/live")
40
+ async def liveness() -> dict:
41
+ return {_KEY_STATUS: _STATUS_ALIVE}
42
+
43
+
44
+ @router.get("/health/ready")
45
+ async def readiness(request: Request) -> dict:
46
+ registry: HealthRegistry = request.app.state.sm.health_registry
47
+ checks = registry.all_checks
48
+
49
+ if not checks:
50
+ return {_KEY_STATUS: _STATUS_HEALTHY, _KEY_CHECKS: {}}
51
+
52
+ # Run all checks concurrently
53
+ async def _run_check(name: str, check_fn):
54
+ try:
55
+ return name, await check_fn()
56
+ except Exception as exc:
57
+ return name, HealthCheckResult(
58
+ status=HealthStatus.UNHEALTHY,
59
+ detail=str(exc),
60
+ )
61
+
62
+ tasks = [_run_check(c.name, c.check) for c in checks]
63
+ completed = await asyncio.gather(*tasks)
64
+
65
+ results: dict[str, HealthCheckResult] = dict(completed)
66
+
67
+ # Aggregate: worst status wins
68
+ worst = HealthStatus.HEALTHY
69
+ for result in results.values():
70
+ if _STATUS_SEVERITY[result.status] > _STATUS_SEVERITY[worst]:
71
+ worst = result.status
72
+
73
+ return {
74
+ _KEY_STATUS: worst.value,
75
+ _KEY_CHECKS: {
76
+ name: {_KEY_STATUS: r.status.value, **({_KEY_DETAIL: r.detail} if r.detail else {})}
77
+ for name, r in results.items()
78
+ },
79
+ }
@@ -0,0 +1,33 @@
1
+ """Host-level settings stored in the DB (not env).
2
+
3
+ Registered under ``package="host"`` so the UI shows them alongside module
4
+ settings. The hosting layer still reads these directly from
5
+ ``app.state.host.settings``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pydantic import model_validator
11
+ from pydantic_settings import BaseSettings, SettingsConfigDict
12
+
13
+
14
+ class HostSettings(BaseSettings):
15
+ """DB-backed host configuration — defaults live here, overrides in DB."""
16
+
17
+ model_config = SettingsConfigDict(extra="ignore")
18
+
19
+ multi_tenant: bool = False
20
+ tenant_header: str = ""
21
+
22
+ i18n_default_locale: str = "en"
23
+ i18n_supported_locales: list[str] = ["en"]
24
+ i18n_cookie_name: str = "locale"
25
+
26
+ @model_validator(mode="after")
27
+ def _check_default_locale_supported(self) -> HostSettings:
28
+ if self.i18n_default_locale not in self.i18n_supported_locales:
29
+ raise ValueError(
30
+ f"i18n_default_locale '{self.i18n_default_locale}' is not in "
31
+ f"i18n_supported_locales {self.i18n_supported_locales}"
32
+ )
33
+ return self
@@ -0,0 +1,25 @@
1
+ """FastAPI dependency for request-scoped Translator resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from fastapi import Depends, Request
8
+ from simple_module_core.i18n import Translator
9
+
10
+
11
+ async def get_translator(request: Request) -> Translator:
12
+ """Resolve a Translator bound to ``request.state.locale``.
13
+
14
+ Reads the registry from ``request.app.state.sm.i18n_registry`` and the
15
+ default locale from ``request.app.state.sm.settings.i18n_default_locale``.
16
+
17
+ ``request.state.locale`` is populated by LocaleMiddleware.
18
+ """
19
+ sm = request.app.state.sm
20
+ default_locale = sm.settings.i18n_default_locale
21
+ locale = getattr(request.state, "locale", default_locale)
22
+ return Translator(sm.i18n_registry, locale=locale, default_locale=default_locale)
23
+
24
+
25
+ TranslatorDep = Annotated[Translator, Depends(get_translator)]
@@ -0,0 +1,202 @@
1
+ """Emit generated-resources.ts + keys.generated.ts for the frontend.
2
+
3
+ Both files are consumed by ``@simple-module-py/i18n``:
4
+
5
+ * ``generated-resources.ts`` — flat empty-string keys, fed into i18next's
6
+ ``CustomTypeOptions['resources']`` so ``t('foo.bar')`` narrows to the
7
+ accepted key union.
8
+
9
+ * ``keys.generated.ts`` — a nested ``keys`` constant whose leaves are the
10
+ full dotted key strings. Consumers use ``t(keys.foo.bar)`` for typed
11
+ call-sites.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from simple_module_core import ModuleBase
21
+ from simple_module_core.i18n import PLURAL_CATEGORIES, I18nRegistry
22
+
23
+ from simple_module_hosting.manifest import _write_if_changed
24
+ from simple_module_hosting.settings import Settings
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def build_i18n_registry(
30
+ settings: Settings,
31
+ modules: list[ModuleBase],
32
+ project_root: Path,
33
+ ) -> tuple[I18nRegistry, list[tuple[str, str, Path]]]:
34
+ """Construct the i18n registry from module + host + UI sources.
35
+
36
+ Returns ``(registry, extra_sources)`` where ``extra_sources`` is the list
37
+ of ``(reporter_name, namespace, dir)`` triples the diagnostic runner
38
+ needs to validate non-module locale directories.
39
+ """
40
+ registry = I18nRegistry(
41
+ default_locale=settings.i18n_default_locale,
42
+ supported_locales=settings.i18n_supported_locales,
43
+ )
44
+ extra_sources: list[tuple[str, str, Path]] = []
45
+
46
+ for mod in modules:
47
+ for namespace, locale_dir in mod.locale_dirs().items():
48
+ registry.add_source(namespace, locale_dir)
49
+
50
+ host_locales = project_root / "host" / "locales"
51
+ if host_locales.is_dir():
52
+ registry.add_source("host", host_locales)
53
+ extra_sources.append(("host", "host", host_locales))
54
+
55
+ ui_locales = project_root / "packages" / "ui" / "locales"
56
+ if ui_locales.is_dir():
57
+ registry.add_source("ui", ui_locales)
58
+ extra_sources.append(("packages/ui", "ui", ui_locales))
59
+
60
+ registry.load()
61
+ return registry, extra_sources
62
+
63
+
64
+ def emit_frontend_types(registry: I18nRegistry, project_root: Path) -> None:
65
+ """Write the TS augmentation files into @simple-module-py/i18n if present.
66
+
67
+ Logs but does not raise on failure — stale types are preferable to a
68
+ broken boot. Dev-loop only; callers should gate on ``is_development``.
69
+ """
70
+ try:
71
+ pkg_src = project_root / "packages" / "i18n" / "src"
72
+ if pkg_src.is_dir():
73
+ write_generated_resources(registry, pkg_src)
74
+ except Exception:
75
+ logger.exception("Failed to write generated-resources.ts — frontend types will be stale")
76
+
77
+
78
+ _RESOURCES_HEADER = (
79
+ "// AUTO-GENERATED by simple_module_hosting.i18n_manifest — do not edit by hand.\n"
80
+ "// Regenerate by booting the host in development mode."
81
+ )
82
+
83
+ _KEYS_HEADER = _RESOURCES_HEADER
84
+
85
+ _PLURAL_SUFFIXES: tuple[str, ...] = tuple(f"_{c}" for c in PLURAL_CATEGORIES)
86
+
87
+
88
+ def write_generated_resources(registry: I18nRegistry, output_dir: Path) -> Path:
89
+ """Write both ``generated-resources.ts`` and ``keys.generated.ts``.
90
+
91
+ Returns the path of the resources file. Both files skip the disk write
92
+ when content matches what's already on disk (to avoid bumping mtimes
93
+ that would trigger spurious Vite HMR).
94
+ """
95
+ output_dir = Path(output_dir)
96
+ output_dir.mkdir(parents=True, exist_ok=True)
97
+
98
+ messages = registry.messages(registry.default_locale)
99
+ keys = sorted(messages.keys())
100
+
101
+ resources_path = output_dir / "generated-resources.ts"
102
+ if _write_if_changed(resources_path, _render_resources(keys)):
103
+ logger.info("Wrote %s (%d keys)", resources_path.name, len(keys))
104
+
105
+ keys_path = output_dir / "keys.generated.ts"
106
+ if _write_if_changed(keys_path, _render_keys(keys)):
107
+ logger.info("Wrote %s", keys_path.name)
108
+
109
+ return resources_path
110
+
111
+
112
+ def _render_resources(keys: list[str]) -> str:
113
+ lines = [_RESOURCES_HEADER, "export default {", " translation: {"]
114
+ for key in keys:
115
+ lines.append(f" '{key}': '',")
116
+ lines.append(" },")
117
+ lines.append("} as const;")
118
+ lines.append("")
119
+ return "\n".join(lines)
120
+
121
+
122
+ def _render_keys(keys: list[str]) -> str:
123
+ """Render the nested ``keys`` constant tree."""
124
+ tree = _build_key_tree(keys)
125
+ return f"{_KEYS_HEADER}\n\nexport const keys = {_serialize(tree, indent=0)} as const;\n"
126
+
127
+
128
+ def _build_key_tree(flat_keys: list[str]) -> dict[str, Any]:
129
+ """Build a nested dict from a list of dotted keys.
130
+
131
+ Leaves are the full dotted key string. Plural stems (e.g. ``foo.items``
132
+ when ``foo.items_one``/``foo.items_other`` exist) are added as virtual
133
+ leaves so callers can pass them directly to ``t(key, {count})``.
134
+ """
135
+ tree: dict[str, Any] = {}
136
+ stems: set[str] = set()
137
+
138
+ for flat_key in flat_keys:
139
+ for suffix in _PLURAL_SUFFIXES:
140
+ if flat_key.endswith(suffix):
141
+ stems.add(flat_key[: -len(suffix)])
142
+ break
143
+ _insert(tree, flat_key.split("."), flat_key)
144
+
145
+ existing = set(flat_keys)
146
+ for stem in stems:
147
+ if stem in existing:
148
+ continue
149
+ _insert(tree, stem.split("."), stem)
150
+
151
+ return tree
152
+
153
+
154
+ def _insert(tree: dict[str, Any], path: list[str], value: str) -> None:
155
+ """Insert ``value`` into ``tree`` at ``path``.
156
+
157
+ Existing leaves win over later inserts — real keys cannot be shadowed
158
+ by virtual plural stems, and a path segment that collides with an
159
+ existing leaf causes the insertion to be silently aborted.
160
+ """
161
+ cursor: Any = tree
162
+ for segment in path[:-1]:
163
+ existing = cursor.get(segment)
164
+ if isinstance(existing, dict):
165
+ cursor = existing
166
+ elif existing is None:
167
+ new_dict: dict[str, Any] = {}
168
+ cursor[segment] = new_dict
169
+ cursor = new_dict
170
+ else:
171
+ return
172
+ leaf_key = path[-1]
173
+ if leaf_key not in cursor:
174
+ cursor[leaf_key] = value
175
+
176
+
177
+ def _serialize(node: Any, *, indent: int) -> str:
178
+ """Emit a dict-of-dicts tree as pretty-printed TypeScript object literal.
179
+
180
+ Strings are wrapped in single quotes to match the project's biome config
181
+ (``quoteStyle: "single"``); dotted i18n keys never contain single quotes,
182
+ so plain wrapping is safe.
183
+ """
184
+ if isinstance(node, str):
185
+ return f"'{node}'"
186
+ if not isinstance(node, dict) or not node:
187
+ return "{}"
188
+ pad = " " * indent
189
+ inner_pad = " " * (indent + 1)
190
+ lines = ["{"]
191
+ for k in sorted(node.keys()):
192
+ key_repr = k if _is_valid_js_identifier(k) else f"'{k}'"
193
+ lines.append(f"{inner_pad}{key_repr}: {_serialize(node[k], indent=indent + 1)},")
194
+ lines.append(f"{pad}}}")
195
+ return "\n".join(lines)
196
+
197
+
198
+ def _is_valid_js_identifier(name: str) -> bool:
199
+ """True if ``name`` can be an unquoted object key in JS (ASCII subset)."""
200
+ if not name or not (name[0].isalpha() or name[0] in "_$"):
201
+ return False
202
+ return all(c.isalnum() or c in "_$" for c in name)
@@ -0,0 +1,95 @@
1
+ """LocaleMiddleware — resolve active locale from cookie / Accept-Language / default."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from starlette.datastructures import Headers
6
+ from starlette.requests import Request
7
+ from starlette.types import ASGIApp, Receive, Scope, Send
8
+
9
+
10
+ class LocaleMiddleware:
11
+ """Set ``request.state.locale`` based on cookie, Accept-Language, and default.
12
+
13
+ Resolution order:
14
+
15
+ 1. Cookie named ``cookie_name``, validated against ``supported_locales``.
16
+ 2. ``Accept-Language`` header, negotiated against supported_locales via
17
+ longest-prefix match (``es-MX`` matches supported ``es``).
18
+ 3. ``default_locale``.
19
+
20
+ Runs as a pure ASGI middleware (no BaseHTTPMiddleware) to match the rest
21
+ of the framework's middleware stack.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ app: ASGIApp,
27
+ *,
28
+ supported_locales: list[str],
29
+ default_locale: str,
30
+ cookie_name: str = "locale",
31
+ ) -> None:
32
+ self.app = app
33
+ self.supported = list(supported_locales)
34
+ self.default_locale = default_locale
35
+ self.cookie_name = cookie_name
36
+
37
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
38
+ if scope["type"] != "http":
39
+ await self.app(scope, receive, send)
40
+ return
41
+
42
+ request = Request(scope)
43
+ locale = self._resolve(request)
44
+ request.state.locale = locale
45
+ await self.app(scope, receive, send)
46
+
47
+ def _resolve(self, request: Request) -> str:
48
+ # 1. Cookie.
49
+ cookie = request.cookies.get(self.cookie_name)
50
+ if cookie and cookie in self.supported:
51
+ return cookie
52
+
53
+ # 2. Accept-Language.
54
+ accept = Headers(scope=request.scope).get("accept-language")
55
+ if accept:
56
+ matched = self._negotiate(accept)
57
+ if matched:
58
+ return matched
59
+
60
+ # 3. Default.
61
+ return self.default_locale
62
+
63
+ def _negotiate(self, accept_language: str) -> str | None:
64
+ """Parse Accept-Language and return the highest-q supported locale.
65
+
66
+ Matches either exact tag or primary prefix (``es-MX`` -> ``es``).
67
+ """
68
+ # Hard cap to blunt adversarial Accept-Language: a,a,a,... spam.
69
+ # Real browsers send <10 tags; 20 is comfortably above that.
70
+ parts = accept_language.split(",", 20)
71
+ candidates: list[tuple[float, str]] = []
72
+ for part in parts[:20]:
73
+ part = part.strip()
74
+ if not part:
75
+ continue
76
+ tag, _, q_part = part.partition(";")
77
+ tag = tag.strip().lower()
78
+ q_part = q_part.strip().lower()
79
+ try:
80
+ q = float(q_part.split("=", 1)[1]) if q_part.startswith("q=") else 1.0
81
+ except ValueError:
82
+ q = 1.0
83
+ candidates.append((q, tag))
84
+
85
+ # Sort by q descending, stable.
86
+ candidates.sort(key=lambda pair: -pair[0])
87
+
88
+ supported_lower = {loc.lower(): loc for loc in self.supported}
89
+ for _, tag in candidates:
90
+ if tag in supported_lower:
91
+ return supported_lower[tag]
92
+ primary = tag.split("-", 1)[0]
93
+ if primary in supported_lower:
94
+ return supported_lower[primary]
95
+ return None
@@ -0,0 +1,27 @@
1
+ """Lazy Inertia dependency that resolves from app state at request time."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from fastapi import Depends, Request
8
+ from inertia import Inertia
9
+
10
+
11
+ async def get_inertia(request: Request) -> Inertia:
12
+ """Resolve the Inertia instance from the app's configured dependency.
13
+
14
+ This allows view endpoints to declare ``inertia: InertiaDep`` without
15
+ needing the Inertia config at import time.
16
+ """
17
+ inertia_dep = request.app.state.inertia_dependency
18
+ inertia = inertia_dep(request, None)
19
+
20
+ shared = getattr(request.state, "inertia_shared", None)
21
+ if shared:
22
+ inertia.share(**shared)
23
+
24
+ return inertia
25
+
26
+
27
+ InertiaDep = Annotated[Inertia, Depends(get_inertia)]
@@ -0,0 +1,31 @@
1
+ """Shared Inertia utilities for form-action view endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import Request
6
+ from pydantic import ValidationError
7
+ from starlette.responses import RedirectResponse
8
+
9
+ from simple_module_hosting.redirects import safe_referer_or_root
10
+
11
+ SESSION_ERRORS_KEY = "_errors"
12
+
13
+
14
+ def validation_errors_to_dict(exc: ValidationError) -> dict[str, str]:
15
+ """Flatten a Pydantic ValidationError — takes only the last loc segment
16
+ so nested model paths don't leak into the frontend field keys."""
17
+ errors: dict[str, str] = {}
18
+ for error in exc.errors():
19
+ field = str(error["loc"][-1]) if error["loc"] else "general"
20
+ errors[field] = error["msg"]
21
+ return errors
22
+
23
+
24
+ def redirect_back_with_errors(request: Request, errors: dict[str, str]) -> RedirectResponse:
25
+ """Store validation errors in the session and redirect to the referring page.
26
+
27
+ Uses ``safe_referer_or_root`` to reject attacker-controlled Referer values
28
+ (cross-origin URLs) — otherwise this becomes a reflected open redirect
29
+ accessible to any attacker who can trigger a form validation error."""
30
+ request.session[SESSION_ERRORS_KEY] = errors
31
+ return RedirectResponse(safe_referer_or_root(request), status_code=303)
@@ -0,0 +1,91 @@
1
+ """Structured logging — JSON formatter, correlation IDs, and setup helper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import sys
8
+ from contextvars import ContextVar
9
+ from datetime import UTC, datetime
10
+
11
+ correlation_id: ContextVar[str] = ContextVar("correlation_id", default="")
12
+
13
+
14
+ class _CorrelationIdFilter(logging.Filter):
15
+ """Inject the current correlation ID into every log record."""
16
+
17
+ def filter(self, record: logging.LogRecord) -> bool:
18
+ record.correlation_id = correlation_id.get("") # type: ignore[attr-defined]
19
+ return True
20
+
21
+
22
+ class JsonFormatter(logging.Formatter):
23
+ """Emit log records as single-line JSON objects.
24
+
25
+ Standard fields: timestamp, level, logger, message, correlation_id.
26
+ Extra keys (method, path, status_code, duration_ms, client_ip, user_id)
27
+ are included when present on the record.
28
+ """
29
+
30
+ _EXTRA_KEYS = (
31
+ "method",
32
+ "path",
33
+ "status_code",
34
+ "duration_ms",
35
+ "client_ip",
36
+ "user_id",
37
+ "operation",
38
+ "entity",
39
+ "entity_id",
40
+ "db_duration_ms",
41
+ )
42
+
43
+ def format(self, record: logging.LogRecord) -> str:
44
+ log_obj: dict[str, object] = {
45
+ "timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
46
+ "level": record.levelname,
47
+ "logger": record.name,
48
+ "message": record.getMessage(),
49
+ "correlation_id": getattr(record, "correlation_id", ""),
50
+ }
51
+
52
+ for key in self._EXTRA_KEYS:
53
+ value = getattr(record, key, None)
54
+ if value is not None:
55
+ log_obj[key] = value
56
+
57
+ if record.exc_info and record.exc_info[0] is not None:
58
+ log_obj["exception"] = self.formatException(record.exc_info)
59
+
60
+ return json.dumps(log_obj, default=str)
61
+
62
+
63
+ _TEXT_FORMAT = "%(asctime)s %(levelname)-8s [%(correlation_id)s] %(name)s — %(message)s"
64
+
65
+
66
+ def setup_logging(*, level: str = "INFO", json_format: bool = True) -> None:
67
+ """Configure the root logger for structured output.
68
+
69
+ Parameters
70
+ ----------
71
+ level:
72
+ Log level name (DEBUG, INFO, WARNING, …).
73
+ json_format:
74
+ When *True* (the default) emit JSON lines; otherwise use a
75
+ human-readable text format that still includes the correlation ID.
76
+ """
77
+ root = logging.getLogger()
78
+ root.setLevel(getattr(logging, level.upper(), logging.INFO))
79
+
80
+ root.handlers.clear()
81
+
82
+ handler = logging.StreamHandler(sys.stdout)
83
+
84
+ if json_format:
85
+ handler.setFormatter(JsonFormatter())
86
+ else:
87
+ handler.setFormatter(logging.Formatter(_TEXT_FORMAT))
88
+
89
+ handler.addFilter(_CorrelationIdFilter())
90
+
91
+ root.addHandler(handler)