simple-module-file-storage 0.0.1__py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ """FileStorage module."""
@@ -0,0 +1,77 @@
1
+ """Storage backend registry — extension point for storage providers.
2
+
3
+ Built-in providers (filesystem, S3) self-register on import. Third-party
4
+ packages add new providers via :func:`register_backend`::
5
+
6
+ from file_storage.backends import register_backend
7
+ from file_storage.contracts.service import StorageBackend
8
+
9
+ @register_backend("azure_blob")
10
+ def _build(settings) -> StorageBackend:
11
+ return AzureBlobBackend(...)
12
+
13
+ The factory receives the parsed :class:`FileStorageSettings` so providers
14
+ can read their own ``SM_FILE_STORAGE_*`` keys (or extend the settings
15
+ class with extra fields). :func:`build_backend` resolves the active
16
+ factory by ``settings.backend`` and raises :class:`ConfigurationError`
17
+ on an unknown id, listing all currently registered providers.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Callable
23
+ from typing import TYPE_CHECKING
24
+
25
+ from file_storage.contracts.service import ConfigurationError
26
+
27
+ if TYPE_CHECKING:
28
+ from file_storage.contracts.service import StorageBackend
29
+ from file_storage.settings import FileStorageSettings
30
+
31
+ BackendFactory = Callable[["FileStorageSettings"], "StorageBackend"]
32
+
33
+ _REGISTRY: dict[str, BackendFactory] = {}
34
+
35
+
36
+ def register_backend(backend_id: str) -> Callable[[BackendFactory], BackendFactory]:
37
+ """Decorator that registers a factory under ``backend_id``."""
38
+
39
+ def decorator(factory: BackendFactory) -> BackendFactory:
40
+ _REGISTRY[backend_id] = factory
41
+ return factory
42
+
43
+ return decorator
44
+
45
+
46
+ def unregister_backend(backend_id: str) -> None:
47
+ """Remove a registered backend. Primarily for tests."""
48
+ _REGISTRY.pop(backend_id, None)
49
+
50
+
51
+ def registered_backends() -> list[str]:
52
+ """Return all currently registered backend ids, sorted."""
53
+ return sorted(_REGISTRY)
54
+
55
+
56
+ def build_backend(settings: FileStorageSettings) -> StorageBackend:
57
+ """Construct the backend selected by ``settings.backend``."""
58
+ factory = _REGISTRY.get(settings.backend)
59
+ if factory is None:
60
+ raise ConfigurationError(
61
+ f"Unknown storage backend {settings.backend!r}. Registered: {registered_backends()}."
62
+ )
63
+ return factory(settings)
64
+
65
+
66
+ # Trigger self-registration of built-in providers. Order is irrelevant; both
67
+ # add themselves to the same module-global registry.
68
+ from file_storage.backends import filesystem as _filesystem # noqa: E402,F401
69
+ from file_storage.backends import s3 as _s3 # noqa: E402,F401
70
+
71
+ __all__ = [
72
+ "BackendFactory",
73
+ "build_backend",
74
+ "register_backend",
75
+ "registered_backends",
76
+ "unregister_backend",
77
+ ]
@@ -0,0 +1,84 @@
1
+ """Filesystem storage backend — writes objects under a configurable root."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator
6
+ from pathlib import Path
7
+
8
+ import aiofiles
9
+
10
+ from file_storage import constants
11
+ from file_storage.backends import register_backend
12
+ from file_storage.contracts.service import (
13
+ NotSupportedError,
14
+ StorageBackendError,
15
+ StorageNotFoundError,
16
+ )
17
+ from file_storage.settings import FileStorageSettings
18
+
19
+
20
+ class FilesystemBackend:
21
+ """Stores objects on the local filesystem under ``root``.
22
+
23
+ Keys are sharded by their first two characters to keep any single
24
+ directory from accumulating millions of entries (which slows ``readdir``
25
+ on most filesystems). A key like ``2026/04/19/abc123.png`` is written to
26
+ ``<root>/20/2026/04/19/abc123.png``.
27
+ """
28
+
29
+ backend_id = constants.BackendId.FILESYSTEM
30
+ supports_presigned_url = False
31
+
32
+ def __init__(self, root: Path) -> None:
33
+ self.root = root
34
+
35
+ def _resolve(self, key: str) -> Path:
36
+ # Reject path traversal: ".." segments and absolute paths can escape root.
37
+ if key.startswith("/") or ".." in Path(key).parts:
38
+ raise StorageBackendError(f"Invalid storage key: {key!r}")
39
+ shard = key[:2] if len(key) >= 2 else "_"
40
+ return self.root / shard / key
41
+
42
+ async def put(
43
+ self,
44
+ key: str,
45
+ stream: AsyncIterator[bytes],
46
+ *,
47
+ content_type: str,
48
+ size: int,
49
+ ) -> None:
50
+ path = self._resolve(key)
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ async with aiofiles.open(path, "wb") as fh:
53
+ async for chunk in stream:
54
+ await fh.write(chunk)
55
+
56
+ async def get(self, key: str) -> AsyncIterator[bytes]:
57
+ path = self._resolve(key)
58
+ if not path.exists():
59
+ raise StorageNotFoundError(key)
60
+ return _stream_file(path)
61
+
62
+ async def delete(self, key: str) -> None:
63
+ path = self._resolve(key)
64
+ path.unlink(missing_ok=True)
65
+
66
+ async def exists(self, key: str) -> bool:
67
+ return self._resolve(key).exists()
68
+
69
+ async def presigned_get_url(self, key: str, ttl_seconds: int) -> str:
70
+ raise NotSupportedError("Filesystem backend cannot mint presigned URLs.")
71
+
72
+
73
+ async def _stream_file(path: Path) -> AsyncIterator[bytes]:
74
+ async with aiofiles.open(path, "rb") as fh:
75
+ while True:
76
+ chunk = await fh.read(constants.DEFAULT_CHUNK_SIZE)
77
+ if not chunk:
78
+ break
79
+ yield chunk
80
+
81
+
82
+ @register_backend(constants.BackendId.FILESYSTEM)
83
+ def _build(settings: FileStorageSettings) -> FilesystemBackend:
84
+ return FilesystemBackend(root=settings.resolved_fs_root())
@@ -0,0 +1,156 @@
1
+ """S3-compatible storage backend (AWS S3, MinIO, Cloudflare R2, ...).
2
+
3
+ ``aioboto3`` is imported lazily at backend construction so the module loads
4
+ even when the optional S3 extra isn't installed; the failure mode is a clear
5
+ :class:`ConfigurationError` at boot rather than a confusing import error.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import AsyncIterator
11
+ from tempfile import SpooledTemporaryFile
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from file_storage import constants
15
+ from file_storage.backends import register_backend
16
+ from file_storage.contracts.service import (
17
+ ConfigurationError,
18
+ StorageBackendError,
19
+ StorageNotFoundError,
20
+ )
21
+ from file_storage.settings import FileStorageSettings
22
+
23
+ if TYPE_CHECKING:
24
+ pass
25
+
26
+
27
+ class S3Backend:
28
+ """S3-compatible object storage.
29
+
30
+ Each operation opens a fresh ``aioboto3`` client context (cheap; the
31
+ underlying ``aiobotocore`` session is reused via ``self._session``). We
32
+ deliberately do not hold one long-lived client across requests because
33
+ aioboto3 client contexts are per-event-loop and tests run multiple loops.
34
+ """
35
+
36
+ backend_id = constants.BackendId.S3
37
+ supports_presigned_url = True
38
+
39
+ def __init__(self, *, bucket: str, region: str, client_kwargs: dict[str, Any]) -> None:
40
+ try:
41
+ import aioboto3
42
+ except ImportError as exc:
43
+ raise ConfigurationError(
44
+ "S3 backend requires the 'aioboto3' package. "
45
+ "Install with: uv add --optional s3 aioboto3"
46
+ ) from exc
47
+ self._aioboto3 = aioboto3
48
+ self._session = aioboto3.Session()
49
+ self.bucket = bucket
50
+ self.region = region
51
+ self.client_kwargs = client_kwargs
52
+
53
+ def _client(self):
54
+ return self._session.client("s3", **self.client_kwargs)
55
+
56
+ async def put(
57
+ self,
58
+ key: str,
59
+ stream: AsyncIterator[bytes],
60
+ *,
61
+ content_type: str,
62
+ size: int,
63
+ ) -> None:
64
+ # Spool to memory up to SPOOL_MAX_SIZE_BYTES, then to a temp file.
65
+ # put_object needs a seekable file-like; multipart upload would be
66
+ # the next step for very large files (deferred to v2).
67
+ with SpooledTemporaryFile(max_size=constants.SPOOL_MAX_SIZE_BYTES) as buf:
68
+ async for chunk in stream:
69
+ buf.write(chunk)
70
+ length = buf.tell()
71
+ buf.seek(0)
72
+ try:
73
+ async with self._client() as client:
74
+ await client.put_object(
75
+ Bucket=self.bucket,
76
+ Key=key,
77
+ Body=buf,
78
+ ContentType=content_type,
79
+ ContentLength=length,
80
+ )
81
+ except Exception as exc:
82
+ raise StorageBackendError(f"S3 put_object failed: {exc}") from exc
83
+
84
+ async def get(self, key: str) -> AsyncIterator[bytes]:
85
+ try:
86
+ async with self._client() as client:
87
+ resp = await client.get_object(Bucket=self.bucket, Key=key)
88
+ # ``Body.read()`` works uniformly across aioboto3 (real) and
89
+ # moto's patched aiobotocore (tests); ``iter_chunks`` is sync
90
+ # under moto, breaking ``async for``.
91
+ data = await resp["Body"].read()
92
+ except Exception as exc:
93
+ if _is_not_found(exc):
94
+ raise StorageNotFoundError(key) from exc
95
+ raise StorageBackendError(f"S3 get_object failed: {exc}") from exc
96
+
97
+ for offset in range(0, len(data), constants.DEFAULT_CHUNK_SIZE):
98
+ yield data[offset : offset + constants.DEFAULT_CHUNK_SIZE]
99
+
100
+ async def delete(self, key: str) -> None:
101
+ try:
102
+ async with self._client() as client:
103
+ await client.delete_object(Bucket=self.bucket, Key=key)
104
+ except Exception as exc:
105
+ raise StorageBackendError(f"S3 delete_object failed: {exc}") from exc
106
+
107
+ async def exists(self, key: str) -> bool:
108
+ try:
109
+ async with self._client() as client:
110
+ await client.head_object(Bucket=self.bucket, Key=key)
111
+ return True
112
+ except Exception as exc:
113
+ if _is_not_found(exc):
114
+ return False
115
+ raise StorageBackendError(f"S3 head_object failed: {exc}") from exc
116
+
117
+ async def presigned_get_url(self, key: str, ttl_seconds: int) -> str:
118
+ try:
119
+ async with self._client() as client:
120
+ return await client.generate_presigned_url(
121
+ "get_object",
122
+ Params={"Bucket": self.bucket, "Key": key},
123
+ ExpiresIn=ttl_seconds,
124
+ )
125
+ except Exception as exc:
126
+ raise StorageBackendError(f"presign failed: {exc}") from exc
127
+
128
+
129
+ def _is_not_found(exc: Exception) -> bool:
130
+ """Detect a 404 from botocore's heterogeneous error shapes."""
131
+ response = getattr(exc, "response", None)
132
+ if isinstance(response, dict):
133
+ code = response.get("Error", {}).get("Code")
134
+ if code in {"NoSuchKey", "404", "NotFound"}:
135
+ return True
136
+ return getattr(exc, "__class__", type(exc)).__name__ in {
137
+ "NoSuchKey",
138
+ "ClientError",
139
+ } and "404" in str(exc)
140
+
141
+
142
+ @register_backend(constants.BackendId.S3)
143
+ def _build(settings: FileStorageSettings) -> S3Backend:
144
+ if not settings.s3_bucket or not settings.s3_region:
145
+ raise ConfigurationError("S3 backend requires s3_bucket and s3_region.")
146
+ client_kwargs: dict[str, Any] = {"region_name": settings.s3_region}
147
+ if settings.s3_endpoint_url:
148
+ client_kwargs["endpoint_url"] = settings.s3_endpoint_url
149
+ if settings.s3_access_key_id and settings.s3_secret_access_key:
150
+ client_kwargs["aws_access_key_id"] = settings.s3_access_key_id
151
+ client_kwargs["aws_secret_access_key"] = settings.s3_secret_access_key
152
+ return S3Backend(
153
+ bucket=settings.s3_bucket,
154
+ region=settings.s3_region,
155
+ client_kwargs=client_kwargs,
156
+ )
@@ -0,0 +1,107 @@
1
+ """Single source of truth for every literal in the file_storage module.
2
+
3
+ Permissions, event topics, route prefixes, env-var prefix, page names, table
4
+ names, error codes, default config values — all live here. Other files in the
5
+ module import from this one rather than embedding string literals, so renames
6
+ stay localised and `rg` can prove there are no magic strings outside of this
7
+ file.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Final
13
+
14
+ # ── Module identity ──────────────────────────────────────────────────
15
+ # ``MODULE_NAME`` is the snake_case package name; ``MODULE_PASCAL`` matches the
16
+ # Inertia page-prefix convention (PascalCase of the module dir). ``meta.name``
17
+ # uses ``MODULE_PASCAL`` so the diagnostic code SM003 lines up against
18
+ # ``FileStorage/Browse.tsx``. ``MODULE_DISPLAY_NAME`` is the user-visible label
19
+ # (menu, permission group) — keep it free-form.
20
+ MODULE_NAME: Final = "file_storage"
21
+ MODULE_PASCAL: Final = "FileStorage"
22
+ MODULE_DISPLAY_NAME: Final = "Files"
23
+
24
+ # ── Configuration ────────────────────────────────────────────────────
25
+ ENV_PREFIX: Final = "SM_FILE_STORAGE_"
26
+
27
+ # ── Routing ──────────────────────────────────────────────────────────
28
+ ROUTE_PREFIX_API: Final = "/api/file-storage"
29
+ ROUTE_PREFIX_VIEW: Final = "/file-storage"
30
+
31
+ # REST sub-paths (joined to ROUTE_PREFIX_API by the router)
32
+ PATH_UPLOAD: Final = "/upload"
33
+ PATH_FILES: Final = "/files"
34
+ PATH_FILE_BY_ID: Final = "/files/{file_id}"
35
+ PATH_FILE_DOWNLOAD: Final = "/files/{file_id}/download"
36
+
37
+ # ── Inertia page names ───────────────────────────────────────────────
38
+ PAGE_BROWSE: Final = "FileStorage/Browse"
39
+
40
+ # ── Database ─────────────────────────────────────────────────────────
41
+ SCHEMA_NAME: Final = "file_storage"
42
+ TABLE_STORED_FILE: Final = "file_storage_stored_file"
43
+
44
+ # ── i18n ─────────────────────────────────────────────────────────────
45
+ LOCALE_NAMESPACE: Final = MODULE_NAME
46
+
47
+
48
+ class BackendId:
49
+ """Stable identifiers for built-in storage providers.
50
+
51
+ Third-party providers register themselves under any string id, but the
52
+ built-ins are referenced from settings, the model, and tests, so they
53
+ deserve named constants.
54
+ """
55
+
56
+ FILESYSTEM: Final = "filesystem"
57
+ S3: Final = "s3"
58
+
59
+
60
+ class Permission:
61
+ UPLOAD: Final = "file_storage.upload"
62
+ DOWNLOAD: Final = "file_storage.download"
63
+ DELETE: Final = "file_storage.delete"
64
+ MANAGE: Final = "file_storage.manage"
65
+
66
+
67
+ class Event:
68
+ FILE_UPLOADED: Final = "file_storage.file.uploaded"
69
+ FILE_DELETED: Final = "file_storage.file.deleted"
70
+
71
+
72
+ class FeatureFlag:
73
+ PUBLIC_UPLOADS: Final = "file_storage.public_uploads"
74
+
75
+
76
+ class ErrorCode:
77
+ """Machine-readable codes returned in 4xx response bodies."""
78
+
79
+ TOO_LARGE: Final = "file_storage.too_large"
80
+ BAD_TYPE: Final = "file_storage.bad_type"
81
+ NOT_FOUND: Final = "file_storage.not_found"
82
+ BACKEND_ERROR: Final = "file_storage.backend_error"
83
+
84
+
85
+ class I18nKey:
86
+ """Translator keys (full dotted paths). Mirror locales/en.json shape."""
87
+
88
+ ERR_NOT_FOUND: Final = "file_storage.errors.not_found"
89
+ ERR_TOO_LARGE: Final = "file_storage.errors.too_large"
90
+ ERR_BAD_TYPE: Final = "file_storage.errors.bad_type"
91
+ ERR_BACKEND: Final = "file_storage.errors.backend_error"
92
+
93
+
94
+ # ── Defaults ─────────────────────────────────────────────────────────
95
+ DEFAULT_BACKEND: Final = BackendId.FILESYSTEM
96
+ DEFAULT_FS_ROOT: Final = "./uploads"
97
+ DEFAULT_MAX_FILE_SIZE_BYTES: Final = 100 * 1024 * 1024 # 100 MB
98
+ DEFAULT_PRESIGN_TTL_SECONDS: Final = 300 # 5 minutes
99
+ DEFAULT_CHUNK_SIZE: Final = 64 * 1024 # 64 KB
100
+ SPOOL_MAX_SIZE_BYTES: Final = 10 * 1024 * 1024 # 10 MB before disk-spill
101
+
102
+ # ── Menu ─────────────────────────────────────────────────────────────
103
+ MENU_ICON: Final = "files"
104
+ MENU_ORDER: Final = 40
105
+ MENU_ROLES: Final = ("admin",)
106
+ ADMIN_ROLE: Final = "admin"
107
+ USER_ROLE: Final = "user"
@@ -0,0 +1,25 @@
1
+ """file_storage contracts — public interface for other modules."""
2
+
3
+ from file_storage.contracts.events import FileDeleted, FileUploaded
4
+ from file_storage.contracts.schemas import StoredFileListOut, StoredFileOut
5
+ from file_storage.contracts.service import (
6
+ ConfigurationError,
7
+ NotSupportedError,
8
+ StorageBackend,
9
+ StorageBackendError,
10
+ StorageError,
11
+ StorageNotFoundError,
12
+ )
13
+
14
+ __all__ = [
15
+ "ConfigurationError",
16
+ "FileDeleted",
17
+ "FileUploaded",
18
+ "NotSupportedError",
19
+ "StorageBackend",
20
+ "StorageBackendError",
21
+ "StorageError",
22
+ "StorageNotFoundError",
23
+ "StoredFileListOut",
24
+ "StoredFileOut",
25
+ ]
@@ -0,0 +1,23 @@
1
+ """file_storage domain events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from dataclasses import dataclass
7
+
8
+ from simple_module_core.events import Event
9
+
10
+
11
+ @dataclass
12
+ class FileUploaded(Event):
13
+ file_id: uuid.UUID
14
+ key: str
15
+ backend: str
16
+ size_bytes: int
17
+ uploaded_by: str | None
18
+
19
+
20
+ @dataclass
21
+ class FileDeleted(Event):
22
+ file_id: uuid.UUID
23
+ key: str
@@ -0,0 +1,37 @@
1
+ """SQLModel DTOs for the file_storage module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime
7
+
8
+ from pydantic import ConfigDict
9
+ from sqlmodel import Field, SQLModel
10
+
11
+
12
+ class StoredFileOut(SQLModel):
13
+ """Metadata returned for a stored file."""
14
+
15
+ model_config = ConfigDict(from_attributes=True)
16
+
17
+ id: uuid.UUID
18
+ key: str
19
+ filename: str
20
+ content_type: str
21
+ size_bytes: int
22
+ backend: str
23
+ checksum_sha256: str
24
+ uploaded_by: str | None = Field(
25
+ default=None,
26
+ description="User id from AuditMixin.created_by — populated by the audit listener.",
27
+ )
28
+ created_at: datetime | None = None
29
+
30
+
31
+ class StoredFileListOut(SQLModel):
32
+ """Paginated list response."""
33
+
34
+ items: list[StoredFileOut]
35
+ total: int
36
+ page: int
37
+ per_page: int
@@ -0,0 +1,82 @@
1
+ """Storage backend contract — extension point for new providers.
2
+
3
+ A storage provider is anything that satisfies :class:`StorageBackend`. Adding
4
+ a new provider (Azure Blob, GCS, R2 with custom auth, in-memory for tests)
5
+ means writing one module that implements this Protocol and self-registers via
6
+ :func:`file_storage.backends.register_backend`.
7
+
8
+ The Protocol is deliberately narrow: ``put``, ``get``, ``delete``, ``exists``,
9
+ plus capability flags so the service layer can dispatch (e.g. presigned-URL
10
+ download vs proxied stream) without inspecting backend identity.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import AsyncIterator
16
+ from typing import Protocol, runtime_checkable
17
+
18
+
19
+ class StorageError(Exception):
20
+ """Base class for all storage backend errors."""
21
+
22
+
23
+ class StorageNotFoundError(StorageError):
24
+ """Raised when an object key does not exist in the backend."""
25
+
26
+
27
+ class StorageBackendError(StorageError):
28
+ """Raised when the backend itself fails (network, IO, auth)."""
29
+
30
+
31
+ class NotSupportedError(StorageError):
32
+ """Raised when a backend does not implement an optional capability."""
33
+
34
+
35
+ class ConfigurationError(StorageError):
36
+ """Raised at backend construction when required config is missing."""
37
+
38
+
39
+ @runtime_checkable
40
+ class StorageBackend(Protocol):
41
+ """Async object storage abstraction.
42
+
43
+ Implementations must be safe to share across requests — they're held as
44
+ a singleton on ``app.state.file_storage.backend``.
45
+ """
46
+
47
+ backend_id: str
48
+ """Stable identifier matching the registry key (e.g. ``"filesystem"``)."""
49
+
50
+ supports_presigned_url: bool
51
+ """Whether :meth:`presigned_get_url` returns a usable URL.
52
+
53
+ The download endpoint reads this flag to choose between proxying bytes
54
+ and issuing a 302 redirect — no `if backend == "s3"` branching.
55
+ """
56
+
57
+ async def put(
58
+ self,
59
+ key: str,
60
+ stream: AsyncIterator[bytes],
61
+ *,
62
+ content_type: str,
63
+ size: int,
64
+ ) -> None:
65
+ """Persist ``stream`` under ``key``. ``size`` is the total byte count."""
66
+
67
+ async def get(self, key: str) -> AsyncIterator[bytes]:
68
+ """Yield the object's bytes in chunks. Raises :class:`StorageNotFoundError`."""
69
+
70
+ async def delete(self, key: str) -> None:
71
+ """Remove the object at ``key``. No-op if absent."""
72
+
73
+ async def exists(self, key: str) -> bool:
74
+ """Return whether an object exists at ``key``."""
75
+
76
+ async def presigned_get_url(self, key: str, ttl_seconds: int) -> str:
77
+ """Return a short-lived URL the client can fetch directly.
78
+
79
+ Implementations without presigned-URL support must raise
80
+ :class:`NotSupportedError`. The endpoint will not call this on
81
+ backends with ``supports_presigned_url=False``.
82
+ """
file_storage/deps.py ADDED
@@ -0,0 +1,26 @@
1
+ """FastAPI dependencies for the file_storage module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import Depends, Request
6
+ from simple_module_core.events import EventBus
7
+ from simple_module_db.deps import get_db
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from file_storage.service import FileStorageService
11
+ from file_storage.services import FileStorageServices
12
+
13
+
14
+ def get_file_storage_services(request: Request) -> FileStorageServices:
15
+ return request.app.state.file_storage
16
+
17
+
18
+ async def get_file_storage_service(
19
+ db: AsyncSession = Depends(get_db),
20
+ services: FileStorageServices = Depends(get_file_storage_services),
21
+ ) -> FileStorageService:
22
+ return FileStorageService(db, services.backend, services.settings)
23
+
24
+
25
+ def get_event_bus(request: Request) -> EventBus:
26
+ return request.app.state.sm.event_bus
File without changes