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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# 038. Storage binary reads and relative copy; request-scoped Vite and Inertia state

Date: 2026-10-25

## Problem

Storage (`storage/drivers/local.py`, `s3.py`):

- `LocalDriver.copy` passed its arguments straight to `shutil.copyfile`, so `Storage.copy("a.txt", "b.txt")` resolved against the process working directory instead of the disk root and raised `FileNotFoundError`.
- `LocalDriver.get` opened files in text mode and `S3Driver.get` decoded the body as UTF-8, so reading a binary file such as a `.png` raised `UnicodeDecodeError`. No driver offered a bytes-returning read.
- `S3Driver.append` built the combined content but uploaded only the new `content`, so every append replaced the object.

Vite (`vite/vite.py`):

- `use_script_tag_attributes`, `use_style_tag_attributes` and `use_preload_tag_attributes` wrapped a dict as `attributes = lambda *_: attributes`. The lambda closes over the rebound name, so it returned itself and `dict.update` raised `TypeError`. Passing a dict, as documented, never worked.
- The CSP nonce and preloaded-asset map were instance attributes on the single `vite` container binding. A nonce set while handling one request was used by every concurrent request.

Inertia (`inertia/inertia.py`):

- `Inertia.set_root_view` and `Inertia.version` always wrote to the process-wide `ResponseFactory`. Called inside a request, they changed the root view and version for every concurrent and later request. ADR 007 already gives each request an `InertiaRequestState`; these two setters ignored it.

## Alternatives

Binary reads:

1. Change `get` to always return `bytes`. Breaks every caller and test that expects text.
2. Add only a `get_bytes` method. Keeps `get` crashing on binary files.
3. `get` reads bytes and returns `str` when the content is valid UTF-8, otherwise the raw `bytes`; plus a `get_bytes` that always returns `bytes`.

Per-request Vite state:

1. Create a `Vite` instance per request. Every template global and facade would need a request-aware lookup.
2. Keep the nonce and preloaded assets in bare `ContextVar`s. A value set in a child task never reaches the parent. A dict created lazily in an outer context (boot, warm-up, CLI) is inherited by every later request task, which brings the leak back. A `BaseHTTPMiddleware` that reads after `call_next` sees nothing, because the endpoint runs in a child task. A nonce set during lifespan, which uvicorn runs in its own task, never reaches requests.
3. Give each task its own state, the way `Cache.memo` does (ADR 029). This removes the leak, but a middleware reading after `call_next` still sees nothing, and a nonce set at startup still does not act as a default.
4. Open an explicit request scope in a small ASGI middleware. The scope holds the state object for that request and shares it with every child task of the request. Outside a scope, the instance-level values act as process-wide defaults.

## Decision

- `LocalDriver.copy` resolves both paths through `get_path`, like `move`. Absolute paths still work because `os.path.join` keeps them.
- Binary reads take option 3 on both the local and S3 drivers. `get_bytes` returns `None` for a missing file, the same as `get`. `decode_text` in `storage/helper.py` is shared. `StorageManager` and the `Storage` facade proxy `get_bytes`. Reading in binary mode means `get` no longer converts `\r\n` to `\n`.
- `get` only decodes UTF-8. A text file in another encoding (for example latin-1 `caf\xe9`) is not valid UTF-8, so `get` returns it as `bytes`. Callers that need a fixed type use `get_bytes` and decode it themselves.
- `S3Driver.append` uploads the existing content followed by the new content, and returns the new content like `LocalDriver.append`.
- Vite turns non-callable attributes into a resolver through `_as_resolver`, which binds the value instead of the name. Dicts and `False` (to skip preloads) now work.
- Vite takes option 4. `Vite.request_scope()` is a context manager. It sets a fresh `ViteRequestState` (nonce and preloaded assets) in a per-instance `ContextVar` and resets it on exit. A nested scope reuses the outer state. Inside a scope, `use_csp_nonce` sets the request nonce and `csp_nonce` returns it, falling back to the process-wide default. `preloaded_assets` and `flush` act on the request dict. Outside a scope, they act on the instance-level default nonce and dict, the same as before this change. So a nonce set at startup or in lifespan is the default for every request.
- `ViteMiddleware` is a pure ASGI middleware. It wraps each `http` and `websocket` scope in `vite.request_scope()` and passes `lifespan` through unchanged. It takes `vite=` or resolves `vite` from the container on first use. `ViteProvider.boot` registers it when FastAPI is installed. Because it is pure ASGI, the endpoint and any inner `BaseHTTPMiddleware` share the same state object, so a Link-header middleware inside it can read `preloaded_assets()` after `call_next`. Middleware added with `app.add_middleware` after providers boot sits outside the provider's `ViteMiddleware`. If such a middleware reads preloaded assets, add `ViteMiddleware` again after it. The outer instance opens the scope, and the inner one reuses it.
- The manifest cache stays shared, because it is keyed by path and does not depend on the request.
- `ResponseFactory.set_root_view` and `set_version` write to the current `InertiaRequestState` when one exists, and to the global defaults otherwise, the same as `share` and `set_encrypt_history`. A version set during a request is resolved immediately and wins over the middleware's version for that request.

## Validation

- Local driver: relative `copy`, a UTF-8 `get`, a binary `get` and `get_bytes`, and a missing file through `get_bytes`. The S3 driver gets matching tests plus `append` with and without existing content, and a `Storage.fake` test covers the facade.
- Vite: dict attributes for script, style and preload tags, and `False` for preloads. `tests/vite/test_request_scope.py` covers the following:
- An outer render followed by two concurrent requests: each request sees only its own key.
- Three concurrent requests with a default nonce: two override it, and one keeps the default.
- A nonce set in a separate startup task reaches requests.
- An inner `BaseHTTPMiddleware` reads the preloaded assets after `call_next`.
- An outer `ViteMiddleware` wraps middleware added after the provider's.
- Lifespan passes through, nested scopes are reused, container resolution works, and the provider registers the middleware.
- Inertia: two concurrent requests through `InertiaMiddleware` each set a root view and version, then wait on a shared barrier. Each response returns its own values, and the global defaults are unchanged. Outside a request, the setters still configure the defaults.
- All new tests fail on the old code.
1 change: 1 addition & 0 deletions docs/adr/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ Read this index first, then open the records relevant to the change. Before impl
| [029](029-cache-module.md) | 2026-10-21 | Cache module with remember, memo, locks and tags | New `fastapi_startkit.cache`: async `Cache.get/put/has/forget/flush`, `get` with a callable default, and `remember(key, ttl, factory)` that runs a sync or async factory only on a miss; `remember(key, ttl)` without a factory is a decorator keyed by `key` plus a hash of the call arguments. Stores implement an async contract with a `MISSING` sentinel (cached `None` is a hit): array, file (pickled per-key files), database (masoniteorm table) and redis (named connection from the existing `RedisManager`, `SET PX` TTL, flush by `SCAN` of prefixed keys, never `FLUSHDB`). TTL in seconds or `timedelta`, `None` forever, <=0 forgets. `CacheConfig` via `resolve_config`; `CacheProvider` binds `cache`; facade `remember` resolves lazily. `Cache.memo()` memoizes reads per asyncio task through a `ContextVar` owned by the task, so requests never share memo state. `Cache.lock(name, seconds, owner)` gives atomic, owner-checked, non-re-entrant locks with `get`, `block` (250 ms polling, `LockTimeoutError`), `release`, `force_release` and `async with`, per store: event-loop dict, `fcntl.flock` file, `lock:<name>` row with update-if-expired, Redis `SET NX PX` plus a Lua owner-checked delete. `Cache.tags([...])` uses Laravel's namespaced keys: random tag ids at `<prefix>:tag:<name>:key`, entries at `<prefix>:sha1(ids):<key>`, flush rewrites the ids; tags are sorted and deduplicated. Shipped in slices into `feature/caches`; the former memo, lock and tags ADRs (030, 031, 034) are folded in. |
| [032](032-migrate-seed-option.md) | 2026-10-21 | db:migrate:fresh and db:migrate:refresh --seed run seeders in the same event loop | `--seed` called the unregistered `seed:run` through Cleo `call` (which also drops the first argument) inside a running event loop, where `db:seed` would call `asyncio.run` again. The seeder selection moves into async `DBSeedCommand.run_seeders`, which `db:seed`, `db:migrate:fresh` and `db:migrate:refresh` all await. `db:migrate:refresh` is now registered. |
| [033](033-async-seeder-stub.md) | 2026-10-21 | Generated seeders have an async run | `create_seed.stub` generated a synchronous `run` with docstrings, but `Seeder` awaits `run()`, so every generated table seeder raised `TypeError`. The stub now generates `async def run(self) -> None` with no docstrings; `Seeder` is unchanged. |
| [038](038-storage-binary-copy-and-request-scoped-vite-inertia.md) | 2026-10-25 | Storage binary reads and relative copy; request-scoped Vite and Inertia state | `LocalDriver.copy` resolves both paths through `get_path`. Local and S3 `get` read bytes and return `str` for UTF-8 content or raw `bytes` otherwise, and a new `get_bytes` (also on `StorageManager`/`Storage`) always returns bytes. `get` returns `bytes` for any non-UTF-8 content, including latin-1 text. `S3Driver.append` keeps the existing content. Vite `use_*_tag_attributes` accept dicts and `False`; the self-referencing lambda is gone. A pure ASGI `ViteMiddleware` (registered by `ViteProvider`) opens `Vite.request_scope()`, which holds the request's CSP nonce and preloaded assets in a per-instance `ContextVar` shared with child tasks. Outside a scope, the instance values are process-wide defaults, so a nonce set at startup still applies, and middleware inside the scope can read preloaded assets after `call_next`. `Inertia.set_root_view`/`Inertia.version` write to the current `InertiaRequestState` inside a request and to global defaults otherwise. |
3 changes: 3 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/facades/Vite.pyi
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from contextlib import AbstractContextManager
from typing import Callable, Optional

class Vite:
Expand Down Expand Up @@ -85,6 +86,8 @@ class Vite:
"""Reset per-request state (call after each response in long-running servers)."""
...

@staticmethod
def request_scope() -> AbstractContextManager[None]: ...
@staticmethod
def to_html() -> str:
"""Render pre-configured entry points as an HTML string."""
Expand Down
13 changes: 11 additions & 2 deletions fastapi_startkit/src/fastapi_startkit/inertia/inertia.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ def __init__(self):
self.preserve_big_integers_enabled: bool = False

def set_root_view(self, view: str):
self.root_view = view
state = current_state.get()
if state is not None:
state.root_view = view
else:
self.root_view = view

def share(self, key: str | dict[str, Any], value: Any = None):
state = current_state.get()
Expand All @@ -58,7 +62,12 @@ def shared(self) -> dict[str, Any]:
return {**self.shared_props, **request_props}

def set_version(self, version):
self.version = version
state = current_state.get()
if state is not None:
state.resolved_version = self.resolve_version(version)
state.version_resolved = True
else:
self.version = version

def get_version(self) -> str | None:
state = current_state.get()
Expand Down
14 changes: 8 additions & 6 deletions fastapi_startkit/src/fastapi_startkit/storage/drivers/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from ..filestream import FileStream
from ..file import File
from ..helper import get_extension
from ..helper import decode_text, get_extension


class LocalDriver:
Expand Down Expand Up @@ -55,11 +55,13 @@ def put_file(self, file_path, content, name=None):
return os.path.join(file_path, file_name)

def get(self, file_path):
try:
with open(self.get_path(file_path), "r") as f:
content = f.read()
content = self.get_bytes(file_path)
return None if content is None else decode_text(content)

return content
def get_bytes(self, file_path):
try:
with open(self.get_path(file_path), "rb") as f:
return f.read()
except FileNotFoundError:
return None

Expand All @@ -75,7 +77,7 @@ def stream(self, file_path):
return FileStream(content)

def copy(self, from_file_path, to_file_path):
return copyfile(from_file_path, to_file_path)
return copyfile(self.get_path(from_file_path), self.get_path(to_file_path))

def move(self, from_file_path, to_file_path):
return move(self.get_path(from_file_path), self.get_path(to_file_path))
Expand Down
16 changes: 9 additions & 7 deletions fastapi_startkit/src/fastapi_startkit/storage/drivers/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import uuid

from ..file import File
from ..helper import get_extension
from ..helper import decode_text, get_extension


class S3Driver:
Expand Down Expand Up @@ -67,12 +67,14 @@ def put_file(self, file_path, content, name=None):
return os.path.join(file_path, file_name)

def get(self, file_path):
content = self.get_bytes(file_path)
return None if content is None else decode_text(content)

def get_bytes(self, file_path):
try:
return (
self.get_resource().Bucket(self.get_bucket()).Object(file_path).get().get("Body").read().decode("utf-8")
)
return self.get_resource().Bucket(self.get_bucket()).Object(file_path).get().get("Body").read()
except self.missing_file_exceptions():
pass
return None

def missing_file_exceptions(self):
import botocore # pyright: ignore[reportMissingImports] # boto3 is an optional extra, imported lazily
Expand Down Expand Up @@ -126,8 +128,8 @@ def prepend(self, file_path, content):

def append(self, file_path, content):
value = self.get(file_path) or ""
value += content
self.put(file_path, content)
self.put(file_path, value + content)
return content

def delete(self, file_path):
return self.get_resource().Object(self.get_bucket(), file_path).delete()
Expand Down
7 changes: 7 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/storage/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,10 @@ def get_extension(filepath: str, without_dot=False) -> str:
if without_dot:
extension = extension[1:]
return extension


def decode_text(content: bytes) -> str | bytes:
try:
return content.decode("utf-8")
except UnicodeDecodeError:
return content
7 changes: 7 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/storage/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ def put(self, *args, **kwargs):
def get(self, *args, **kwargs):
return self.disk().get(*args, **kwargs)

def get_bytes(self, *args, **kwargs):
return self.disk().get_bytes(*args, **kwargs)

def exists(self, *args, **kwargs):
return self.disk().exists(*args, **kwargs)

Expand Down Expand Up @@ -140,6 +143,10 @@ def put(cls, *args, **kwargs):
def get(cls, *args, **kwargs):
return cls.init().get(*args, **kwargs)

@classmethod
def get_bytes(cls, *args, **kwargs):
return cls.init().get_bytes(*args, **kwargs)

@classmethod
def exists(cls, *args, **kwargs):
return cls.init().exists(*args, **kwargs)
Expand Down
2 changes: 2 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/vite/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from .vite import Vite
from .middleware import ViteMiddleware
from .config.vite import ViteConfig
from .providers.provider import ViteProvider
from .exceptions import ViteException, ViteManifestNotFoundException

__all__ = [
"Vite",
"ViteMiddleware",
"ViteConfig",
"ViteProvider",
"ViteException",
Expand Down
25 changes: 25 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/vite/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from typing import TYPE_CHECKING, Any

from .vite import Vite

if TYPE_CHECKING:
from starlette.types import ASGIApp, Receive, Scope, Send


class ViteMiddleware:
def __init__(self, app: "ASGIApp", vite: Vite | None = None) -> None:
self.app = app
self.vite = vite

async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> Any:
if scope["type"] not in ("http", "websocket"):
return await self.app(scope, receive, send)
with self.resolve_vite().request_scope():
return await self.app(scope, receive, send)

def resolve_vite(self) -> Vite:
if self.vite is None:
from fastapi_startkit.application import app

self.vite = app().make("vite")
return self.vite
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import os
from importlib.util import find_spec


from fastapi_startkit.support import Provider
from ..config.vite import ViteConfig

from ..middleware import ViteMiddleware
from ..vite import Vite


Expand Down Expand Up @@ -53,6 +55,7 @@ def boot(self) -> None:
config = ViteConfig(**config)

self.mount_static_file_if_require(config)
self.register_request_scope(vite)
self.register_jinja_directives(vite)

source = os.path.abspath(str(os.path.join(str(os.path.dirname(__file__)), "../config/vite.py")))
Expand Down Expand Up @@ -86,6 +89,10 @@ def mount_static_file_if_require(self, config: ViteConfig):
except ImportError:
pass # fastapi optional dependency not installed

def register_request_scope(self, vite: Vite):
if find_spec("fastapi") is not None:
self.app.fastapi.add_middleware(ViteMiddleware, vite=vite)

def register_jinja_directives(self, vite: Vite):
# Wire up Jinja2 globals when a template object is registered.
# Wrap HTML-returning helpers in Markup so Jinja2 does not escape the tags.
Expand Down
Loading
Loading