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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ node_modules/
*.db
*.sqlite3

# file_storage filesystem backend default root (override via SM_FILE_STORAGE_FS_ROOT_PATH).
uploads/

# Vite
host/static/dist/

Expand Down
14 changes: 9 additions & 5 deletions framework/hosting/simple_module_hosting/i18n_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

from __future__ import annotations

import json
import logging
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -114,7 +113,7 @@ def write_generated_resources(registry: I18nRegistry, output_dir: Path) -> Path:


def _render_resources(keys: list[str]) -> str:
lines = [_RESOURCES_HEADER, "", "export default {", " translation: {"]
lines = [_RESOURCES_HEADER, "export default {", " translation: {"]
for key in keys:
lines.append(f" '{key}': '',")
lines.append(" },")
Expand Down Expand Up @@ -179,16 +178,21 @@ def _insert(tree: dict[str, Any], path: list[str], value: str) -> None:


def _serialize(node: Any, *, indent: int) -> str:
"""Emit a dict-of-dicts tree as pretty-printed TypeScript object literal."""
"""Emit a dict-of-dicts tree as pretty-printed TypeScript object literal.

Strings are wrapped in single quotes to match the project's biome config
(``quoteStyle: "single"``); dotted i18n keys never contain single quotes,
so plain wrapping is safe.
"""
if isinstance(node, str):
return json.dumps(node)
return f"'{node}'"
if not isinstance(node, dict) or not node:
return "{}"
pad = " " * indent
inner_pad = " " * (indent + 1)
lines = ["{"]
for k in sorted(node.keys()):
key_repr = k if _is_valid_js_identifier(k) else json.dumps(k)
key_repr = k if _is_valid_js_identifier(k) else f"'{k}'"
lines.append(f"{inner_pad}{key_repr}: {_serialize(node[k], indent=indent + 1)},")
lines.append(f"{pad}}}")
return "\n".join(lines)
Expand Down
14 changes: 7 additions & 7 deletions framework/hosting/tests/test_i18n_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ def test_keys_tree_is_nested(tmp_path: Path) -> None:
# Nested structure: keys = { auth: { errors: { not_authenticated: ... } }, products: { ... } }
assert "auth:" in text
assert "errors:" in text
assert 'not_authenticated: "auth.errors.not_authenticated"' in text
assert 'title: "products.browse.title"' in text
assert "not_authenticated: 'auth.errors.not_authenticated'" in text
assert "title: 'products.browse.title'" in text


def test_keys_tree_adds_plural_stems(tmp_path: Path) -> None:
Expand All @@ -86,9 +86,9 @@ def test_keys_tree_adds_plural_stems(tmp_path: Path) -> None:
write_generated_resources(reg, tmp_path)
text = (tmp_path / "keys.generated.ts").read_text()
# Both the concrete variants AND the virtual stem must be emitted.
assert 'count_one: "products.browse.count_one"' in text
assert 'count_other: "products.browse.count_other"' in text
assert 'count: "products.browse.count"' in text
assert "count_one: 'products.browse.count_one'" in text
assert "count_other: 'products.browse.count_other'" in text
assert "count: 'products.browse.count'" in text


def test_keys_tree_quotes_non_identifier_segments(tmp_path: Path) -> None:
Expand All @@ -97,7 +97,7 @@ def test_keys_tree_quotes_non_identifier_segments(tmp_path: Path) -> None:
reg._messages = {"en": {"ui.switcher.a-b": "X"}} # hyphen in leaf
write_generated_resources(reg, tmp_path)
text = (tmp_path / "keys.generated.ts").read_text()
assert '"a-b":' in text
assert "'a-b':" in text


def test_keys_tree_does_not_overwrite_real_key_with_stem(tmp_path: Path) -> None:
Expand All @@ -113,4 +113,4 @@ def test_keys_tree_does_not_overwrite_real_key_with_stem(tmp_path: Path) -> None
write_generated_resources(reg, tmp_path)
text = (tmp_path / "keys.generated.ts").read_text()
# The real key retains its value; the virtual stem is skipped.
assert 'count: "products.browse.count"' in text
assert "count: 'products.browse.count'" in text
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""create file_storage tables

Revision ID: 5d44218ee368
Revises: b7e1af4c9d02
Create Date: 2026-04-19 11:20:24.247357
"""

from __future__ import annotations

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "5d44218ee368"
down_revision: str | None = "b7e1af4c9d02"
branch_labels: str | Sequence[str] | None = ("file_storage",)
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
if op.get_context().dialect.name == "postgresql":
op.execute("CREATE SCHEMA IF NOT EXISTS file_storage")

op.create_table(
"file_storage_stored_file",
sa.Column("is_deleted", sa.Boolean(), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_by", sa.String(length=255), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("updated_by", sa.String(length=255), nullable=True),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("key", sa.String(length=512), nullable=False),
sa.Column("filename", sa.String(length=255), nullable=False),
sa.Column("content_type", sa.String(length=128), nullable=False),
sa.Column("size_bytes", sa.Integer(), nullable=False),
sa.Column("backend", sa.String(length=32), nullable=False),
sa.Column("checksum_sha256", sa.String(length=64), nullable=False),
sa.Column("extra_metadata", sa.JSON(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_file_storage_stored_file")),
)
op.create_index(
"ix_file_storage_stored_file_created_by",
"file_storage_stored_file",
["created_by"],
unique=False,
)
op.create_index(
"ix_file_storage_stored_file_is_deleted",
"file_storage_stored_file",
["is_deleted"],
unique=False,
)
op.create_index(
"ix_file_storage_stored_file_key",
"file_storage_stored_file",
["key"],
unique=True,
)


def downgrade() -> None:
op.drop_index("ix_file_storage_stored_file_key", table_name="file_storage_stored_file")
op.drop_index("ix_file_storage_stored_file_is_deleted", table_name="file_storage_stored_file")
op.drop_index("ix_file_storage_stored_file_created_by", table_name="file_storage_stored_file")
op.drop_table("file_storage_stored_file")

if op.get_context().dialect.name == "postgresql":
op.execute("DROP SCHEMA IF EXISTS file_storage")
2 changes: 2 additions & 0 deletions host/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dependencies = [
"auth",
"dashboard",
"products",
"file-storage",
"python-multipart>=0.0.6",
]

Expand All @@ -16,3 +17,4 @@ simple-module-hosting = { workspace = true }
auth = { workspace = true }
products = { workspace = true }
dashboard = { workspace = true }
file-storage = { workspace = true }
1 change: 1 addition & 0 deletions modules/file_storage/file_storage/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""FileStorage module."""
77 changes: 77 additions & 0 deletions modules/file_storage/file_storage/backends/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Storage backend registry — extension point for storage providers.

Built-in providers (filesystem, S3) self-register on import. Third-party
packages add new providers via :func:`register_backend`::

from file_storage.backends import register_backend
from file_storage.contracts.service import StorageBackend

@register_backend("azure_blob")
def _build(settings) -> StorageBackend:
return AzureBlobBackend(...)

The factory receives the parsed :class:`FileStorageSettings` so providers
can read their own ``SM_FILE_STORAGE_*`` keys (or extend the settings
class with extra fields). :func:`build_backend` resolves the active
factory by ``settings.backend`` and raises :class:`ConfigurationError`
on an unknown id, listing all currently registered providers.
"""

from __future__ import annotations

from collections.abc import Callable
from typing import TYPE_CHECKING

from file_storage.contracts.service import ConfigurationError

if TYPE_CHECKING:
from file_storage.contracts.service import StorageBackend
from file_storage.settings import FileStorageSettings

BackendFactory = Callable[["FileStorageSettings"], "StorageBackend"]

_REGISTRY: dict[str, BackendFactory] = {}


def register_backend(backend_id: str) -> Callable[[BackendFactory], BackendFactory]:
"""Decorator that registers a factory under ``backend_id``."""

def decorator(factory: BackendFactory) -> BackendFactory:
_REGISTRY[backend_id] = factory
return factory

return decorator


def unregister_backend(backend_id: str) -> None:
"""Remove a registered backend. Primarily for tests."""
_REGISTRY.pop(backend_id, None)


def registered_backends() -> list[str]:
"""Return all currently registered backend ids, sorted."""
return sorted(_REGISTRY)


def build_backend(settings: FileStorageSettings) -> StorageBackend:
"""Construct the backend selected by ``settings.backend``."""
factory = _REGISTRY.get(settings.backend)
if factory is None:
raise ConfigurationError(
f"Unknown storage backend {settings.backend!r}. Registered: {registered_backends()}."
)
return factory(settings)


# Trigger self-registration of built-in providers. Order is irrelevant; both
# add themselves to the same module-global registry.
from file_storage.backends import filesystem as _filesystem # noqa: E402,F401
from file_storage.backends import s3 as _s3 # noqa: E402,F401

__all__ = [
"BackendFactory",
"build_backend",
"register_backend",
"registered_backends",
"unregister_backend",
]
84 changes: 84 additions & 0 deletions modules/file_storage/file_storage/backends/filesystem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Filesystem storage backend — writes objects under a configurable root."""

from __future__ import annotations

from collections.abc import AsyncIterator
from pathlib import Path

import aiofiles

from file_storage import constants
from file_storage.backends import register_backend
from file_storage.contracts.service import (
NotSupportedError,
StorageBackendError,
StorageNotFoundError,
)
from file_storage.settings import FileStorageSettings


class FilesystemBackend:
"""Stores objects on the local filesystem under ``root``.

Keys are sharded by their first two characters to keep any single
directory from accumulating millions of entries (which slows ``readdir``
on most filesystems). A key like ``2026/04/19/abc123.png`` is written to
``<root>/20/2026/04/19/abc123.png``.
"""

backend_id = constants.BackendId.FILESYSTEM
supports_presigned_url = False

def __init__(self, root: Path) -> None:
self.root = root

def _resolve(self, key: str) -> Path:
# Reject path traversal: ".." segments and absolute paths can escape root.
if key.startswith("/") or ".." in Path(key).parts:
raise StorageBackendError(f"Invalid storage key: {key!r}")
shard = key[:2] if len(key) >= 2 else "_"
return self.root / shard / key

async def put(
self,
key: str,
stream: AsyncIterator[bytes],
*,
content_type: str,
size: int,
) -> None:
path = self._resolve(key)
path.parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(path, "wb") as fh:
async for chunk in stream:
await fh.write(chunk)

async def get(self, key: str) -> AsyncIterator[bytes]:
path = self._resolve(key)
if not path.exists():
raise StorageNotFoundError(key)
return _stream_file(path)

async def delete(self, key: str) -> None:
path = self._resolve(key)
path.unlink(missing_ok=True)

async def exists(self, key: str) -> bool:
return self._resolve(key).exists()

async def presigned_get_url(self, key: str, ttl_seconds: int) -> str:
raise NotSupportedError("Filesystem backend cannot mint presigned URLs.")


async def _stream_file(path: Path) -> AsyncIterator[bytes]:
async with aiofiles.open(path, "rb") as fh:
while True:
chunk = await fh.read(constants.DEFAULT_CHUNK_SIZE)
if not chunk:
break
yield chunk


@register_backend(constants.BackendId.FILESYSTEM)
def _build(settings: FileStorageSettings) -> FilesystemBackend:
return FilesystemBackend(root=settings.resolved_fs_root())
Loading
Loading