simple-module-core 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.
- simple_module_core/__init__.py +76 -0
- simple_module_core/__main__.py +96 -0
- simple_module_core/diagnostics/__init__.py +24 -0
- simple_module_core/diagnostics/_coupling.py +81 -0
- simple_module_core/diagnostics/_i18n.py +121 -0
- simple_module_core/diagnostics/_inertia_api.py +73 -0
- simple_module_core/diagnostics/_js_workspace.py +35 -0
- simple_module_core/diagnostics/_migration.py +45 -0
- simple_module_core/diagnostics/_module.py +252 -0
- simple_module_core/diagnostics/_runner.py +81 -0
- simple_module_core/diagnostics/_types.py +33 -0
- simple_module_core/discovery.py +195 -0
- simple_module_core/dotenv.py +38 -0
- simple_module_core/environments.py +15 -0
- simple_module_core/events.py +91 -0
- simple_module_core/exceptions.py +56 -0
- simple_module_core/feature_flags.py +187 -0
- simple_module_core/health.py +46 -0
- simple_module_core/i18n.py +258 -0
- simple_module_core/menu.py +89 -0
- simple_module_core/module.py +179 -0
- simple_module_core/permissions.py +121 -0
- simple_module_core/py.typed +0 -0
- simple_module_core/services.py +45 -0
- simple_module_core/versioning.py +67 -0
- simple_module_core-0.0.1.dist-info/METADATA +85 -0
- simple_module_core-0.0.1.dist-info/RECORD +29 -0
- simple_module_core-0.0.1.dist-info/WHEEL +4 -0
- simple_module_core-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Async in-process event bus backed by pyee for inter-module communication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable, Coroutine
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pyee.asyncio import AsyncIOEventEmitter
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
EventHandler = Callable[..., Coroutine[Any, Any, None]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Event:
|
|
20
|
+
"""Base class for all domain events.
|
|
21
|
+
|
|
22
|
+
Subclass this in your module's contracts:
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ProductCreated(Event):
|
|
26
|
+
product_id: int
|
|
27
|
+
name: str
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EventBus:
|
|
32
|
+
"""Async event bus backed by pyee's ``AsyncIOEventEmitter``.
|
|
33
|
+
|
|
34
|
+
Modules subscribe to event types in ``register_event_handlers``.
|
|
35
|
+
Publishing dispatches to all subscribers concurrently.
|
|
36
|
+
|
|
37
|
+
* ``publish`` — awaits all handlers via ``asyncio.gather`` (error-isolated).
|
|
38
|
+
* ``publish_nowait`` — fire-and-forget via pyee's event-loop scheduling.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self._emitter = AsyncIOEventEmitter()
|
|
43
|
+
self._emitter.on("error", self._on_emitter_error)
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def _on_emitter_error(error: Exception) -> None:
|
|
47
|
+
logger.error("EventBus background handler error: %s", error, exc_info=error)
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _event_key(event_type: type[Event]) -> str:
|
|
51
|
+
return f"{event_type.__module__}.{event_type.__qualname__}"
|
|
52
|
+
|
|
53
|
+
def subscribe(self, event_type: type[Event], handler: EventHandler) -> None:
|
|
54
|
+
"""Register a handler for an event type."""
|
|
55
|
+
self._emitter.on(self._event_key(event_type), handler)
|
|
56
|
+
logger.debug(
|
|
57
|
+
"Subscribed %s to %s",
|
|
58
|
+
getattr(handler, "__qualname__", repr(handler)),
|
|
59
|
+
event_type.__name__,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def publish(self, event: Event) -> None:
|
|
63
|
+
"""Dispatch event to all registered handlers (awaited).
|
|
64
|
+
|
|
65
|
+
All handlers run concurrently via ``asyncio.gather``.
|
|
66
|
+
Individual handler failures are logged but do not propagate.
|
|
67
|
+
"""
|
|
68
|
+
handlers = self._emitter.listeners(self._event_key(type(event)))
|
|
69
|
+
if not handlers:
|
|
70
|
+
return
|
|
71
|
+
results = await asyncio.gather(
|
|
72
|
+
*(h(event) for h in handlers),
|
|
73
|
+
return_exceptions=True,
|
|
74
|
+
)
|
|
75
|
+
for i, result in enumerate(results):
|
|
76
|
+
if isinstance(result, Exception):
|
|
77
|
+
logger.error(
|
|
78
|
+
"Event handler %s failed for %s: %s",
|
|
79
|
+
getattr(handlers[i], "__qualname__", repr(handlers[i])),
|
|
80
|
+
type(event).__name__,
|
|
81
|
+
result,
|
|
82
|
+
exc_info=result,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def publish_nowait(self, event: Event) -> None:
|
|
86
|
+
"""Fire-and-forget: schedule event dispatch on the current event loop.
|
|
87
|
+
|
|
88
|
+
Uses pyee's ``AsyncIOEventEmitter.emit`` which schedules async
|
|
89
|
+
handlers as tasks on the running loop.
|
|
90
|
+
"""
|
|
91
|
+
self._emitter.emit(self._event_key(type(event)), event)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Framework exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ModuleError(Exception):
|
|
5
|
+
"""Base exception for module-related errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidModuleError(ModuleError):
|
|
9
|
+
"""Raised when a discovered module fails structural validation.
|
|
10
|
+
|
|
11
|
+
Used by strict discovery (production) to turn "missing ``meta``",
|
|
12
|
+
"not a ``ModuleBase``", and entry-point load failures into boot-time
|
|
13
|
+
errors rather than silently-skipped modules.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CircularDependencyError(ModuleError):
|
|
18
|
+
"""Raised when a circular dependency is detected between modules."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, cycle: list[str]) -> None:
|
|
21
|
+
self.cycle = cycle
|
|
22
|
+
path = " -> ".join(cycle)
|
|
23
|
+
super().__init__(f"Circular dependency detected: {path}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NotFoundError(Exception):
|
|
27
|
+
"""Raised when a requested resource is not found."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, resource: str, identifier: str | int) -> None:
|
|
30
|
+
self.resource = resource
|
|
31
|
+
self.identifier = identifier
|
|
32
|
+
super().__init__(f"{resource} with id '{identifier}' not found")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ValidationError(Exception):
|
|
36
|
+
"""Raised when input validation fails."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, errors: dict[str, list[str]]) -> None:
|
|
39
|
+
self.errors = errors
|
|
40
|
+
super().__init__(f"Validation failed: {errors}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FrameworkVersionError(ModuleError):
|
|
44
|
+
"""Raised when installed modules are incompatible with the framework API version."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, framework_version: str, failures: list[tuple[str, str, str]]) -> None:
|
|
47
|
+
# failures: list of (module_name, requires_framework, reason)
|
|
48
|
+
self.framework_version = framework_version
|
|
49
|
+
self.failures = failures
|
|
50
|
+
lines = [f" - {name}: requires '{spec}' — {reason}" for name, spec, reason in failures]
|
|
51
|
+
super().__init__(
|
|
52
|
+
f"Installed module(s) incompatible with framework API version {framework_version}:\n"
|
|
53
|
+
+ "\n".join(lines)
|
|
54
|
+
+ "\n\nResolution: upgrade the module(s), upgrade simple_module_core, "
|
|
55
|
+
"or remove the incompatible module."
|
|
56
|
+
)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Feature flag registry — modules declare toggleable features.
|
|
2
|
+
|
|
3
|
+
Overrides live at two scopes:
|
|
4
|
+
|
|
5
|
+
* **system** — applies to every request unless a tenant override exists
|
|
6
|
+
* **tenant** — applies only when ``is_enabled`` is called with a matching
|
|
7
|
+
``tenant_id``; a per-tenant value beats the system override
|
|
8
|
+
|
|
9
|
+
Resolution order: tenant override > system override > definition default.
|
|
10
|
+
|
|
11
|
+
Consumers check a flag inside a FastAPI endpoint via ``is_flag_enabled``,
|
|
12
|
+
``flag_enabled``, ``require_flag``, or the ``@feature_flag`` decorator —
|
|
13
|
+
all tenant-aware using ``request.state.tenant_id`` set by
|
|
14
|
+
``TenantMiddleware``. Every helper accepts either a
|
|
15
|
+
``FeatureFlagDefinition`` (preferred: pass the constant you registered) or
|
|
16
|
+
the raw flag name.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import functools
|
|
22
|
+
import inspect
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from fastapi import HTTPException, Request
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class FeatureFlagDefinition:
|
|
32
|
+
"""A feature that can be toggled on/off at runtime."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
description: str = ""
|
|
36
|
+
default_enabled: bool = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FeatureFlagRegistry:
|
|
40
|
+
"""In-memory registry of flag definitions and their resolved overrides."""
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
self._flags: dict[str, FeatureFlagDefinition] = {}
|
|
44
|
+
self._system_overrides: dict[str, bool] = {}
|
|
45
|
+
self._tenant_overrides: dict[tuple[str, str], bool] = {}
|
|
46
|
+
|
|
47
|
+
def add(self, flag: FeatureFlagDefinition) -> None:
|
|
48
|
+
self._flags[flag.name] = flag
|
|
49
|
+
|
|
50
|
+
def is_enabled(self, name: str, tenant_id: str | None = None) -> bool:
|
|
51
|
+
if tenant_id is not None:
|
|
52
|
+
tenant_value = self._tenant_overrides.get((name, tenant_id))
|
|
53
|
+
if tenant_value is not None:
|
|
54
|
+
return tenant_value
|
|
55
|
+
if name in self._system_overrides:
|
|
56
|
+
return self._system_overrides[name]
|
|
57
|
+
flag = self._flags.get(name)
|
|
58
|
+
return flag.default_enabled if flag else False
|
|
59
|
+
|
|
60
|
+
def set_override(self, name: str, enabled: bool, tenant_id: str | None = None) -> None:
|
|
61
|
+
if tenant_id is None:
|
|
62
|
+
self._system_overrides[name] = enabled
|
|
63
|
+
else:
|
|
64
|
+
self._tenant_overrides[(name, tenant_id)] = enabled
|
|
65
|
+
|
|
66
|
+
def clear_override(self, name: str, tenant_id: str | None = None) -> None:
|
|
67
|
+
if tenant_id is None:
|
|
68
|
+
self._system_overrides.pop(name, None)
|
|
69
|
+
else:
|
|
70
|
+
self._tenant_overrides.pop((name, tenant_id), None)
|
|
71
|
+
|
|
72
|
+
def tenant_override(self, name: str, tenant_id: str) -> bool | None:
|
|
73
|
+
"""Return the per-tenant override for ``(name, tenant_id)`` if set, else None."""
|
|
74
|
+
return self._tenant_overrides.get((name, tenant_id))
|
|
75
|
+
|
|
76
|
+
def system_override(self, name: str) -> bool | None:
|
|
77
|
+
"""Return the system-level override for ``name`` if set, else None."""
|
|
78
|
+
return self._system_overrides.get(name)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def all_flags(self) -> list[FeatureFlagDefinition]:
|
|
82
|
+
return list(self._flags.values())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _flag_name(flag: FeatureFlagDefinition | str) -> str:
|
|
86
|
+
return flag.name if isinstance(flag, FeatureFlagDefinition) else flag
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_flag_enabled(request: Request, flag: FeatureFlagDefinition | str) -> bool:
|
|
90
|
+
"""Return whether ``flag`` is enabled for this request's tenant context.
|
|
91
|
+
|
|
92
|
+
Reads the registry from ``request.app.state.sm.feature_flags`` and the
|
|
93
|
+
tenant from ``request.state.tenant_id`` (set by ``TenantMiddleware``).
|
|
94
|
+
Without a tenant on the request, falls back to the system value or the
|
|
95
|
+
definition default.
|
|
96
|
+
"""
|
|
97
|
+
registry: FeatureFlagRegistry = request.app.state.sm.feature_flags
|
|
98
|
+
tenant_id: str | None = getattr(request.state, "tenant_id", None)
|
|
99
|
+
return registry.is_enabled(_flag_name(flag), tenant_id=tenant_id)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def flag_enabled(flag: FeatureFlagDefinition | str) -> Callable[[Request], bool]:
|
|
103
|
+
"""FastAPI dep factory — yields ``True`` when the flag is on.
|
|
104
|
+
|
|
105
|
+
Usage::
|
|
106
|
+
|
|
107
|
+
async def handler(
|
|
108
|
+
on: Annotated[bool, Depends(flag_enabled(FLAG_NEW_UI))],
|
|
109
|
+
) -> ...:
|
|
110
|
+
if on: ...
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def _dep(request: Request) -> bool:
|
|
114
|
+
return is_flag_enabled(request, flag)
|
|
115
|
+
|
|
116
|
+
return _dep
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def require_flag(flag: FeatureFlagDefinition | str) -> Callable[[Request], None]:
|
|
120
|
+
"""FastAPI dep factory — raises 404 when the flag is off.
|
|
121
|
+
|
|
122
|
+
Gate an entire endpoint behind a flag::
|
|
123
|
+
|
|
124
|
+
@router.post(
|
|
125
|
+
"/bulk",
|
|
126
|
+
dependencies=[Depends(require_flag(FLAG_BULK_IMPORT))],
|
|
127
|
+
)
|
|
128
|
+
async def bulk_import(...): ...
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def _dep(request: Request) -> None:
|
|
132
|
+
if not is_flag_enabled(request, flag):
|
|
133
|
+
raise HTTPException(status_code=404, detail="Feature not available")
|
|
134
|
+
|
|
135
|
+
return _dep
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def feature_flag(
|
|
139
|
+
flag: FeatureFlagDefinition | str,
|
|
140
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
141
|
+
"""Decorator — gates an endpoint behind a flag, 404s when off.
|
|
142
|
+
|
|
143
|
+
Attribute-style alternative to ``Depends(require_flag(...))``: apply
|
|
144
|
+
directly to the handler. The decorated function must accept a
|
|
145
|
+
``request: Request`` parameter (FastAPI injects it automatically).
|
|
146
|
+
|
|
147
|
+
Usage::
|
|
148
|
+
|
|
149
|
+
@router.post("/bulk")
|
|
150
|
+
@feature_flag(FLAG_BULK_IMPORT)
|
|
151
|
+
async def bulk_import(request: Request, payload: Payload): ...
|
|
152
|
+
"""
|
|
153
|
+
name = _flag_name(flag)
|
|
154
|
+
|
|
155
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
156
|
+
sig = inspect.signature(fn, eval_str=True)
|
|
157
|
+
request_param = next(
|
|
158
|
+
(p.name for p in sig.parameters.values() if p.annotation is Request),
|
|
159
|
+
None,
|
|
160
|
+
)
|
|
161
|
+
if request_param is None:
|
|
162
|
+
raise TypeError(
|
|
163
|
+
f"@feature_flag({name!r}): {fn.__qualname__} must declare a "
|
|
164
|
+
f"'request: Request' parameter for the decorator to read tenant state"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if inspect.iscoroutinefunction(fn):
|
|
168
|
+
|
|
169
|
+
@functools.wraps(fn)
|
|
170
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
171
|
+
request = sig.bind(*args, **kwargs).arguments[request_param]
|
|
172
|
+
if not is_flag_enabled(request, name):
|
|
173
|
+
raise HTTPException(status_code=404, detail="Feature not available")
|
|
174
|
+
return await fn(*args, **kwargs)
|
|
175
|
+
|
|
176
|
+
return async_wrapper
|
|
177
|
+
|
|
178
|
+
@functools.wraps(fn)
|
|
179
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
180
|
+
request = sig.bind(*args, **kwargs).arguments[request_param]
|
|
181
|
+
if not is_flag_enabled(request, name):
|
|
182
|
+
raise HTTPException(status_code=404, detail="Feature not available")
|
|
183
|
+
return fn(*args, **kwargs)
|
|
184
|
+
|
|
185
|
+
return sync_wrapper
|
|
186
|
+
|
|
187
|
+
return decorator
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Health check registry for module-contributed health checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HealthStatus(StrEnum):
|
|
11
|
+
HEALTHY = "healthy"
|
|
12
|
+
DEGRADED = "degraded"
|
|
13
|
+
UNHEALTHY = "unhealthy"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class HealthCheckResult:
|
|
18
|
+
"""Result of a single health check."""
|
|
19
|
+
|
|
20
|
+
status: HealthStatus
|
|
21
|
+
detail: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
HealthCheckFn = Callable[[], Awaitable[HealthCheckResult]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class HealthCheck:
|
|
29
|
+
"""A named health check with an async callable."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
check: HealthCheckFn
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class HealthRegistry:
|
|
36
|
+
"""Collects health checks contributed by modules."""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._checks: list[HealthCheck] = []
|
|
40
|
+
|
|
41
|
+
def add(self, check: HealthCheck) -> None:
|
|
42
|
+
self._checks.append(check)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def all_checks(self) -> list[HealthCheck]:
|
|
46
|
+
return list(self._checks)
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Internationalization registry and translator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import MappingProxyType
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from babel import Locale
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
#: CLDR plural categories, in spec order. Used both for runtime resolution and
|
|
18
|
+
#: as the exhaustive suffix set when tools (e.g. the frontend-types emitter)
|
|
19
|
+
#: need to detect plural-variant keys.
|
|
20
|
+
PLURAL_CATEGORIES: tuple[str, ...] = ("zero", "one", "two", "few", "many", "other")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@lru_cache(maxsize=64)
|
|
24
|
+
def _plural_rule(locale: str): # type: ignore[no-untyped-def]
|
|
25
|
+
"""Cached CLDR plural rule for a locale tag (e.g. 'en', 'ru', 'pt_BR')."""
|
|
26
|
+
return Locale.parse(locale).plural_form
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _plural_form(locale: str, count: float) -> str:
|
|
30
|
+
"""Return CLDR plural category ('one', 'few', 'many', 'other', ...).
|
|
31
|
+
|
|
32
|
+
Falls back to 'other' if the locale cannot be parsed by Babel.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
rule = _plural_rule(locale)
|
|
36
|
+
except Exception:
|
|
37
|
+
return "other"
|
|
38
|
+
return rule(count)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def flatten_messages(
|
|
42
|
+
nested: dict[str, Any],
|
|
43
|
+
*,
|
|
44
|
+
prefix: str = "",
|
|
45
|
+
) -> dict[str, str]:
|
|
46
|
+
"""Flatten a nested dict of string leaves to dotted keys.
|
|
47
|
+
|
|
48
|
+
{"browse": {"title": "X"}} -> {"browse.title": "X"}
|
|
49
|
+
|
|
50
|
+
Raises ValueError if any leaf is not a string.
|
|
51
|
+
"""
|
|
52
|
+
out: dict[str, str] = {}
|
|
53
|
+
for key, value in nested.items():
|
|
54
|
+
composed = f"{prefix}.{key}" if prefix else key
|
|
55
|
+
if isinstance(value, dict):
|
|
56
|
+
out.update(flatten_messages(value, prefix=composed))
|
|
57
|
+
elif isinstance(value, str):
|
|
58
|
+
out[composed] = value
|
|
59
|
+
else:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"Locale value at '{composed}' must be string or nested dict, "
|
|
62
|
+
f"got {type(value).__name__}"
|
|
63
|
+
)
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class I18nRegistry:
|
|
68
|
+
"""Merged view of all module locale JSON files, keyed by locale.
|
|
69
|
+
|
|
70
|
+
Usage::
|
|
71
|
+
|
|
72
|
+
registry = I18nRegistry(default_locale="en", supported_locales=["en", "es"])
|
|
73
|
+
registry.add_source("products", Path("modules/products/products/locales"))
|
|
74
|
+
registry.load()
|
|
75
|
+
registry.messages("en") # {"products.browse.title": "Products", ...}
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, default_locale: str, supported_locales: list[str]) -> None:
|
|
79
|
+
self.default_locale = default_locale
|
|
80
|
+
self.supported_locales = list(supported_locales)
|
|
81
|
+
self._sources: list[tuple[str, Path]] = []
|
|
82
|
+
self._messages: dict[str, dict[str, str]] = {}
|
|
83
|
+
# Immutable views into ``_messages`` — handed out by ``messages()`` to
|
|
84
|
+
# avoid a per-call dict copy. Rebuilt whenever ``load()`` runs.
|
|
85
|
+
self._message_views: dict[str, MappingProxyType[str, str]] = {}
|
|
86
|
+
# Plain-dict snapshots for JSON-serializing callers (e.g. Inertia shared
|
|
87
|
+
# props). Built once per load; handing the same dict out on every request
|
|
88
|
+
# avoids per-request dict copies that used to dominate allocations on the
|
|
89
|
+
# Inertia render path.
|
|
90
|
+
self._message_snapshots: dict[str, dict[str, str]] = {}
|
|
91
|
+
self._available_locales: tuple[str, ...] = ()
|
|
92
|
+
self._available_locales_list: list[str] = []
|
|
93
|
+
self._empty_view: MappingProxyType[str, str] = MappingProxyType({})
|
|
94
|
+
self._empty_snapshot: dict[str, str] = {}
|
|
95
|
+
self._loaded = False
|
|
96
|
+
|
|
97
|
+
def add_source(self, namespace: str, locale_dir: Path) -> None:
|
|
98
|
+
"""Queue a module's locale directory for loading under a namespace."""
|
|
99
|
+
self._sources.append((namespace, Path(locale_dir)))
|
|
100
|
+
|
|
101
|
+
def load(self) -> None:
|
|
102
|
+
"""Read and flatten all registered JSON files.
|
|
103
|
+
|
|
104
|
+
Missing <locale>.json files for declared supported_locales log a
|
|
105
|
+
warning but do not raise. Malformed JSON raises ValueError.
|
|
106
|
+
"""
|
|
107
|
+
self._messages = {locale: {} for locale in self.supported_locales}
|
|
108
|
+
|
|
109
|
+
for namespace, locale_dir in self._sources:
|
|
110
|
+
for locale in self.supported_locales:
|
|
111
|
+
path = locale_dir / f"{locale}.json"
|
|
112
|
+
if not path.is_file():
|
|
113
|
+
logger.warning(
|
|
114
|
+
"Missing locale file for namespace '%s': %s",
|
|
115
|
+
namespace,
|
|
116
|
+
path,
|
|
117
|
+
)
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
121
|
+
except json.JSONDecodeError as exc:
|
|
122
|
+
raise ValueError(f"invalid JSON in {path}: {exc}") from exc
|
|
123
|
+
if not isinstance(raw, dict):
|
|
124
|
+
raise ValueError(f"{path} must contain a JSON object at the top level")
|
|
125
|
+
flat = flatten_messages(raw, prefix=namespace)
|
|
126
|
+
self._messages[locale].update(flat)
|
|
127
|
+
|
|
128
|
+
# Cache the derived views now that loading is complete. Downstream
|
|
129
|
+
# (middleware, translator, switcher) reads these on every request.
|
|
130
|
+
self._message_views = {
|
|
131
|
+
locale: MappingProxyType(msgs) for locale, msgs in self._messages.items()
|
|
132
|
+
}
|
|
133
|
+
# Plain-dict snapshots for serialization callers. ``dict(msgs)`` runs
|
|
134
|
+
# once here rather than on every Inertia render.
|
|
135
|
+
self._message_snapshots = {locale: dict(msgs) for locale, msgs in self._messages.items()}
|
|
136
|
+
self._available_locales = tuple(locale for locale, msgs in self._messages.items() if msgs)
|
|
137
|
+
self._available_locales_list = list(self._available_locales)
|
|
138
|
+
self._loaded = True
|
|
139
|
+
|
|
140
|
+
def available_locales(self) -> list[str]:
|
|
141
|
+
"""Locales that have at least one loaded message.
|
|
142
|
+
|
|
143
|
+
The list is cached at ``load()`` time; if ``load()`` hasn't run but
|
|
144
|
+
tests populated ``_messages`` directly, a one-off scan returns the
|
|
145
|
+
derived list without caching it (the test is outside the normal flow).
|
|
146
|
+
"""
|
|
147
|
+
if self._loaded:
|
|
148
|
+
return self._available_locales_list
|
|
149
|
+
return [locale for locale, msgs in self._messages.items() if msgs]
|
|
150
|
+
|
|
151
|
+
def messages(self, locale: str) -> Mapping[str, str]:
|
|
152
|
+
"""Flat dotted-key map for the given locale. Empty mapping if unknown.
|
|
153
|
+
|
|
154
|
+
Returns an immutable view (``MappingProxyType``) into the cached
|
|
155
|
+
message dict — zero-copy. Callers that JSON-serialize the result
|
|
156
|
+
should use :meth:`messages_snapshot` instead.
|
|
157
|
+
"""
|
|
158
|
+
view = self._message_views.get(locale)
|
|
159
|
+
if view is not None:
|
|
160
|
+
return view
|
|
161
|
+
# Fallback: ``load()`` wasn't called (tests may populate _messages
|
|
162
|
+
# directly). Expose the raw dict as a proxy so Translator still works.
|
|
163
|
+
raw = self._messages.get(locale)
|
|
164
|
+
if raw is None:
|
|
165
|
+
return self._empty_view
|
|
166
|
+
return MappingProxyType(raw)
|
|
167
|
+
|
|
168
|
+
def messages_snapshot(self, locale: str) -> dict[str, str]:
|
|
169
|
+
"""Plain-dict snapshot for callers that JSON-serialize the result.
|
|
170
|
+
|
|
171
|
+
Built once at :meth:`load` time and handed out by reference on every
|
|
172
|
+
call. Callers must treat it as read-only — mutating the returned dict
|
|
173
|
+
corrupts subsequent responses. Used by the Inertia shared-props builder
|
|
174
|
+
where it sits on the request hot path; prior to this method,
|
|
175
|
+
``dict(messages(locale))`` per request was the top own-code allocator.
|
|
176
|
+
"""
|
|
177
|
+
snapshot = self._message_snapshots.get(locale)
|
|
178
|
+
if snapshot is not None:
|
|
179
|
+
return snapshot
|
|
180
|
+
# Fallback for tests that skip ``load()``: synthesize the snapshot on
|
|
181
|
+
# demand from whatever ``_messages`` holds.
|
|
182
|
+
raw = self._messages.get(locale)
|
|
183
|
+
if raw is None:
|
|
184
|
+
return self._empty_snapshot
|
|
185
|
+
return dict(raw)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class _SafeFormatDict(dict):
|
|
189
|
+
"""Dict that returns ``{key}`` for missing keys so str.format_map doesn't raise."""
|
|
190
|
+
|
|
191
|
+
def __missing__(self, key: str) -> str:
|
|
192
|
+
return "{" + key + "}"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class Translator:
|
|
196
|
+
"""Request-scoped translator bound to a specific locale.
|
|
197
|
+
|
|
198
|
+
Construct via::
|
|
199
|
+
|
|
200
|
+
Translator(registry, locale=request.state.locale, default_locale="en")
|
|
201
|
+
|
|
202
|
+
Resolution order for :meth:`t`:
|
|
203
|
+
|
|
204
|
+
1. Look up key in ``locale``; if missing, fall back to ``default_locale``.
|
|
205
|
+
2. If still missing, return the key itself (with a debug log).
|
|
206
|
+
3. Interpolate ``{name}``-style placeholders using supplied kwargs.
|
|
207
|
+
Missing placeholders are left as ``{name}`` (not raised).
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
def __init__(
|
|
211
|
+
self,
|
|
212
|
+
registry: I18nRegistry,
|
|
213
|
+
locale: str,
|
|
214
|
+
default_locale: str,
|
|
215
|
+
) -> None:
|
|
216
|
+
self._registry = registry
|
|
217
|
+
self.locale = locale
|
|
218
|
+
self.default_locale = default_locale
|
|
219
|
+
|
|
220
|
+
def t(self, key: str, **params: Any) -> str:
|
|
221
|
+
"""Translate ``key`` with optional interpolation and plural resolution.
|
|
222
|
+
|
|
223
|
+
When ``count`` is in params, look up ``<key>_<plural_form>`` using
|
|
224
|
+
Babel's CLDR plural rule for the active locale, falling back to
|
|
225
|
+
``<key>_other`` and finally ``<key>``.
|
|
226
|
+
"""
|
|
227
|
+
resolved_key = self._resolve_plural_key(key, params)
|
|
228
|
+
template = self._lookup(resolved_key)
|
|
229
|
+
if template is None and resolved_key != key:
|
|
230
|
+
template = self._lookup(key)
|
|
231
|
+
if template is None:
|
|
232
|
+
logger.debug("i18n: missing key '%s' in locale '%s'", key, self.locale)
|
|
233
|
+
return key
|
|
234
|
+
return template.format_map(_SafeFormatDict(params))
|
|
235
|
+
|
|
236
|
+
def _resolve_plural_key(self, key: str, params: dict[str, Any]) -> str:
|
|
237
|
+
count = params.get("count")
|
|
238
|
+
if count is None:
|
|
239
|
+
return key
|
|
240
|
+
form = _plural_form(self.locale, count)
|
|
241
|
+
# Prefer the exact form; fall back to _other if that form has no entry.
|
|
242
|
+
candidate = f"{key}_{form}"
|
|
243
|
+
if self._lookup(candidate) is not None:
|
|
244
|
+
return candidate
|
|
245
|
+
other = f"{key}_other"
|
|
246
|
+
if self._lookup(other) is not None:
|
|
247
|
+
return other
|
|
248
|
+
return key
|
|
249
|
+
|
|
250
|
+
def _lookup(self, key: str) -> str | None:
|
|
251
|
+
msgs = self._registry.messages(self.locale)
|
|
252
|
+
if key in msgs:
|
|
253
|
+
return msgs[key]
|
|
254
|
+
if self.locale != self.default_locale:
|
|
255
|
+
default = self._registry.messages(self.default_locale)
|
|
256
|
+
if key in default:
|
|
257
|
+
return default[key]
|
|
258
|
+
return None
|