diff --git a/CHANGELOG.md b/CHANGELOG.md index 40e2cd15b..b9da35de7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,8 @@ All notable changes to this project are documented in this file. The format is b ### Changed +- Cache env-supplied settings fields per class and default API version discovery per process; changes to environment, dotenv, or secret sources require a restart. +- Pre-serialize shared Inertia i18n catalogs per locale, audience, and catalog load, preserving page JSON bytes and HTML escaping. - Reduce background task polling and list reads to their required columns; count task history separately from paginated reads and index newest-first, nulls-last ordering (migration `c5d8e2f1a903`, 2026-11-05 performance review). - Scaffold new modules with `core/` and `web/` areas and mirrored nested Python tests; existing modules migrate individually. See `docs/module-layout.md`. - `OidcAuthProvider` and `KeycloakAuthProvider` gain a public `sign_in_from_claims(request, claims)`; the OIDC and Keycloak login callbacks call it instead of the private `_identity`/`_upsert_user_cache`/`_claims_to_user_context` helpers (#848). The OIDC variant returns `(UserContext, identity)` and raises the new `IdentityUnavailableError` when the user cache refuses the identity. No behaviour change. diff --git a/docs/framework/i18n.md b/docs/framework/i18n.md index 8edc6821c..013382094 100644 --- a/docs/framework/i18n.md +++ b/docs/framework/i18n.md @@ -230,6 +230,13 @@ This matters more than it sounds. The client initialises i18next with `fallbackL The practical effect is that partial translation is a **safe, incremental state**. A half-finished locale reads as a mix of two languages, not as a screen of dotted keys. +Inertia caches the serialized catalog for each registry, locale, and audience. +Full catalogs skip recursive prop resolution and encoding; the resulting JSON +bytes and HTML embedding escapes are unchanged. `I18nRegistry.load()` replaces +the snapshots, so the next render serializes the new catalogs. Nested partial +reloads still filter catalog keys normally. Registry snapshots and the shared +`i18n.messages` dictionary must be treated as read-only. + ## Translating menu labels and audit links Some strings are chosen in Python and rendered on every page. Those are translated **server-side** rather than shipped to the client as keys. diff --git a/docs/framework/settings.md b/docs/framework/settings.md index 20e375793..97a6e555e 100644 --- a/docs/framework/settings.md +++ b/docs/framework/settings.md @@ -38,6 +38,13 @@ process environment variables take precedence over that file. Both the boot `load_dotenv_into_environ()` first. Direct settings construction supports an explicit `_env_file` path or `_env_file=None` to disable dotenv loading. +The set of env-supplied fields is cached once per settings class per process, +including custom sources and `env_seeded_fields`. Configure process variables, +`.env`, and secret files before the first settings hydration; changing these +sources at runtime requires a process restart. DB-backed settings remain +live-editable. The default API version (installed host version or project +`pyproject.toml` fallback) is also discovered once per process. + When passing a `BootstrapSettings` to `merge_host_settings(bootstrap)`, every field explicitly supplied on that object is preserved, including `environment`, `modules_enabled`, `redis_url`, `debug` and `vite_dev_url`. The resolved secret diff --git a/framework/core/simple_module_core/settings_hydrate.py b/framework/core/simple_module_core/settings_hydrate.py index 2e76ec527..e90704f07 100644 --- a/framework/core/simple_module_core/settings_hydrate.py +++ b/framework/core/simple_module_core/settings_hydrate.py @@ -18,6 +18,7 @@ class at boot whether or not the ``settings`` plugin is enabled. The DB read import logging import types +from functools import cache from typing import Any, Literal, Protocol, Union, get_args, get_origin from pydantic import BaseModel @@ -105,6 +106,7 @@ def input_type_for_field(cls: type[BaseSettings], field_name: str) -> tuple[str, return "json", nullable +@cache def env_supplied_fields(cls: type[BaseSettings]) -> frozenset[str]: """Fields the class's own non-init sources (env, ``.env``, secrets) answer. @@ -114,6 +116,9 @@ def env_supplied_fields(cls: type[BaseSettings]) -> frozenset[str]: model validator can reject the env-only view (a production broker check) that the DB-merged one would pass. + Cached per class for the process lifetime: environment, dotenv and secret + files must be configured before first use; changes require a restart. + A class with no ``env_prefix`` reports nothing from its sources: a plain ``BaseSettings`` reads bare, case-insensitive names, so a field called ``user``, ``home`` or ``path`` would be pinned by the process environment @@ -128,7 +133,7 @@ def env_supplied_fields(cls: type[BaseSettings]) -> frozenset[str]: seeded = getattr(cls, "env_seeded_fields", None) if callable(seeded): found.update(seeded()) - return frozenset(found) & cls.model_fields.keys() + return frozenset(found & cls.model_fields.keys()) def _source_supplied_fields(cls: type[BaseSettings]) -> set[str]: diff --git a/framework/core/tests/test_core_settings_source_cache.py b/framework/core/tests/test_core_settings_source_cache.py new file mode 100644 index 000000000..43ba29356 --- /dev/null +++ b/framework/core/tests/test_core_settings_source_cache.py @@ -0,0 +1,45 @@ +"""Settings source metadata is a process snapshot, isolated by settings class.""" + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict +from simple_module_core.settings_hydrate import env_supplied_fields + + +def test_source_fields_are_computed_once_per_class(tmp_path, monkeypatch): + dotenv = tmp_path / "settings.env" + dotenv.write_text("CACHE_FILE=file\n") + monkeypatch.setenv("CACHE_PROCESS", "process") + calls = [] + + class Config(BaseSettings): + model_config = SettingsConfigDict(env_prefix="CACHE_", env_file=dotenv) + process: str = "" + file: str = "" + seeded: str = "" + alias: str = Field("", alias="CACHE_ALIAS") + + @classmethod + def settings_customise_sources(cls, *args, **kwargs): + calls.append("sources") + return super().settings_customise_sources(*args, **kwargs) + + @classmethod + def env_seeded_fields(cls): + calls.append("seeded") + return {"seeded", "unknown"} + + monkeypatch.setenv("CACHE_ALIAS", "alias") + assert env_supplied_fields(Config) == {"process", "file", "seeded", "alias"} + assert isinstance(env_supplied_fields(Config), frozenset) + dotenv.unlink() + monkeypatch.delenv("CACHE_PROCESS") + monkeypatch.delenv("CACHE_ALIAS") + assert env_supplied_fields(Config) == {"process", "file", "seeded", "alias"} + assert calls == ["sources", "seeded"] + + class Other(Config): + @classmethod + def env_seeded_fields(cls): + return set() + + assert env_supplied_fields(Other) == set() diff --git a/framework/hosting/simple_module_hosting/_api_version.py b/framework/hosting/simple_module_hosting/_api_version.py index e82272f94..5aec38085 100644 --- a/framework/hosting/simple_module_hosting/_api_version.py +++ b/framework/hosting/simple_module_hosting/_api_version.py @@ -2,7 +2,10 @@ from __future__ import annotations +from functools import cache + +@cache def default_api_version() -> str: """The host project's version, else ``0.0.0``. diff --git a/framework/hosting/simple_module_hosting/_i18n_json.py b/framework/hosting/simple_module_hosting/_i18n_json.py new file mode 100644 index 000000000..605c464d0 --- /dev/null +++ b/framework/hosting/simple_module_hosting/_i18n_json.py @@ -0,0 +1,28 @@ +"""Serialized catalogs owned by a registry and replaced with its snapshots.""" + +from weakref import WeakKeyDictionary + +from simple_module_core.i18n import I18nRegistry +from simple_module_inertia._json_snapshot import JsonSnapshot + +_catalogs: WeakKeyDictionary[ + I18nRegistry, dict[tuple[str, bool], tuple[dict[str, str], JsonSnapshot]] +] = WeakKeyDictionary() + + +def catalog_json(registry: I18nRegistry, locale: str, *, include_admin: bool) -> JsonSnapshot: + snapshot = registry.messages_snapshot(locale, include_admin=include_admin) + try: + pool = _catalogs.setdefault(registry, {}) + except TypeError: + # A stub registry (``__slots__``, unhashable dataclass) cannot key a weak + # dict; serve it uncached rather than fail the request. + return JsonSnapshot(snapshot) + key = (locale, include_admin) + cached = pool.get(key) + # load() replaces snapshots: identity is the catalog version, without a hash + # or a walk of the catalog on the request path. + if cached is None or cached[0] is not snapshot: + cached = (snapshot, JsonSnapshot(snapshot)) + pool[key] = cached + return cached[1] diff --git a/framework/hosting/simple_module_hosting/_inertia_shared.py b/framework/hosting/simple_module_hosting/_inertia_shared.py index 9ed351261..8ac61f6dd 100644 --- a/framework/hosting/simple_module_hosting/_inertia_shared.py +++ b/framework/hosting/simple_module_hosting/_inertia_shared.py @@ -15,6 +15,7 @@ from starlette.requests import Request from starlette.types import Scope +from simple_module_hosting._i18n_json import catalog_json from simple_module_hosting.csrf import get_csrf_token logger = logging.getLogger(__name__) @@ -80,7 +81,7 @@ def build_i18n_block(scope: Scope, request: Request, *, is_authenticated: bool = "audience": audience, "supportedLocales": registry.available_locales(), "messages": ( - registry.messages_snapshot(locale, include_admin=is_authenticated) + catalog_json(registry, locale, include_admin=is_authenticated) if send_messages else None ), diff --git a/framework/hosting/tests/test_hosting_api_version_cache.py b/framework/hosting/tests/test_hosting_api_version_cache.py new file mode 100644 index 000000000..3486f3e09 --- /dev/null +++ b/framework/hosting/tests/test_hosting_api_version_cache.py @@ -0,0 +1,10 @@ +"""Installed host version discovery is constant within a process.""" + +from simple_module_hosting._api_version import default_api_version + + +def test_host_version_discovery_is_cached(monkeypatch): + monkeypatch.setattr("importlib.metadata.version", lambda name: "4.5.6") + assert default_api_version() == "4.5.6" + monkeypatch.setattr("importlib.metadata.version", lambda name: "9.9.9") + assert default_api_version() == "4.5.6" diff --git a/framework/hosting/tests/test_hosting_catalog_json_cache.py b/framework/hosting/tests/test_hosting_catalog_json_cache.py new file mode 100644 index 000000000..f50ccbaf3 --- /dev/null +++ b/framework/hosting/tests/test_hosting_catalog_json_cache.py @@ -0,0 +1,64 @@ +"""Shared catalogs cache by registry, locale and audience, and follow reloads.""" + +from types import SimpleNamespace + +from fastapi import FastAPI +from simple_module_core.i18n import I18nRegistry +from simple_module_hosting._i18n_json import catalog_json +from simple_module_hosting._inertia_shared import build_i18n_block +from starlette.requests import Request + + +def _block(registry, locale="en", authenticated=False): + app = FastAPI() + app.state.sm = SimpleNamespace(i18n_registry=registry) + request = Request( + { + "type": "http", + "method": "GET", + "path": "/", + "headers": [], + "app": app, + "state": {"locale": locale}, + } + ) + return build_i18n_block(request.scope, request, is_authenticated=authenticated) + + +def test_catalog_json_cache_tracks_locale_audience_and_reload(tmp_path): + public = tmp_path / "public" + admin = tmp_path / "admin" + public.mkdir() + admin.mkdir() + (public / "en.json").write_text('{"title":"Hello"}') + (public / "es.json").write_text('{"title":"Hola"}') + (admin / "en.json").write_text('{"title":"Secret"}') + (admin / "es.json").write_text("{}") + registry = I18nRegistry("en", ["en", "es"]) + registry.add_source("public", public) + registry.add_source("admin", admin, audience="admin") + registry.load() + first = _block(registry)["messages"] + assert first == {"public.title": "Hello"} + assert _block(registry)["messages"] is first + assert _block(registry, "es")["messages"] == {"public.title": "Hola"} + assert _block(registry, "es", True)["messages"] == { + "public.title": "Hola", + "admin.title": "Secret", + } + assert _block(I18nRegistry("en", ["en"]))["messages"] == {} + (public / "en.json").write_text('{"title":"Updated"}') + registry.load() + updated = _block(registry)["messages"] + assert updated == {"public.title": "Updated"} + assert updated is not first + + +def test_a_registry_that_cannot_be_weak_referenced_is_served_uncached(): + class _Stub: + __slots__ = () + + def messages_snapshot(self, locale, *, include_admin): + return {"a": "A"} + + assert catalog_json(_Stub(), "en", include_admin=True) == {"a": "A"} diff --git a/framework/hosting/tests/test_hosting_openapi.py b/framework/hosting/tests/test_hosting_openapi.py index 6e4371258..e14874efe 100644 --- a/framework/hosting/tests/test_hosting_openapi.py +++ b/framework/hosting/tests/test_hosting_openapi.py @@ -128,6 +128,7 @@ def missing(_name: str) -> str: monkeypatch.setattr("importlib.metadata.version", missing) monkeypatch.setenv("SM_PROJECT_ROOT", str(tmp_path)) assert host_settings._default_api_version() == "4.5.6" + host_settings._default_api_version.cache_clear() monkeypatch.setenv("SM_PROJECT_ROOT", str(tmp_path / "nope")) assert host_settings._default_api_version() == "0.0.0" @@ -150,8 +151,10 @@ def missing(_name: str) -> str: (tmp_path / "host" / "pyproject.toml").write_bytes(b"\xff\xfe not utf8") assert host_settings._default_api_version() == "9.9.9" # unreadable: skipped + host_settings._default_api_version.cache_clear() (tmp_path / "host" / "pyproject.toml").write_text("[project\n") assert host_settings._default_api_version() == "9.9.9" # invalid TOML: skipped + host_settings._default_api_version.cache_clear() (tmp_path / "host" / "pyproject.toml").write_text( '[project]\nname = "h"\ndynamic = ["version"]\n' ) diff --git a/framework/inertia/simple_module_inertia/_json_snapshot.py b/framework/inertia/simple_module_inertia/_json_snapshot.py new file mode 100644 index 000000000..c3ebf874b --- /dev/null +++ b/framework/inertia/simple_module_inertia/_json_snapshot.py @@ -0,0 +1,54 @@ +"""Pre-serialized JSON for the shared i18n catalog (path props.i18n.messages). + +Exists only so the catalog can be spliced into page JSON without re-walking it. +""" + +from __future__ import annotations + +import json +from typing import Any + +from fastapi.encoders import jsonable_encoder + +_HTML_ENCODER = json.JSONEncoder() +_RESPONSE_ENCODER = json.JSONEncoder(ensure_ascii=False, allow_nan=False, separators=(",", ":")) + + +class JsonSnapshot(dict): + """The shared i18n catalog (props.i18n.messages), encoded once per response format. + + Like registry snapshots, callers must never mutate this dict. Keeping the + dict interface preserves shared-prop consumers outside Inertia rendering. + Only wrap the trusted static catalog: dynamic props must go through resolution. + """ + + def __init__(self, value: dict) -> None: + super().__init__(jsonable_encoder(value)) + self.html_json = _HTML_ENCODER.encode(self) + self.response_json = _RESPONSE_ENCODER.encode(self) + + +def dumps(value: Any, *, compact: bool = False) -> str: + """Splice the shared i18n catalog into page JSON using the original format. + + Only descend along props.i18n.messages. Other props keep the C encoder's + fast path, including large lists. HTML escaping still happens afterwards + in htmlsafe_json_dumps, covering the catalog and the rest of the page. + """ + encoder = _RESPONSE_ENCODER if compact else _HTML_ENCODER + + def encode(item: Any, path: tuple[str, ...]) -> str: + if isinstance(item, JsonSnapshot): + return item.response_json if compact else item.html_json + if not path or not isinstance(item, dict) or path[0] not in item: + return encoder.encode(item) + parts = [] + for key, child in item.items(): + if key == path[0]: + parts.append(encoder.encode(key) + encoder.key_separator + encode(child, path[1:])) + else: + # Strip only this single-entry object's surrounding braces. + parts.append(encoder.encode({key: child})[1:-1]) + return "{" + encoder.item_separator.join(parts) + "}" + + return encode(value, ("props", "i18n", "messages")) diff --git a/framework/inertia/simple_module_inertia/inertia.py b/framework/inertia/simple_module_inertia/inertia.py index 40d076dcd..52ab89281 100644 --- a/framework/inertia/simple_module_inertia/inertia.py +++ b/framework/inertia/simple_module_inertia/inertia.py @@ -9,6 +9,7 @@ from pydantic import BaseModel from starlette.responses import RedirectResponse, Response +from simple_module_inertia._json_snapshot import dumps from simple_module_inertia.config import InertiaConfig, resolved_version from simple_module_inertia.errors import InertiaVersionConflictException from simple_module_inertia.head import HEAD_PROP, Head, SiteInfo, head_tags @@ -140,7 +141,7 @@ def _render_html( # encode_page yields a JSON-safe dict; htmlsafe_json_dumps serialises it # exactly once with HTML-safe escaping. Passing a pre-dumped string here # would double-encode the page object into a quoted JSON literal. - page_json = htmlsafe_json_dumps(encode_page(page)) + page_json = htmlsafe_json_dumps(encode_page(page), dumps=dumps) context = { "inertia": InertiaContext( environment=self._config.environment, diff --git a/framework/inertia/simple_module_inertia/page.py b/framework/inertia/simple_module_inertia/page.py index 4c0bea037..d5aed4702 100644 --- a/framework/inertia/simple_module_inertia/page.py +++ b/framework/inertia/simple_module_inertia/page.py @@ -16,6 +16,7 @@ from fastapi.encoders import jsonable_encoder +from simple_module_inertia._json_snapshot import JsonSnapshot from simple_module_inertia.resolve import JSON_SCALARS, ResolvedProps @@ -127,6 +128,8 @@ def _is_plain_json(value: Any) -> bool: if depth >= _MAX_PLAIN_DEPTH: return False stack.extend((child, depth + 1) for child in item) + elif kind is JsonSnapshot: + continue elif kind not in JSON_SCALARS: return False return True @@ -137,6 +140,6 @@ def encode_page(page: dict[str, Any]) -> Any: if _is_plain_json(page): return page try: - return jsonable_encoder(page) + return jsonable_encoder(page, custom_encoder={JsonSnapshot: lambda value: value}) except Exception as exc: raise PropEncodingError(_locate_failure(page["props"], "props"), exc) from exc diff --git a/framework/inertia/simple_module_inertia/resolve.py b/framework/inertia/simple_module_inertia/resolve.py index ef31b7c75..445c754ae 100644 --- a/framework/inertia/simple_module_inertia/resolve.py +++ b/framework/inertia/simple_module_inertia/resolve.py @@ -13,6 +13,7 @@ from dataclasses import dataclass, field from typing import Any +from simple_module_inertia._json_snapshot import JsonSnapshot from simple_module_inertia.props import Prop, as_prop, resolve_value from simple_module_inertia.request import InertiaRequest @@ -175,6 +176,8 @@ async def _resolve_children( out: ResolvedProps, bypass: bool, ) -> Any: + if isinstance(value, JsonSnapshot) and (not partial or bypass): + return value if isinstance(value, dict): return await _resolve_tree(value, req, partial, out, path, bypass) if isinstance(value, list): diff --git a/framework/inertia/simple_module_inertia/response.py b/framework/inertia/simple_module_inertia/response.py index 7fd0ec727..827f2a8b4 100644 --- a/framework/inertia/simple_module_inertia/response.py +++ b/framework/inertia/simple_module_inertia/response.py @@ -13,6 +13,7 @@ from fastapi.responses import HTMLResponse, JSONResponse from starlette.responses import RedirectResponse, Response +from simple_module_inertia._json_snapshot import dumps from simple_module_inertia.page import encode_page InertiaResponse = HTMLResponse | JSONResponse @@ -20,8 +21,13 @@ _JSON_HEADERS = {"X-Inertia": "true", "Vary": "Accept"} +class _PageJSONResponse(JSONResponse): + def render(self, content: Any) -> bytes: + return dumps(content, compact=True).encode("utf-8") + + def json_response(page: dict[str, Any]) -> JSONResponse: - return JSONResponse(content=encode_page(page), headers=_JSON_HEADERS) + return _PageJSONResponse(content=encode_page(page), headers=_JSON_HEADERS) def version_conflict(url: str, version: str) -> Response: diff --git a/framework/inertia/tests/test_inertia_json_snapshot.py b/framework/inertia/tests/test_inertia_json_snapshot.py new file mode 100644 index 000000000..63bf663a9 --- /dev/null +++ b/framework/inertia/tests/test_inertia_json_snapshot.py @@ -0,0 +1,140 @@ +"""Pre-serialized catalogs keep wire bytes and partial filtering unchanged.""" + +import json +from datetime import UTC, datetime + +import pytest +from jinja2.utils import htmlsafe_json_dumps +from simple_module_inertia._json_snapshot import ( + _HTML_ENCODER, + _RESPONSE_ENCODER, + JsonSnapshot, + dumps, +) +from simple_module_inertia.page import _is_plain_json, build_page, encode_page +from simple_module_inertia.request import InertiaRequest +from simple_module_inertia.resolve import resolve_props +from simple_module_inertia.response import json_response +from starlette.responses import JSONResponse + + +async def _page(messages, headers=None, rich=False): + resolved = await resolve_props( + {"when": datetime(2026, 11, 5, tzinfo=UTC)} if rich else {}, + {"i18n": {"messages": messages}}, + "C", + InertiaRequest.from_headers(headers or {}, "GET"), + ) + return build_page(component="C", resolved=resolved, url="/", version="v", errors={}) + + +@pytest.mark.parametrize("rich", [False, True]) +async def test_snapshot_skips_walks_and_preserves_json_bytes(rich, monkeypatch): + messages = {"a": "é