From 2968deb1adfbad753ebc60d4558fdb45bcddd381 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 5 Nov 2026 17:21:06 +0100 Subject: [PATCH 1/4] perf(settings,inertia): cache settings source metadata and pre-serialise the i18n catalog Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- CHANGELOG.md | 2 + conftest.py | 15 ++ docs/framework/i18n.md | 7 + docs/framework/settings.md | 7 + .../simple_module_core/settings_hydrate.py | 7 +- .../tests/test_core_settings_source_cache.py | 45 ++++++ .../simple_module_hosting/_api_version.py | 3 + .../simple_module_hosting/_i18n_json.py | 23 +++ .../simple_module_hosting/_inertia_shared.py | 3 +- .../tests/test_hosting_api_version_cache.py | 10 ++ .../tests/test_hosting_catalog_json_cache.py | 53 +++++++ .../hosting/tests/test_hosting_openapi.py | 3 + .../inertia/simple_module_inertia/inertia.py | 3 +- .../inertia/simple_module_inertia/json.py | 51 ++++++ .../inertia/simple_module_inertia/page.py | 5 +- .../inertia/simple_module_inertia/resolve.py | 3 + .../inertia/simple_module_inertia/response.py | 8 +- .../tests/test_inertia_json_snapshot.py | 149 ++++++++++++++++++ .../tests/test_settings_field_sources.py | 2 + 19 files changed, 394 insertions(+), 5 deletions(-) create mode 100644 framework/core/tests/test_core_settings_source_cache.py create mode 100644 framework/hosting/simple_module_hosting/_i18n_json.py create mode 100644 framework/hosting/tests/test_hosting_api_version_cache.py create mode 100644 framework/hosting/tests/test_hosting_catalog_json_cache.py create mode 100644 framework/inertia/simple_module_inertia/json.py create mode 100644 framework/inertia/tests/test_inertia_json_snapshot.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1881de9e2..6b0fb07c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,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. These hot paths were identified by the 2026-11-05 Locust and py-spy run. - Scaffold new modules with `core/` and `web/` areas and mirrored nested Python tests; existing modules migrate individually. See `docs/module-layout.md`. - `mail` follows the module layout: `mail.core.{constants,settings,state,data_compatibility}` and `mail.delivery.{sender,runtime}`. `mail.settings` and `mail.constants` still import, with a `DeprecationWarning`, until 0.0.46. - `site_lock` follows the module layout: `site_lock.core.{constants,settings,state}` and `site_lock.web.{middleware,page,rate_limit}` (the unlock template moves with the page). `site_lock.settings` still imports, with a `DeprecationWarning`, until 0.0.46. diff --git a/conftest.py b/conftest.py index e4ecfb533..6f88be6e6 100644 --- a/conftest.py +++ b/conftest.py @@ -13,3 +13,18 @@ """ from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def _process_settings_caches(): + """Tests mutate process wiring; each test represents a fresh process.""" + from simple_module_core.settings_hydrate import env_supplied_fields + from simple_module_hosting._api_version import default_api_version + + env_supplied_fields.cache_clear() + default_api_version.cache_clear() + yield + env_supplied_fields.cache_clear() + default_api_version.cache_clear() diff --git a/docs/framework/i18n.md b/docs/framework/i18n.md index 8edc6821c..4be2208ab 100644 --- a/docs/framework/i18n.md +++ b/docs/framework/i18n.md @@ -226,6 +226,13 @@ Shipping a locale is not the same as enabling it. `es.json` files exist across t Each non-default locale's snapshot is layered **over** the default locale's before it reaches the client, so an untranslated key renders in the default language rather than as a raw dotted key. +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. + This matters more than it sounds. The client initialises i18next with `fallbackLng` set to the *active* locale, so there is no cross-locale fallback in the browser — a key missing from the payload renders as `dashboard.home.system_meta` on screen. The server-side `Translator` has always fallen back to the default locale, so without this the two paths disagreed: menu labels (translated server-side) rendered English while the page body showed keys. 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. 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..0d2d2e376 --- /dev/null +++ b/framework/hosting/simple_module_hosting/_i18n_json.py @@ -0,0 +1,23 @@ +"""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 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) + pool = _catalogs.setdefault(registry, {}) + 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..7b3989d7e --- /dev/null +++ b/framework/hosting/tests/test_hosting_catalog_json_cache.py @@ -0,0 +1,53 @@ +"""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._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 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/inertia.py b/framework/inertia/simple_module_inertia/inertia.py index 40d076dcd..69a1cf56d 100644 --- a/framework/inertia/simple_module_inertia/inertia.py +++ b/framework/inertia/simple_module_inertia/inertia.py @@ -12,6 +12,7 @@ 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 +from simple_module_inertia.json import dumps from simple_module_inertia.manifest import entry_assets from simple_module_inertia.page import build_page, encode_page from simple_module_inertia.request import InertiaRequest @@ -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/json.py b/framework/inertia/simple_module_inertia/json.py new file mode 100644 index 000000000..84cf48c48 --- /dev/null +++ b/framework/inertia/simple_module_inertia/json.py @@ -0,0 +1,51 @@ +"""JSON snapshots for trusted static props, with byte-preserving page encoding.""" + +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): + """Read-only-by-contract JSON data, encoded once for each response format. + + Like registry snapshots, callers must never mutate this dict. Keeping the + dict interface preserves shared-prop consumers outside Inertia rendering. + Only wrap trusted static data: 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/page.py b/framework/inertia/simple_module_inertia/page.py index 4c0bea037..279b1cc19 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 import JsonSnapshot from simple_module_inertia.resolve import JSON_SCALARS, ResolvedProps @@ -115,6 +116,8 @@ def _is_plain_json(value: Any) -> bool: stack = [(value, 0)] while stack: item, depth = stack.pop() + if isinstance(item, JsonSnapshot): + continue kind = type(item) if kind is dict: if depth >= _MAX_PLAIN_DEPTH: @@ -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..cc1942fd8 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 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..551c644a9 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 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..1b56742e9 --- /dev/null +++ b/framework/inertia/tests/test_inertia_json_snapshot.py @@ -0,0 +1,149 @@ +"""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.page import 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): + from simple_module_inertia.json import ( + _HTML_ENCODER, + _RESPONSE_ENCODER, + JsonSnapshot, + dumps, + ) + from simple_module_inertia.page import _is_plain_json + + messages = {"a": "é