simple-module-hosting 0.0.31__tar.gz → 0.0.32__tar.gz
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.
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/PKG-INFO +3 -3
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/pyproject.toml +3 -3
- simple_module_hosting-0.0.32/simple_module_hosting/_inertia_cache.py +104 -0
- simple_module_hosting-0.0.32/simple_module_hosting/_inertia_json.py +86 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_inertia_setup.py +5 -1
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_phase_helpers.py +7 -1
- simple_module_hosting-0.0.32/tests/test_inertia_cache.py +187 -0
- simple_module_hosting-0.0.32/tests/test_inertia_json_encoder.py +83 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_middleware_order.py +9 -1
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/.gitignore +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/LICENSE +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/README.md +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/__init__.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/__main__.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_db_health.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_error_handlers.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_host_services.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_hydrate_step.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_inertia_shared.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_observability.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_registrations.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/app_builder.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/assets.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/bootstrap_settings.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/csrf.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/health.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/host_cli.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/host_settings.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/i18n_deps.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/i18n_middleware.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/inertia_deps.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/inertia_utils.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/logging.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/manifest.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/middleware.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/migrations.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/permissions.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/py.typed +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/redirects.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/settings.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/shared_props.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/static_files.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_api_error_negotiation.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_app.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_auth_provider_setting.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_branding_head.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_check_migrations.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_csp_module_sources.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_db_health_check.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_design_packs_wiring.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_error_page_shared_props.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_health.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_host_cli.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_hosting_csrf.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_hosting_permissions.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_inertia_i18n_shared_props.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_inertia_manifest.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_inertia_shared_providers.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_lifespan_order.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_locale_middleware.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_logging.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_manifest.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_menu_urls_are_canonical.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_precompressed_static.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_proxy_headers.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_redirects.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_response_compression.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_session_cookie_security.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_settings_i18n.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_settings_secrets.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_sqlite_path_anchor.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_static_caching.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_strict_discovery_wiring.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_tenant_middleware.py +0 -0
- {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_translator_dep.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_hosting
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.32
|
|
4
4
|
Summary: FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -27,8 +27,8 @@ Requires-Dist: fastapi>=0.115
|
|
|
27
27
|
Requires-Dist: httpx>=0.27
|
|
28
28
|
Requires-Dist: itsdangerous>=2.0
|
|
29
29
|
Requires-Dist: jinja2>=3.1
|
|
30
|
-
Requires-Dist: simple-module-core==0.0.
|
|
31
|
-
Requires-Dist: simple-module-db==0.0.
|
|
30
|
+
Requires-Dist: simple-module-core==0.0.32
|
|
31
|
+
Requires-Dist: simple-module-db==0.0.32
|
|
32
32
|
Requires-Dist: starlette>=0.44
|
|
33
33
|
Requires-Dist: tomlkit>=0.13
|
|
34
34
|
Requires-Dist: uvicorn[standard]>=0.34
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_hosting"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.32"
|
|
4
4
|
description = "FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -30,8 +30,8 @@ dependencies = [
|
|
|
30
30
|
# it directly — out-of-tree hosts have nothing else pulling it in.
|
|
31
31
|
"itsdangerous>=2.0",
|
|
32
32
|
"jinja2>=3.1",
|
|
33
|
-
"simple_module_core==0.0.
|
|
34
|
-
"simple_module_db==0.0.
|
|
33
|
+
"simple_module_core==0.0.32",
|
|
34
|
+
"simple_module_db==0.0.32",
|
|
35
35
|
"starlette>=0.44",
|
|
36
36
|
"tomlkit>=0.13",
|
|
37
37
|
"uvicorn[standard]>=0.34",
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Keep the Inertia payload out of caches that answer page requests.
|
|
2
|
+
|
|
3
|
+
Every Inertia route serves one URL as two representations, chosen on the
|
|
4
|
+
request's ``X-Inertia`` header: an HTML document for a full page load, a JSON
|
|
5
|
+
payload for a client-side visit. Nothing in either response says so, which
|
|
6
|
+
leaves a cache free to store one and hand it back for the other. Visit a page
|
|
7
|
+
through the SPA, then open the same URL directly, and the browser can serve the
|
|
8
|
+
stored payload as the document — the visitor gets
|
|
9
|
+
``{"component":"...","props":{...}}`` where the page should be.
|
|
10
|
+
|
|
11
|
+
That only bites once a route marks itself cacheable, which a public-content
|
|
12
|
+
module reasonably does. The framework is what makes it unsafe:
|
|
13
|
+
:class:`~simple_module_hosting.middleware.InertiaLayoutDataMiddleware` merges
|
|
14
|
+
the signed-in user's ``auth`` block, their permission list and the menus their
|
|
15
|
+
roles resolve to into *every* Inertia payload. A route author choosing
|
|
16
|
+
``Cache-Control: public`` for their page content has no way to know that, so
|
|
17
|
+
the guarantee belongs here rather than in each module:
|
|
18
|
+
|
|
19
|
+
* **An Inertia payload is never stored.** ``private, no-store``, and the ETag
|
|
20
|
+
is dropped so no cache can revalidate its way back to a copy it should not
|
|
21
|
+
have kept. The cost is per-visit caching on client-side navigation, which was
|
|
22
|
+
never safe to take — those bytes are specific to one user.
|
|
23
|
+
* **Both representations declare ``Vary: X-Inertia``**, so a cache that honours
|
|
24
|
+
Vary keeps them in separate entries instead of inferring it from ``Accept``.
|
|
25
|
+
|
|
26
|
+
``Vary`` is added to the document only when the response is HTML, so static
|
|
27
|
+
assets and JSON APIs keep the validators and cache entries they had.
|
|
28
|
+
|
|
29
|
+
A module that wants its public page content cached should give the *document*
|
|
30
|
+
its own ``Cache-Control`` and an ETag that identifies the representation; this
|
|
31
|
+
middleware leaves that path alone and only governs the payload.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from starlette.datastructures import MutableHeaders
|
|
37
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
38
|
+
|
|
39
|
+
_SCOPE_HTTP = "http"
|
|
40
|
+
_HEADER_INERTIA = b"x-inertia"
|
|
41
|
+
|
|
42
|
+
#: What an Inertia payload is allowed to say about its own cacheability.
|
|
43
|
+
PAYLOAD_CACHE_CONTROL = "private, no-store"
|
|
44
|
+
|
|
45
|
+
#: The request header that selects the representation, and therefore the one
|
|
46
|
+
#: caches must key on.
|
|
47
|
+
VARY_FIELD = "X-Inertia"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def add_vary(headers: MutableHeaders, field: str = VARY_FIELD) -> None:
|
|
51
|
+
"""Add a field to ``Vary``, keeping whatever is already listed."""
|
|
52
|
+
existing = headers.get("vary")
|
|
53
|
+
if not existing:
|
|
54
|
+
headers["vary"] = field
|
|
55
|
+
return
|
|
56
|
+
if any(part.strip().lower() == field.lower() for part in existing.split(",")):
|
|
57
|
+
return
|
|
58
|
+
headers["vary"] = f"{existing}, {field}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_inertia_request(scope: Scope) -> bool:
|
|
62
|
+
"""Whether this request asked for the JSON representation.
|
|
63
|
+
|
|
64
|
+
Mirrors ``fastapi-inertia``'s own ``Inertia._is_inertia_request`` exactly —
|
|
65
|
+
presence of the header, any value — rather than requiring it to equal
|
|
66
|
+
``"true"``. The library renders JSON for *any* ``X-Inertia`` value, so a
|
|
67
|
+
stricter check here would disagree with it: a request the renderer treats
|
|
68
|
+
as Inertia would sail through uncached, undoing the whole fix.
|
|
69
|
+
"""
|
|
70
|
+
return any(key == _HEADER_INERTIA for key, _ in scope.get("headers", ()))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_html(headers: MutableHeaders) -> bool:
|
|
74
|
+
return headers.get("content-type", "").split(";", 1)[0].strip() == "text/html"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class InertiaCacheMiddleware:
|
|
78
|
+
"""Stop an Inertia payload being cached as though it were the page."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
81
|
+
self.app = app
|
|
82
|
+
|
|
83
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
84
|
+
if scope["type"] != _SCOPE_HTTP:
|
|
85
|
+
await self.app(scope, receive, send)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
inertia = is_inertia_request(scope)
|
|
89
|
+
|
|
90
|
+
async def send_with_cache_rules(message: Message) -> None:
|
|
91
|
+
if message["type"] == "http.response.start":
|
|
92
|
+
headers = MutableHeaders(scope=message)
|
|
93
|
+
if inertia:
|
|
94
|
+
headers["cache-control"] = PAYLOAD_CACHE_CONTROL
|
|
95
|
+
del headers["etag"]
|
|
96
|
+
add_vary(headers)
|
|
97
|
+
elif _is_html(headers):
|
|
98
|
+
add_vary(headers)
|
|
99
|
+
await send(message)
|
|
100
|
+
|
|
101
|
+
await self.app(scope, receive, send_with_cache_rules)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = ["PAYLOAD_CACHE_CONTROL", "VARY_FIELD", "InertiaCacheMiddleware", "add_vary"]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Make Inertia's JSON branch encode props the way its HTML branch does.
|
|
2
|
+
|
|
3
|
+
``fastapi-inertia`` renders the same props two ways and configures only one of
|
|
4
|
+
them::
|
|
5
|
+
|
|
6
|
+
# full page load — honours InertiaConfig.json_encoder
|
|
7
|
+
json_string = json.dumps(page_data, cls=self._config.json_encoder)
|
|
8
|
+
|
|
9
|
+
# client-side visit — Starlette's JSONResponse, so plain json.dumps
|
|
10
|
+
return JSONResponse(content=await self._get_page_data(), ...)
|
|
11
|
+
|
|
12
|
+
``InertiaJsonEncoder`` exists precisely to run the payload through FastAPI's
|
|
13
|
+
``jsonable_encoder``, and the second branch never reaches it. The effect is a
|
|
14
|
+
route that works on a reload and 500s on every client-side visit, for any prop
|
|
15
|
+
the stdlib encoder cannot handle — ``Path``, ``Decimal``, ``UUID``, a dataclass,
|
|
16
|
+
an enum.
|
|
17
|
+
|
|
18
|
+
Settings → Modules is where this surfaced: it reflects each installed module's
|
|
19
|
+
pydantic settings back to the admin, and a module whose settings carry a
|
|
20
|
+
``Path`` field (``PagebuilderSettings.media_root``) puts a ``PosixPath`` in the
|
|
21
|
+
payload::
|
|
22
|
+
|
|
23
|
+
TypeError: Object of type PosixPath is not JSON serializable
|
|
24
|
+
|
|
25
|
+
Fixing it here rather than in each module is deliberate. A module putting a
|
|
26
|
+
rich value in props is not doing anything wrong — the HTML branch has always
|
|
27
|
+
accepted it — so the asymmetry is the defect, and closing it at the point of
|
|
28
|
+
serialisation covers every module and every prop type at once.
|
|
29
|
+
|
|
30
|
+
Applied by wrapping the dependency published on ``app.state.inertia_dependency``,
|
|
31
|
+
which ``inertia_deps.get_inertia`` calls once per request. The replacement binds
|
|
32
|
+
to the instance, so the library's class is untouched and any other construction
|
|
33
|
+
path keeps the stock behaviour.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import logging
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from fastapi.encoders import jsonable_encoder
|
|
42
|
+
from starlette.responses import JSONResponse
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
#: The headers upstream's ``_render_json`` sets, reproduced so the wrap is a
|
|
47
|
+
#: pure encoder change. ``InertiaCacheMiddleware`` owns the caching rules and
|
|
48
|
+
#: extends ``Vary`` on the way out.
|
|
49
|
+
_JSON_HEADERS = {"X-Inertia": "true", "Vary": "Accept"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def json_safe_inertia_dependency(inertia_dep: Any) -> Any:
|
|
53
|
+
"""Wrap an Inertia dependency so its JSON branch uses ``jsonable_encoder``.
|
|
54
|
+
|
|
55
|
+
The wrapped callable keeps the ``(request, client)`` shape
|
|
56
|
+
``inertia_dependency_factory`` returns. An instance that doesn't expose the
|
|
57
|
+
private hook this relies on is handed back untouched: the failure mode is
|
|
58
|
+
the 500 that already happens, and refusing to boot because an upstream
|
|
59
|
+
attribute moved would be a worse trade.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def dependency(request: Any, client: Any = None) -> Any:
|
|
63
|
+
inertia = inertia_dep(request, client)
|
|
64
|
+
if hasattr(inertia, "_get_page_data"):
|
|
65
|
+
inertia._render_json = _render_json_for(inertia)
|
|
66
|
+
else: # pragma: no cover - upstream layout changed
|
|
67
|
+
logger.warning(
|
|
68
|
+
"Inertia instance has no _get_page_data; JSON responses keep the "
|
|
69
|
+
"stock encoder and non-JSON-native props will fail to serialise"
|
|
70
|
+
)
|
|
71
|
+
return inertia
|
|
72
|
+
|
|
73
|
+
return dependency
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _render_json_for(inertia: Any) -> Any:
|
|
77
|
+
"""Build the instance's replacement ``_render_json``."""
|
|
78
|
+
|
|
79
|
+
async def _render_json() -> JSONResponse:
|
|
80
|
+
page_data = await inertia._get_page_data()
|
|
81
|
+
return JSONResponse(content=jsonable_encoder(page_data), headers=_JSON_HEADERS)
|
|
82
|
+
|
|
83
|
+
return _render_json
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
__all__ = ["json_safe_inertia_dependency"]
|
|
@@ -12,6 +12,7 @@ from fastapi import FastAPI
|
|
|
12
12
|
from inertia import InertiaConfig, inertia_dependency_factory
|
|
13
13
|
from starlette.requests import Request
|
|
14
14
|
|
|
15
|
+
from simple_module_hosting._inertia_json import json_safe_inertia_dependency
|
|
15
16
|
from simple_module_hosting.settings import Settings
|
|
16
17
|
|
|
17
18
|
logger = logging.getLogger(__name__)
|
|
@@ -184,6 +185,9 @@ def setup_inertia(
|
|
|
184
185
|
use_flash_errors=True,
|
|
185
186
|
)
|
|
186
187
|
|
|
187
|
-
|
|
188
|
+
# Upstream's JSON branch builds a Starlette JSONResponse directly, so the
|
|
189
|
+
# encoder configured above only ever applies to full page loads. Wrap the
|
|
190
|
+
# dependency so a client-side visit encodes the same props the same way.
|
|
191
|
+
inertia_dep = json_safe_inertia_dependency(inertia_dependency_factory(inertia_config))
|
|
188
192
|
app.state.inertia_dependency = inertia_dep
|
|
189
193
|
return inertia_config
|
|
@@ -34,6 +34,7 @@ from simple_module_hosting._error_handlers import (
|
|
|
34
34
|
unhandled_exception_handler,
|
|
35
35
|
)
|
|
36
36
|
from simple_module_hosting._host_services import _HostServices
|
|
37
|
+
from simple_module_hosting._inertia_cache import InertiaCacheMiddleware
|
|
37
38
|
from simple_module_hosting.host_settings import HostSettings
|
|
38
39
|
from simple_module_hosting.i18n_middleware import LocaleMiddleware
|
|
39
40
|
from simple_module_hosting.middleware import (
|
|
@@ -103,12 +104,17 @@ def install_middleware(
|
|
|
103
104
|
Order matters: last added = first executed. Execution order:
|
|
104
105
|
(ProxyHeaders, if trusted_proxy) → CorrelationId → RequestLogging
|
|
105
106
|
→ Security → Session → [module] → (Tenant, if multi_tenant) → Locale
|
|
106
|
-
→ Inertia → CommitBeforeResponse.
|
|
107
|
+
→ Inertia → InertiaCache → CommitBeforeResponse.
|
|
107
108
|
"""
|
|
108
109
|
# Added first, so it is innermost and its send-wrapper is the first to see
|
|
109
110
|
# the response: the request's DB work commits before any byte reaches the
|
|
110
111
|
# client, instead of in get_db's post-response exit code (GH #257).
|
|
111
112
|
app.add_middleware(CommitBeforeResponseMiddleware)
|
|
113
|
+
# Paired with InertiaLayoutDataMiddleware below, which is what puts this
|
|
114
|
+
# user's auth, permissions and menus into every Inertia payload: this one
|
|
115
|
+
# makes sure the payload that results is never stored where a page request
|
|
116
|
+
# can be answered with it.
|
|
117
|
+
app.add_middleware(InertiaCacheMiddleware)
|
|
112
118
|
app.add_middleware(
|
|
113
119
|
InertiaLayoutDataMiddleware,
|
|
114
120
|
menu_registry=menu_registry,
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""An Inertia payload must never be cacheable as the page it belongs to.
|
|
2
|
+
|
|
3
|
+
The same URL answers twice — HTML for a full page load, JSON for a client-side
|
|
4
|
+
visit — and the only thing separating them is the ``X-Inertia`` request header.
|
|
5
|
+
Left unsaid, a cache stores one and serves it for the other, so opening a page
|
|
6
|
+
directly after visiting it through the SPA renders the raw payload. The payload
|
|
7
|
+
also carries this user's ``auth`` block, permissions and menus, so a shared
|
|
8
|
+
cache storing it is a disclosure bug as well as a broken page.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import pytest
|
|
15
|
+
from simple_module_hosting._inertia_cache import (
|
|
16
|
+
PAYLOAD_CACHE_CONTROL,
|
|
17
|
+
InertiaCacheMiddleware,
|
|
18
|
+
is_inertia_request,
|
|
19
|
+
)
|
|
20
|
+
from starlette.applications import Starlette
|
|
21
|
+
from starlette.responses import HTMLResponse, JSONResponse, Response
|
|
22
|
+
from starlette.routing import Route
|
|
23
|
+
|
|
24
|
+
_OK = 200
|
|
25
|
+
_INERTIA = {"X-Inertia": "true"}
|
|
26
|
+
#: What a public-content module might reasonably put on its own page.
|
|
27
|
+
_PUBLIC_CACHE = "public, max-age=60, stale-while-revalidate=600"
|
|
28
|
+
_SHARED_ETAG = 'W/"deadbeef"'
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _vary_fields(response: httpx.Response) -> set[str]:
|
|
32
|
+
return {part.strip().lower() for part in response.headers.get("vary", "").split(",")}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _build_app() -> Starlette:
|
|
36
|
+
"""A route that markets itself as publicly cacheable, both ways.
|
|
37
|
+
|
|
38
|
+
Mirrors what the page-builder's public viewer does: one ETag and one
|
|
39
|
+
``Cache-Control`` computed from the page row, stamped on whichever
|
|
40
|
+
representation the request asked for.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
async def page(request):
|
|
44
|
+
if is_inertia_request(request.scope):
|
|
45
|
+
response: Response = JSONResponse(
|
|
46
|
+
{"component": "PublicPage", "props": {"auth": {"user": "editor"}}},
|
|
47
|
+
headers={"X-Inertia": "true", "Vary": "Accept"},
|
|
48
|
+
)
|
|
49
|
+
else:
|
|
50
|
+
response = HTMLResponse("<!DOCTYPE html><title>Page</title>")
|
|
51
|
+
response.headers["Cache-Control"] = _PUBLIC_CACHE
|
|
52
|
+
response.headers["ETag"] = _SHARED_ETAG
|
|
53
|
+
return response
|
|
54
|
+
|
|
55
|
+
async def asset(request):
|
|
56
|
+
return Response(b"body{}", media_type="text/css", headers={"ETag": _SHARED_ETAG})
|
|
57
|
+
|
|
58
|
+
app = Starlette(routes=[Route("/p/home", page), Route("/static/app.css", asset)])
|
|
59
|
+
app.add_middleware(InertiaCacheMiddleware)
|
|
60
|
+
return app
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.fixture
|
|
64
|
+
def cache_client() -> httpx.AsyncClient:
|
|
65
|
+
return httpx.AsyncClient(
|
|
66
|
+
transport=httpx.ASGITransport(app=_build_app()), base_url="http://test"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestInertiaPayload:
|
|
71
|
+
async def test_payload_is_not_stored_even_when_the_route_says_public(
|
|
72
|
+
self, cache_client: httpx.AsyncClient
|
|
73
|
+
) -> None:
|
|
74
|
+
"""The route asked for `public`; the payload is per-user, so it loses."""
|
|
75
|
+
async with cache_client as client:
|
|
76
|
+
resp = await client.get("/p/home", headers=_INERTIA)
|
|
77
|
+
|
|
78
|
+
assert resp.status_code == _OK
|
|
79
|
+
assert resp.headers["cache-control"] == PAYLOAD_CACHE_CONTROL
|
|
80
|
+
|
|
81
|
+
async def test_payload_carries_no_validator(self, cache_client: httpx.AsyncClient) -> None:
|
|
82
|
+
"""A shared ETag lets a cache revalidate its way back to the payload.
|
|
83
|
+
|
|
84
|
+
The route stamps the same ETag on both representations, so a client
|
|
85
|
+
holding the JSON could revalidate a *document* request into a 304 and
|
|
86
|
+
keep rendering it. Nothing to revalidate means nothing to restore.
|
|
87
|
+
"""
|
|
88
|
+
async with cache_client as client:
|
|
89
|
+
resp = await client.get("/p/home", headers=_INERTIA)
|
|
90
|
+
|
|
91
|
+
assert "etag" not in resp.headers
|
|
92
|
+
|
|
93
|
+
async def test_payload_varies_on_the_header_that_selected_it(
|
|
94
|
+
self, cache_client: httpx.AsyncClient
|
|
95
|
+
) -> None:
|
|
96
|
+
async with cache_client as client:
|
|
97
|
+
resp = await client.get("/p/home", headers=_INERTIA)
|
|
98
|
+
|
|
99
|
+
assert "x-inertia" in _vary_fields(resp)
|
|
100
|
+
# Whatever the route already listed stays listed.
|
|
101
|
+
assert "accept" in _vary_fields(resp)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestMatchesUpstreamDetection:
|
|
105
|
+
"""``is_inertia_request`` must agree with ``fastapi-inertia``'s own check.
|
|
106
|
+
|
|
107
|
+
Upstream's ``Inertia._is_inertia_request`` is ``"X-Inertia" in
|
|
108
|
+
self._request.headers`` — presence only, any value. A stricter check here
|
|
109
|
+
(e.g. requiring the value to equal ``"true"``) would disagree with it: a
|
|
110
|
+
request the renderer treats as Inertia and answers with JSON would sail
|
|
111
|
+
through this middleware uncached, reopening the exact leak the fix closes.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def test_any_header_value_counts_as_inertia(self) -> None:
|
|
115
|
+
scope = {"type": "http", "headers": [(b"x-inertia", b"false")]}
|
|
116
|
+
|
|
117
|
+
assert is_inertia_request(scope) is True
|
|
118
|
+
|
|
119
|
+
async def test_a_non_true_value_still_gets_the_safety_headers(self) -> None:
|
|
120
|
+
"""A route that mirrors upstream's own (presence-only) detection.
|
|
121
|
+
|
|
122
|
+
Unlike ``cache_client``'s app, this handler does not call
|
|
123
|
+
``is_inertia_request`` itself — it reproduces upstream's check
|
|
124
|
+
directly, so the two are genuinely independent here.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
async def page(request):
|
|
128
|
+
if "x-inertia" in request.headers:
|
|
129
|
+
response = JSONResponse({"component": "Home", "props": {}})
|
|
130
|
+
else:
|
|
131
|
+
response = HTMLResponse("<!DOCTYPE html><title>Page</title>")
|
|
132
|
+
response.headers["Cache-Control"] = _PUBLIC_CACHE
|
|
133
|
+
response.headers["ETag"] = _SHARED_ETAG
|
|
134
|
+
return response
|
|
135
|
+
|
|
136
|
+
app = Starlette(routes=[Route("/p/home", page)])
|
|
137
|
+
app.add_middleware(InertiaCacheMiddleware)
|
|
138
|
+
transport = httpx.ASGITransport(app=app)
|
|
139
|
+
|
|
140
|
+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
141
|
+
resp = await client.get("/p/home", headers={"X-Inertia": "false"})
|
|
142
|
+
|
|
143
|
+
assert resp.headers["content-type"].startswith("application/json")
|
|
144
|
+
assert resp.headers["cache-control"] == PAYLOAD_CACHE_CONTROL
|
|
145
|
+
assert "etag" not in resp.headers
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TestDocumentResponse:
|
|
149
|
+
async def test_document_varies_on_x_inertia(self, cache_client: httpx.AsyncClient) -> None:
|
|
150
|
+
"""Otherwise a cached document can be served to a client-side visit."""
|
|
151
|
+
async with cache_client as client:
|
|
152
|
+
resp = await client.get("/p/home")
|
|
153
|
+
|
|
154
|
+
assert "x-inertia" in _vary_fields(resp)
|
|
155
|
+
|
|
156
|
+
async def test_document_keeps_the_caching_the_route_chose(
|
|
157
|
+
self, cache_client: httpx.AsyncClient
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Public page content stays cacheable — that half was never the bug."""
|
|
160
|
+
async with cache_client as client:
|
|
161
|
+
resp = await client.get("/p/home")
|
|
162
|
+
|
|
163
|
+
assert resp.headers["cache-control"] == _PUBLIC_CACHE
|
|
164
|
+
assert resp.headers["etag"] == _SHARED_ETAG
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class TestUnrelatedResponses:
|
|
168
|
+
async def test_static_assets_are_untouched(self, cache_client: httpx.AsyncClient) -> None:
|
|
169
|
+
"""Adding Vary or dropping ETags here would cost every asset its 304."""
|
|
170
|
+
async with cache_client as client:
|
|
171
|
+
resp = await client.get("/static/app.css")
|
|
172
|
+
|
|
173
|
+
assert resp.headers["etag"] == _SHARED_ETAG
|
|
174
|
+
assert "x-inertia" not in _vary_fields(resp)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class TestAgainstTheRealApp:
|
|
178
|
+
async def test_a_rendered_view_never_returns_a_storable_payload(
|
|
179
|
+
self, authenticated_client: httpx.AsyncClient
|
|
180
|
+
) -> None:
|
|
181
|
+
"""End to end, through the whole middleware pipeline."""
|
|
182
|
+
resp = await authenticated_client.get("/dashboard/", headers=_INERTIA)
|
|
183
|
+
|
|
184
|
+
assert resp.status_code == _OK
|
|
185
|
+
assert resp.headers["content-type"].startswith("application/json")
|
|
186
|
+
assert resp.headers["cache-control"] == PAYLOAD_CACHE_CONTROL
|
|
187
|
+
assert "x-inertia" in _vary_fields(resp)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Both Inertia render paths must encode props the same way.
|
|
2
|
+
|
|
3
|
+
Upstream configures ``InertiaConfig.json_encoder`` — which exists to run props
|
|
4
|
+
through FastAPI's ``jsonable_encoder`` — and then only applies it on the full
|
|
5
|
+
page load. A client-side visit builds a Starlette ``JSONResponse``, reaching
|
|
6
|
+
plain ``json.dumps``. Any prop the stdlib cannot encode therefore renders on a
|
|
7
|
+
reload and 500s when reached by clicking a link, which is a miserable bug to
|
|
8
|
+
read from a stack trace: the page "works", right up until it is navigated to.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
from simple_module_hosting._inertia_json import json_safe_inertia_dependency
|
|
18
|
+
from starlette.responses import JSONResponse
|
|
19
|
+
|
|
20
|
+
_OK = 200
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _FakeInertia:
|
|
24
|
+
"""Stands in for the library's Inertia, with the two hooks the wrap uses."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, page_data: dict) -> None:
|
|
27
|
+
self._page_data = page_data
|
|
28
|
+
self.rendered_by_stock_encoder = False
|
|
29
|
+
|
|
30
|
+
async def _get_page_data(self) -> dict:
|
|
31
|
+
return self._page_data
|
|
32
|
+
|
|
33
|
+
async def _render_json(self) -> JSONResponse:
|
|
34
|
+
# What upstream does: no encoder, so a rich value raises here.
|
|
35
|
+
self.rendered_by_stock_encoder = True
|
|
36
|
+
return JSONResponse(content=self._page_data)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _dependency_for(page_data: dict):
|
|
40
|
+
inertia = _FakeInertia(page_data)
|
|
41
|
+
dependency = json_safe_inertia_dependency(lambda request, client=None: inertia)
|
|
42
|
+
return dependency(object(), None)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestJsonSafeRendering:
|
|
46
|
+
async def test_a_path_prop_would_break_the_stock_encoder(self) -> None:
|
|
47
|
+
"""Establishes the bug this wrap exists for, so the fix can't drift."""
|
|
48
|
+
with pytest.raises(TypeError, match="PosixPath"):
|
|
49
|
+
json.dumps({"props": {"media_root": Path("var/media")}})
|
|
50
|
+
|
|
51
|
+
async def test_a_path_prop_renders(self) -> None:
|
|
52
|
+
inertia = _dependency_for({"props": {"media_root": Path("var/media")}})
|
|
53
|
+
|
|
54
|
+
response = await inertia._render_json()
|
|
55
|
+
|
|
56
|
+
assert response.status_code == _OK
|
|
57
|
+
assert json.loads(bytes(response.body))["props"]["media_root"] == "var/media"
|
|
58
|
+
|
|
59
|
+
async def test_the_wrap_replaces_the_stock_path(self) -> None:
|
|
60
|
+
"""Not merely catching the error afterwards — the encoder is swapped."""
|
|
61
|
+
inertia = _dependency_for({"props": {}})
|
|
62
|
+
|
|
63
|
+
await inertia._render_json()
|
|
64
|
+
|
|
65
|
+
assert inertia.rendered_by_stock_encoder is False
|
|
66
|
+
|
|
67
|
+
async def test_inertia_headers_are_preserved(self) -> None:
|
|
68
|
+
inertia = _dependency_for({"props": {}})
|
|
69
|
+
|
|
70
|
+
response = await inertia._render_json()
|
|
71
|
+
|
|
72
|
+
assert response.headers["x-inertia"] == "true"
|
|
73
|
+
|
|
74
|
+
async def test_an_unfamiliar_instance_is_left_alone(self) -> None:
|
|
75
|
+
"""An upstream rename must not take the boot down with it."""
|
|
76
|
+
|
|
77
|
+
class _Unfamiliar:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
original = _Unfamiliar()
|
|
81
|
+
wrapped = json_safe_inertia_dependency(lambda request, client=None: original)
|
|
82
|
+
|
|
83
|
+
assert wrapped(object(), None) is original
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_middleware_order.py
RENAMED
|
@@ -4,7 +4,13 @@ CLAUDE.md spells out the pipeline:
|
|
|
4
4
|
|
|
5
5
|
CorrelationId → RequestLogging → GZip → Security → Session → <module>
|
|
6
6
|
→ Tenant (opt-in) → Locale → InertiaLayoutData
|
|
7
|
-
→ CommitBeforeResponse → app
|
|
7
|
+
→ InertiaCache → CommitBeforeResponse → app
|
|
8
|
+
|
|
9
|
+
InertiaCache sits directly inside InertiaLayoutData, the middleware that puts
|
|
10
|
+
this user's auth block, permissions and menus into every Inertia payload. Being
|
|
11
|
+
inside it means its send-wrapper sees the response before anything else can
|
|
12
|
+
read the headers, and pairing the two keeps "what makes the payload per-user"
|
|
13
|
+
and "what stops the payload being cached" from drifting apart.
|
|
8
14
|
|
|
9
15
|
Tenant/Locale must see ``request.state.user`` set by AuthMiddleware so
|
|
10
16
|
DB queries get filtered correctly; CorrelationId must wrap everything so
|
|
@@ -45,6 +51,7 @@ _EXPECTED_MULTI_TENANT = (
|
|
|
45
51
|
"TenantMiddleware",
|
|
46
52
|
"LocaleMiddleware",
|
|
47
53
|
"InertiaLayoutDataMiddleware",
|
|
54
|
+
"InertiaCacheMiddleware",
|
|
48
55
|
"CommitBeforeResponseMiddleware",
|
|
49
56
|
)
|
|
50
57
|
|
|
@@ -58,6 +65,7 @@ _EXPECTED_SINGLE_TENANT = (
|
|
|
58
65
|
"AuthMiddleware",
|
|
59
66
|
"LocaleMiddleware",
|
|
60
67
|
"InertiaLayoutDataMiddleware",
|
|
68
|
+
"InertiaCacheMiddleware",
|
|
61
69
|
"CommitBeforeResponseMiddleware",
|
|
62
70
|
)
|
|
63
71
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/__init__.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/__main__.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_db_health.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_hydrate_step.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/app_builder.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/assets.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/health.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/host_cli.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/host_settings.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/i18n_deps.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/i18n_manifest.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/inertia_deps.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/inertia_utils.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/logging.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/manifest.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/middleware.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/migrations.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/permissions.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/py.typed
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/redirects.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/settings.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/shared_props.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/static_files.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_api_error_negotiation.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_auth_provider_setting.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_check_migrations.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_csp_module_sources.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_design_packs_wiring.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_error_page_shared_props.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_hosting_permissions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_inertia_manifest.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_inertia_shared_providers.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_locale_middleware.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_menu_urls_are_canonical.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_precompressed_static.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_response_compression.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_session_cookie_security.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_settings_secrets.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_sqlite_path_anchor.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_strict_discovery_wiring.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_tenant_middleware.py
RENAMED
|
File without changes
|
|
File without changes
|