Skip to content
Merged
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions docs/framework/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions docs/framework/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion framework/core/simple_module_core/settings_hydrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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]:
Expand Down
45 changes: 45 additions & 0 deletions framework/core/tests/test_core_settings_source_cache.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 3 additions & 0 deletions framework/hosting/simple_module_hosting/_api_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.

Expand Down
28 changes: 28 additions & 0 deletions framework/hosting/simple_module_hosting/_i18n_json.py
Original file line number Diff line number Diff line change
@@ -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]
3 changes: 2 additions & 1 deletion framework/hosting/simple_module_hosting/_inertia_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
),
Expand Down
10 changes: 10 additions & 0 deletions framework/hosting/tests/test_hosting_api_version_cache.py
Original file line number Diff line number Diff line change
@@ -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"
64 changes: 64 additions & 0 deletions framework/hosting/tests/test_hosting_catalog_json_cache.py
Original file line number Diff line number Diff line change
@@ -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"}
3 changes: 3 additions & 0 deletions framework/hosting/tests/test_hosting_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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'
)
Expand Down
54 changes: 54 additions & 0 deletions framework/inertia/simple_module_inertia/_json_snapshot.py
Original file line number Diff line number Diff line change
@@ -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"))
3 changes: 2 additions & 1 deletion framework/inertia/simple_module_inertia/inertia.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion framework/inertia/simple_module_inertia/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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
3 changes: 3 additions & 0 deletions framework/inertia/simple_module_inertia/resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading