Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions framework/hosting/simple_module_hosting/i18n_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ def emit_frontend_types_for_modules(
settings: Settings,
installed_modules: list[ModuleBase],
project_root: Path,
*,
strict: bool = False,
) -> None:
"""Emit the TS key union covering every *installed* module.

Expand All @@ -77,20 +79,33 @@ def emit_frontend_types_for_modules(
inactive module's strings are typed, never served.
"""
registry, _ = build_i18n_registry(settings, installed_modules, project_root)
emit_frontend_types(registry, project_root)
emit_frontend_types(registry, project_root, strict=strict)


def emit_frontend_types(registry: I18nRegistry, project_root: Path) -> None:
def emit_frontend_types(
registry: I18nRegistry, project_root: Path, *, strict: bool = False
) -> None:
"""Write the TS augmentation files into @simple-module-py/i18n if present.

Logs but does not raise on failure — stale types are preferable to a
broken boot. Dev-loop only; callers should gate on ``is_development``.

``strict=True`` re-raises instead, for a tool whose whole job is this
regeneration (``make gen-i18n``). There a swallowed failure only defers the
error to a ``tsc`` run several steps removed from the cause. Under strict a
missing package directory is an error too, where on the boot path it is the
normal shape of a wheel-installed app that ships no i18n workspace.
"""
pkg_src = project_root / "packages" / "i18n" / "src"
if not pkg_src.is_dir():
if strict:
raise FileNotFoundError(f"{pkg_src} does not exist — nothing to regenerate")
return
try:
pkg_src = project_root / "packages" / "i18n" / "src"
if pkg_src.is_dir():
write_generated_resources(registry, pkg_src)
write_generated_resources(registry, pkg_src)
except Exception:
if strict:
raise
logger.exception("Failed to write generated-resources.ts — frontend types will be stale")


Expand Down
53 changes: 52 additions & 1 deletion framework/hosting/tests/test_i18n_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

from pathlib import Path

import pytest
from simple_module_core.i18n import I18nRegistry
from simple_module_hosting.i18n_manifest import write_generated_resources
from simple_module_hosting.i18n_manifest import emit_frontend_types, write_generated_resources


def test_writes_file_with_flat_keys(tmp_path: Path) -> None:
Expand Down Expand Up @@ -114,3 +115,53 @@ def test_keys_tree_does_not_overwrite_real_key_with_stem(tmp_path: Path) -> None
text = (tmp_path / "keys.generated.ts").read_text()
# The real key retains its value; the virtual stem is skipped.
assert "count: 'products.browse.count'" in text


class TestEmitFrontendTypesStrictness:
"""`make gen-i18n` must fail loudly where a live boot prefers stale types."""

def _registry(self) -> I18nRegistry:
reg = I18nRegistry(default_locale="en", supported_locales=["en"])
reg._messages = {"en": {"host.landing.title": "Hello"}}
return reg

def test_writes_into_the_i18n_package(self, tmp_path: Path) -> None:
pkg_src = tmp_path / "packages" / "i18n" / "src"
pkg_src.mkdir(parents=True)
emit_frontend_types(self._registry(), tmp_path, strict=True)
assert (pkg_src / "generated-resources.ts").is_file()
assert (pkg_src / "keys.generated.ts").is_file()

def test_missing_package_is_silent_on_the_boot_path(self, tmp_path: Path) -> None:
"""A wheel-installed app ships no i18n workspace — that is not an error."""
emit_frontend_types(self._registry(), tmp_path)
assert not (tmp_path / "packages").exists()

def test_missing_package_raises_under_strict(self, tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError):
emit_frontend_types(self._registry(), tmp_path, strict=True)

def test_write_failure_is_swallowed_on_the_boot_path(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
pkg_src = tmp_path / "packages" / "i18n" / "src"
pkg_src.mkdir(parents=True)
monkeypatch.setattr(
"simple_module_hosting.i18n_manifest.write_generated_resources", _explode
)
emit_frontend_types(self._registry(), tmp_path) # logged, not raised

def test_write_failure_raises_under_strict(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
pkg_src = tmp_path / "packages" / "i18n" / "src"
pkg_src.mkdir(parents=True)
monkeypatch.setattr(
"simple_module_hosting.i18n_manifest.write_generated_resources", _explode
)
with pytest.raises(OSError, match="disk full"):
emit_frontend_types(self._registry(), tmp_path, strict=True)


def _explode(*args: object, **kwargs: object) -> None:
raise OSError("disk full")
2 changes: 1 addition & 1 deletion modules/users/tests/test_user_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ async def test_calls_user_db_update_with_dict(self, manager, fake_user_db, fake_


# ---------------------------------------------------------------------------
# generate_verification_token
# mint_invite_token
# ---------------------------------------------------------------------------


Expand Down
16 changes: 14 additions & 2 deletions scripts/gen_i18n.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Regenerate the typed i18n key files without booting the host. `make gen-i18n`."""

import logging
from pathlib import Path

from simple_module_core.discovery import discover_modules
Expand All @@ -9,6 +10,14 @@
ROOT = Path(__file__).resolve().parent.parent

if __name__ == "__main__":
# i18n_manifest logs one INFO line per file it actually writes. Without a
# handler those lines are below the root logger's default threshold, and the
# script's own closing line reads identically whether it regenerated both
# files or wrote nothing at all. Scoped to that one logger: the root stays at
# WARNING so discovery's per-module chatter doesn't bury the two lines that
# answer the question this command was run to ask.
logging.basicConfig(level=logging.WARNING, format="%(message)s")
logging.getLogger("simple_module_hosting.i18n_manifest").setLevel(logging.INFO)
# Env-only Settings() — no merge_host_settings, so a DB-stored
# i18n_default_locale override is invisible here. Accepted trade-off for a
# tool that must not boot the app (and thus must not touch the DB).
Expand All @@ -19,5 +28,8 @@
# same subset rather than drifting to "every installed module" while the
# running app types fewer.
modules = discover_modules(enabled=settings.modules_enabled, strict=not settings.is_development)
emit_frontend_types_for_modules(settings, modules, ROOT)
print("i18n key files regenerated")
# strict: a live boot prefers stale types to a failed start, but this command
# exists only to write those files. Failing here beats handing `tsc` a stale
# union and letting it report a missing key that the catalog does contain.
emit_frontend_types_for_modules(settings, modules, ROOT, strict=True)
print("i18n key files up to date")
6 changes: 3 additions & 3 deletions tests/e2e/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,9 @@ def verify_token_secret() -> str:
def mint_verify_token(user_id: str, email: str, secret: str) -> str:
"""Mint a fastapi-users verification/invite token locally.

The token shape mirrors what ``UserManager.generate_verification_token``
produces, so the server's ``/api/users/auth/accept-invite`` endpoint
accepts it without modification.
The token shape mirrors what ``UserManager.mint_invite_token`` produces, so
the server's ``/api/users/auth/accept-invite`` endpoint accepts it without
modification.

Audience: ``"fastapi-users:verify"`` — same as the invite flow.
Lifetime: 3600 seconds (sufficient for a test run).
Expand Down
Loading