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.
@@ -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