supython 0.1.0__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.
- supython/__init__.py +24 -0
- supython/admin/__init__.py +3 -0
- supython/admin/api/__init__.py +24 -0
- supython/admin/api/auth.py +118 -0
- supython/admin/api/auth_templates.py +67 -0
- supython/admin/api/auth_users.py +225 -0
- supython/admin/api/db.py +174 -0
- supython/admin/api/functions.py +92 -0
- supython/admin/api/jobs.py +192 -0
- supython/admin/api/ops.py +224 -0
- supython/admin/api/realtime.py +281 -0
- supython/admin/api/service_auth.py +49 -0
- supython/admin/api/service_auth_templates.py +83 -0
- supython/admin/api/service_auth_users.py +346 -0
- supython/admin/api/service_db.py +214 -0
- supython/admin/api/service_functions.py +287 -0
- supython/admin/api/service_jobs.py +282 -0
- supython/admin/api/service_ops.py +213 -0
- supython/admin/api/service_realtime.py +30 -0
- supython/admin/api/service_storage.py +220 -0
- supython/admin/api/storage.py +117 -0
- supython/admin/api/system.py +37 -0
- supython/admin/audit.py +29 -0
- supython/admin/deps.py +22 -0
- supython/admin/errors.py +16 -0
- supython/admin/schemas.py +310 -0
- supython/admin/session.py +52 -0
- supython/admin/spa.py +38 -0
- supython/admin/static/assets/Alert-dluGVkos.js +49 -0
- supython/admin/static/assets/Audit-Njung3HI.js +2 -0
- supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
- supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
- supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
- supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
- supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
- supython/admin/static/assets/Crons-B67vc39F.js +2 -0
- supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
- supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
- supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
- supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
- supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
- supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
- supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
- supython/admin/static/assets/Input-DppYTq9C.js +259 -0
- supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
- supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
- supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
- supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
- supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
- supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
- supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
- supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
- supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
- supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
- supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
- supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
- supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
- supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
- supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
- supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
- supython/admin/static/assets/Space-n5-XcguU.js +400 -0
- supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
- supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
- supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
- supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
- supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
- supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
- supython/admin/static/assets/Users-wzwajhlh.js +2 -0
- supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
- supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
- supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
- supython/admin/static/assets/get-Ca6unauB.js +2 -0
- supython/admin/static/assets/index-CeE6v959.js +951 -0
- supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
- supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
- supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
- supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
- supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
- supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
- supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
- supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
- supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
- supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
- supython/admin/static/favicon.svg +1 -0
- supython/admin/static/icons.svg +24 -0
- supython/admin/static/index.html +24 -0
- supython/app.py +162 -0
- supython/auth/__init__.py +3 -0
- supython/auth/_email_job.py +11 -0
- supython/auth/providers/__init__.py +34 -0
- supython/auth/providers/github.py +22 -0
- supython/auth/providers/google.py +19 -0
- supython/auth/providers/oauth.py +56 -0
- supython/auth/providers/registry.py +16 -0
- supython/auth/ratelimit.py +39 -0
- supython/auth/router.py +282 -0
- supython/auth/schemas.py +79 -0
- supython/auth/service.py +587 -0
- supython/backups/__init__.py +24 -0
- supython/backups/_backup_job.py +170 -0
- supython/backups/schemas.py +18 -0
- supython/backups/service.py +217 -0
- supython/body_size.py +184 -0
- supython/cli.py +1663 -0
- supython/client/__init__.py +67 -0
- supython/client/_auth.py +249 -0
- supython/client/_client.py +145 -0
- supython/client/_config.py +92 -0
- supython/client/_functions.py +69 -0
- supython/client/_storage.py +255 -0
- supython/client/py.typed +0 -0
- supython/db.py +151 -0
- supython/db_admin.py +8 -0
- supython/extensions.py +36 -0
- supython/functions/__init__.py +19 -0
- supython/functions/context.py +262 -0
- supython/functions/loader.py +307 -0
- supython/functions/router.py +228 -0
- supython/functions/schemas.py +50 -0
- supython/gen/__init__.py +5 -0
- supython/gen/_introspect.py +137 -0
- supython/gen/types_py.py +270 -0
- supython/gen/types_ts.py +365 -0
- supython/health.py +229 -0
- supython/hooks.py +117 -0
- supython/jobs/__init__.py +31 -0
- supython/jobs/backends.py +97 -0
- supython/jobs/context.py +58 -0
- supython/jobs/cron.py +152 -0
- supython/jobs/cron_inproc.py +119 -0
- supython/jobs/decorators.py +76 -0
- supython/jobs/registry.py +79 -0
- supython/jobs/router.py +136 -0
- supython/jobs/schemas.py +92 -0
- supython/jobs/service.py +311 -0
- supython/jobs/worker.py +219 -0
- supython/jwks.py +257 -0
- supython/keyset.py +279 -0
- supython/logging_config.py +291 -0
- supython/mail.py +33 -0
- supython/mailer.py +65 -0
- supython/migrate.py +81 -0
- supython/migrations/0001_extensions_and_roles.sql +46 -0
- supython/migrations/0002_auth_schema.sql +66 -0
- supython/migrations/0003_demo_todos.sql +42 -0
- supython/migrations/0004_auth_v0_2.sql +47 -0
- supython/migrations/0005_storage_schema.sql +117 -0
- supython/migrations/0006_realtime_schema.sql +206 -0
- supython/migrations/0007_jobs_schema.sql +254 -0
- supython/migrations/0008_jobs_last_error.sql +56 -0
- supython/migrations/0009_auth_rate_limits.sql +33 -0
- supython/migrations/0010_worker_heartbeat.sql +14 -0
- supython/migrations/0011_admin_schema.sql +45 -0
- supython/migrations/0012_auth_banned_until.sql +10 -0
- supython/migrations/0013_email_templates.sql +19 -0
- supython/migrations/0014_realtime_payload_warning.sql +96 -0
- supython/migrations/0015_backups_schema.sql +14 -0
- supython/passwords.py +15 -0
- supython/realtime/__init__.py +6 -0
- supython/realtime/broker.py +814 -0
- supython/realtime/protocol.py +234 -0
- supython/realtime/router.py +184 -0
- supython/realtime/schemas.py +207 -0
- supython/realtime/service.py +261 -0
- supython/realtime/topics.py +175 -0
- supython/realtime/websocket.py +586 -0
- supython/scaffold/__init__.py +5 -0
- supython/scaffold/init_project.py +144 -0
- supython/scaffold/templates/Caddyfile.tmpl +4 -0
- supython/scaffold/templates/README.md.tmpl +22 -0
- supython/scaffold/templates/apps_hooks.py.tmpl +11 -0
- supython/scaffold/templates/apps_jobs.py.tmpl +8 -0
- supython/scaffold/templates/asgi.py.tmpl +14 -0
- supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
- supython/scaffold/templates/docker-compose.yml.tmpl +45 -0
- supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
- supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
- supython/scaffold/templates/env.example.tmpl +168 -0
- supython/scaffold/templates/functions_README.md.tmpl +21 -0
- supython/scaffold/templates/gitignore.tmpl +14 -0
- supython/scaffold/templates/manage.py.tmpl +11 -0
- supython/scaffold/templates/migrations/.gitkeep +0 -0
- supython/scaffold/templates/package_init.py.tmpl +1 -0
- supython/scaffold/templates/settings.py.tmpl +31 -0
- supython/secretset.py +347 -0
- supython/security_headers.py +78 -0
- supython/settings.py +244 -0
- supython/settings_module.py +117 -0
- supython/storage/__init__.py +5 -0
- supython/storage/backends.py +392 -0
- supython/storage/router.py +341 -0
- supython/storage/schemas.py +50 -0
- supython/storage/service.py +445 -0
- supython/storage/signing.py +119 -0
- supython/tokens.py +85 -0
- supython-0.1.0.dist-info/METADATA +756 -0
- supython-0.1.0.dist-info/RECORD +200 -0
- supython-0.1.0.dist-info/WHEEL +4 -0
- supython-0.1.0.dist-info/entry_points.txt +2 -0
- supython-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Load a user-defined Python settings module (Django-style).
|
|
2
|
+
|
|
3
|
+
Conventional uppercase attributes read from the module:
|
|
4
|
+
EXTENSIONS: list[str] — dotted module paths (additive on top of
|
|
5
|
+
the env-driven ``EXTENSIONS`` list)
|
|
6
|
+
EXTRA_ROUTERS: list[str] — ``"module.path:router_symbol"`` strings,
|
|
7
|
+
resolved to FastAPI ``APIRouter`` instances
|
|
8
|
+
EXTRA_MIDDLEWARE: list[str] — ``"module.path:ClassName"`` strings,
|
|
9
|
+
resolved to ASGI middleware classes
|
|
10
|
+
|
|
11
|
+
The settings module is purely additive on top of env-driven Settings. It
|
|
12
|
+
never replaces Pydantic-managed values (DATABASE_URL, JWT keys, etc.) —
|
|
13
|
+
those continue to come from env / .env and are typed.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import importlib
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ensure_cwd_on_sys_path() -> None:
|
|
28
|
+
"""Make the current working directory importable.
|
|
29
|
+
|
|
30
|
+
When supython is invoked through ``python manage.py``, Python prepends
|
|
31
|
+
the script's directory (the project root) to ``sys.path`` automatically.
|
|
32
|
+
When invoked through the installed ``supython`` console script, ``sys.path[0]``
|
|
33
|
+
is the venv's bin directory instead, so the user's project package is not
|
|
34
|
+
importable. Mirror Django/Flask convention by adding the CWD ourselves.
|
|
35
|
+
"""
|
|
36
|
+
cwd = os.getcwd()
|
|
37
|
+
if cwd not in sys.path and "" not in sys.path:
|
|
38
|
+
sys.path.insert(0, cwd)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class UserSettings:
|
|
43
|
+
"""Resolved attributes from a user settings module."""
|
|
44
|
+
|
|
45
|
+
extensions: list[str] = field(default_factory=list)
|
|
46
|
+
extra_routers: list[Any] = field(default_factory=list)
|
|
47
|
+
extra_middleware: list[type] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_user_settings(module_path: str) -> UserSettings:
|
|
51
|
+
"""Import the user's settings module and resolve the conventional names.
|
|
52
|
+
|
|
53
|
+
Raises ``ImportError`` if the module itself cannot be imported, or if any
|
|
54
|
+
referenced ``module.path:symbol`` cannot be resolved. Raises ``ValueError``
|
|
55
|
+
if a referenced spec is malformed.
|
|
56
|
+
"""
|
|
57
|
+
_ensure_cwd_on_sys_path()
|
|
58
|
+
try:
|
|
59
|
+
mod = importlib.import_module(module_path)
|
|
60
|
+
except ImportError as exc:
|
|
61
|
+
raise ImportError(
|
|
62
|
+
f"settings module {module_path!r} could not be imported. "
|
|
63
|
+
f"Set SUPYTHON_SETTINGS_MODULE to an importable dotted path."
|
|
64
|
+
) from exc
|
|
65
|
+
|
|
66
|
+
extensions = list(_as_str_list(getattr(mod, "EXTENSIONS", []), "EXTENSIONS"))
|
|
67
|
+
extra_routers = [
|
|
68
|
+
_resolve(p) for p in _as_str_list(getattr(mod, "EXTRA_ROUTERS", []), "EXTRA_ROUTERS")
|
|
69
|
+
]
|
|
70
|
+
extra_middleware = [
|
|
71
|
+
_resolve(p)
|
|
72
|
+
for p in _as_str_list(getattr(mod, "EXTRA_MIDDLEWARE", []), "EXTRA_MIDDLEWARE")
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
logger.info(
|
|
76
|
+
"settings module loaded: %s (extensions=%d routers=%d middleware=%d)",
|
|
77
|
+
module_path,
|
|
78
|
+
len(extensions),
|
|
79
|
+
len(extra_routers),
|
|
80
|
+
len(extra_middleware),
|
|
81
|
+
)
|
|
82
|
+
return UserSettings(
|
|
83
|
+
extensions=extensions,
|
|
84
|
+
extra_routers=extra_routers,
|
|
85
|
+
extra_middleware=extra_middleware,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _as_str_list(value: object, name: str) -> Sequence[str]:
|
|
90
|
+
if not isinstance(value, (list, tuple)):
|
|
91
|
+
raise TypeError(f"{name} must be a list of strings, got {type(value).__name__}")
|
|
92
|
+
for item in value:
|
|
93
|
+
if not isinstance(item, str):
|
|
94
|
+
raise TypeError(f"{name} entries must be strings, got {type(item).__name__}")
|
|
95
|
+
return list(value)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _resolve(spec: str) -> Any:
|
|
99
|
+
"""Resolve ``module.path:symbol`` to the imported object."""
|
|
100
|
+
if ":" not in spec:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"expected 'module.path:symbol', got {spec!r}. Use a colon to "
|
|
103
|
+
f"separate the module path from the attribute name."
|
|
104
|
+
)
|
|
105
|
+
mod_path, _, symbol = spec.partition(":")
|
|
106
|
+
try:
|
|
107
|
+
mod = importlib.import_module(mod_path)
|
|
108
|
+
except ImportError as exc:
|
|
109
|
+
raise ImportError(
|
|
110
|
+
f"settings module references {spec!r} but {mod_path!r} could not be imported."
|
|
111
|
+
) from exc
|
|
112
|
+
try:
|
|
113
|
+
return getattr(mod, symbol)
|
|
114
|
+
except AttributeError as exc:
|
|
115
|
+
raise ImportError(
|
|
116
|
+
f"settings module references {spec!r} but {symbol!r} is not in {mod_path!r}."
|
|
117
|
+
) from exc
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Storage backends.
|
|
2
|
+
|
|
3
|
+
A backend is the *bytes* layer. Object metadata (ownership, RLS, mime) lives
|
|
4
|
+
in `storage.objects` in Postgres; the backend only knows about opaque keys.
|
|
5
|
+
|
|
6
|
+
Two implementations:
|
|
7
|
+
|
|
8
|
+
- ``LocalBackend`` writes files under a configurable root using the stdlib.
|
|
9
|
+
- ``S3Backend`` proxies to S3-compatible object storage via the optional
|
|
10
|
+
``aioboto3`` dependency (``pip install supython[s3]``).
|
|
11
|
+
|
|
12
|
+
Both stream bytes — nothing is ever fully buffered in memory.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import hashlib
|
|
17
|
+
import os
|
|
18
|
+
from collections.abc import AsyncIterator
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import IO, Protocol
|
|
22
|
+
|
|
23
|
+
from ..settings import get_settings
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ObjectStat:
|
|
28
|
+
key: str
|
|
29
|
+
size: int
|
|
30
|
+
etag: str
|
|
31
|
+
content_type: str | None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ObjectStream:
|
|
36
|
+
iterator: AsyncIterator[bytes]
|
|
37
|
+
content_length: int
|
|
38
|
+
content_type: str | None
|
|
39
|
+
etag: str
|
|
40
|
+
status_code: int
|
|
41
|
+
content_range: str | None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BackendError(Exception):
|
|
45
|
+
"""Raised for backend-level failures (missing key, IO error, etc.)."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class StorageBackend(Protocol):
|
|
49
|
+
async def put(
|
|
50
|
+
self,
|
|
51
|
+
key: str,
|
|
52
|
+
data: AsyncIterator[bytes],
|
|
53
|
+
content_type: str | None,
|
|
54
|
+
) -> ObjectStat: ...
|
|
55
|
+
|
|
56
|
+
async def get(
|
|
57
|
+
self,
|
|
58
|
+
key: str,
|
|
59
|
+
*,
|
|
60
|
+
byte_range: tuple[int, int | None] | None = None,
|
|
61
|
+
) -> ObjectStream: ...
|
|
62
|
+
|
|
63
|
+
async def stat(self, key: str) -> ObjectStat | None: ...
|
|
64
|
+
|
|
65
|
+
async def delete(self, key: str) -> None: ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Local backend
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_CHUNK = 64 * 1024
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LocalBackend:
|
|
77
|
+
"""Bytes on the local filesystem under ``root``.
|
|
78
|
+
|
|
79
|
+
Keys are joined with ``root`` and resolved; any key that escapes the root
|
|
80
|
+
(via ``..`` or absolute paths) is rejected.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, root: str | os.PathLike[str]) -> None:
|
|
84
|
+
self._root = Path(root).resolve()
|
|
85
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
|
|
87
|
+
def _resolve(self, key: str) -> Path:
|
|
88
|
+
if not key or key.startswith("/") or ".." in Path(key).parts:
|
|
89
|
+
raise BackendError(f"Invalid key: {key!r}")
|
|
90
|
+
candidate = (self._root / key).resolve()
|
|
91
|
+
try:
|
|
92
|
+
candidate.relative_to(self._root)
|
|
93
|
+
except ValueError as exc:
|
|
94
|
+
raise BackendError(f"Key escapes root: {key!r}") from exc
|
|
95
|
+
return candidate
|
|
96
|
+
|
|
97
|
+
async def put(
|
|
98
|
+
self,
|
|
99
|
+
key: str,
|
|
100
|
+
data: AsyncIterator[bytes],
|
|
101
|
+
content_type: str | None,
|
|
102
|
+
) -> ObjectStat:
|
|
103
|
+
import asyncio
|
|
104
|
+
|
|
105
|
+
path = self._resolve(key)
|
|
106
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
hasher = hashlib.sha256()
|
|
108
|
+
size = 0
|
|
109
|
+
|
|
110
|
+
def _open() -> IO[bytes]:
|
|
111
|
+
return open(path, "wb")
|
|
112
|
+
|
|
113
|
+
f = await asyncio.to_thread(_open)
|
|
114
|
+
try:
|
|
115
|
+
async for chunk in data:
|
|
116
|
+
if not chunk:
|
|
117
|
+
continue
|
|
118
|
+
hasher.update(chunk)
|
|
119
|
+
size += len(chunk)
|
|
120
|
+
await asyncio.to_thread(f.write, chunk)
|
|
121
|
+
except BaseException:
|
|
122
|
+
await asyncio.to_thread(f.close)
|
|
123
|
+
with contextlib.suppress(FileNotFoundError):
|
|
124
|
+
await asyncio.to_thread(path.unlink)
|
|
125
|
+
raise
|
|
126
|
+
else:
|
|
127
|
+
await asyncio.to_thread(f.close)
|
|
128
|
+
|
|
129
|
+
return ObjectStat(
|
|
130
|
+
key=key,
|
|
131
|
+
size=size,
|
|
132
|
+
etag=hasher.hexdigest(),
|
|
133
|
+
content_type=content_type,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
async def get(
|
|
137
|
+
self,
|
|
138
|
+
key: str,
|
|
139
|
+
*,
|
|
140
|
+
byte_range: tuple[int, int | None] | None = None,
|
|
141
|
+
) -> ObjectStream:
|
|
142
|
+
import asyncio
|
|
143
|
+
|
|
144
|
+
path = self._resolve(key)
|
|
145
|
+
if not path.exists():
|
|
146
|
+
raise BackendError(f"Object not found: {key!r}")
|
|
147
|
+
|
|
148
|
+
total = path.stat().st_size
|
|
149
|
+
start = 0
|
|
150
|
+
end = total - 1
|
|
151
|
+
status = 200
|
|
152
|
+
content_range: str | None = None
|
|
153
|
+
|
|
154
|
+
if byte_range is not None:
|
|
155
|
+
start, end_opt = byte_range
|
|
156
|
+
end = total - 1 if end_opt is None else min(end_opt, total - 1)
|
|
157
|
+
if start < 0 or start > end:
|
|
158
|
+
raise BackendError(f"Invalid range {byte_range} for size {total}")
|
|
159
|
+
status = 206
|
|
160
|
+
content_range = f"bytes {start}-{end}/{total}"
|
|
161
|
+
|
|
162
|
+
length = end - start + 1
|
|
163
|
+
|
|
164
|
+
async def _iter() -> AsyncIterator[bytes]:
|
|
165
|
+
remaining = length
|
|
166
|
+
|
|
167
|
+
def _open() -> IO[bytes]:
|
|
168
|
+
fh = open(path, "rb") # noqa: SIM115 — closed in finally below
|
|
169
|
+
fh.seek(start)
|
|
170
|
+
return fh
|
|
171
|
+
|
|
172
|
+
fh = await asyncio.to_thread(_open)
|
|
173
|
+
try:
|
|
174
|
+
while remaining > 0:
|
|
175
|
+
to_read = min(_CHUNK, remaining)
|
|
176
|
+
chunk = await asyncio.to_thread(fh.read, to_read)
|
|
177
|
+
if not chunk:
|
|
178
|
+
break
|
|
179
|
+
remaining -= len(chunk)
|
|
180
|
+
yield chunk
|
|
181
|
+
finally:
|
|
182
|
+
await asyncio.to_thread(fh.close)
|
|
183
|
+
|
|
184
|
+
return ObjectStream(
|
|
185
|
+
iterator=_iter(),
|
|
186
|
+
content_length=length,
|
|
187
|
+
content_type=None,
|
|
188
|
+
etag="",
|
|
189
|
+
status_code=status,
|
|
190
|
+
content_range=content_range,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
async def stat(self, key: str) -> ObjectStat | None:
|
|
194
|
+
path = self._resolve(key)
|
|
195
|
+
if not path.exists():
|
|
196
|
+
return None
|
|
197
|
+
return ObjectStat(
|
|
198
|
+
key=key,
|
|
199
|
+
size=path.stat().st_size,
|
|
200
|
+
etag="",
|
|
201
|
+
content_type=None,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
async def delete(self, key: str) -> None:
|
|
205
|
+
import asyncio
|
|
206
|
+
|
|
207
|
+
path = self._resolve(key)
|
|
208
|
+
try:
|
|
209
|
+
await asyncio.to_thread(path.unlink)
|
|
210
|
+
except FileNotFoundError:
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# S3 backend (optional)
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class S3Backend:
|
|
220
|
+
"""S3-compatible backend.
|
|
221
|
+
|
|
222
|
+
All logical buckets are prefixed into a single physical bucket configured
|
|
223
|
+
via ``storage_s3_bucket``. ``aioboto3`` is imported lazily so the cost is
|
|
224
|
+
only paid when the backend is actually selected.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
def __init__(
|
|
228
|
+
self,
|
|
229
|
+
*,
|
|
230
|
+
bucket: str,
|
|
231
|
+
endpoint_url: str | None,
|
|
232
|
+
region: str,
|
|
233
|
+
access_key_id: str,
|
|
234
|
+
secret_access_key: str,
|
|
235
|
+
) -> None:
|
|
236
|
+
if not bucket:
|
|
237
|
+
raise BackendError("S3Backend requires storage_s3_bucket")
|
|
238
|
+
self._bucket = bucket
|
|
239
|
+
self._endpoint_url = endpoint_url
|
|
240
|
+
self._region = region
|
|
241
|
+
self._access_key_id = access_key_id
|
|
242
|
+
self._secret_access_key = secret_access_key
|
|
243
|
+
self._session = None
|
|
244
|
+
|
|
245
|
+
def _client(self):
|
|
246
|
+
try:
|
|
247
|
+
import aioboto3
|
|
248
|
+
except ImportError as exc:
|
|
249
|
+
raise BackendError(
|
|
250
|
+
"S3Backend requires aioboto3. Install with `pip install supython[s3]`."
|
|
251
|
+
) from exc
|
|
252
|
+
if self._session is None:
|
|
253
|
+
self._session = aioboto3.Session(
|
|
254
|
+
aws_access_key_id=self._access_key_id or None,
|
|
255
|
+
aws_secret_access_key=self._secret_access_key or None,
|
|
256
|
+
region_name=self._region,
|
|
257
|
+
)
|
|
258
|
+
return self._session.client(
|
|
259
|
+
"s3",
|
|
260
|
+
endpoint_url=self._endpoint_url,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
async def put(
|
|
264
|
+
self,
|
|
265
|
+
key: str,
|
|
266
|
+
data: AsyncIterator[bytes],
|
|
267
|
+
content_type: str | None,
|
|
268
|
+
) -> ObjectStat:
|
|
269
|
+
hasher = hashlib.sha256()
|
|
270
|
+
chunks: list[bytes] = []
|
|
271
|
+
size = 0
|
|
272
|
+
async for chunk in data:
|
|
273
|
+
if not chunk:
|
|
274
|
+
continue
|
|
275
|
+
hasher.update(chunk)
|
|
276
|
+
chunks.append(chunk)
|
|
277
|
+
size += len(chunk)
|
|
278
|
+
body = b"".join(chunks)
|
|
279
|
+
|
|
280
|
+
kwargs: dict = {"Bucket": self._bucket, "Key": key, "Body": body}
|
|
281
|
+
if content_type:
|
|
282
|
+
kwargs["ContentType"] = content_type
|
|
283
|
+
|
|
284
|
+
async with self._client() as s3:
|
|
285
|
+
resp = await s3.put_object(**kwargs)
|
|
286
|
+
|
|
287
|
+
etag = (resp.get("ETag") or "").strip('"') or hasher.hexdigest()
|
|
288
|
+
return ObjectStat(key=key, size=size, etag=etag, content_type=content_type)
|
|
289
|
+
|
|
290
|
+
async def get(
|
|
291
|
+
self,
|
|
292
|
+
key: str,
|
|
293
|
+
*,
|
|
294
|
+
byte_range: tuple[int, int | None] | None = None,
|
|
295
|
+
) -> ObjectStream:
|
|
296
|
+
kwargs: dict = {"Bucket": self._bucket, "Key": key}
|
|
297
|
+
status = 200
|
|
298
|
+
content_range: str | None = None
|
|
299
|
+
if byte_range is not None:
|
|
300
|
+
start, end_opt = byte_range
|
|
301
|
+
end_part = "" if end_opt is None else str(end_opt)
|
|
302
|
+
kwargs["Range"] = f"bytes={start}-{end_part}"
|
|
303
|
+
status = 206
|
|
304
|
+
|
|
305
|
+
client_ctx = self._client()
|
|
306
|
+
s3 = await client_ctx.__aenter__()
|
|
307
|
+
try:
|
|
308
|
+
resp = await s3.get_object(**kwargs)
|
|
309
|
+
except Exception:
|
|
310
|
+
await client_ctx.__aexit__(None, None, None)
|
|
311
|
+
raise
|
|
312
|
+
|
|
313
|
+
body = resp["Body"]
|
|
314
|
+
length = int(resp.get("ContentLength", 0))
|
|
315
|
+
content_type = resp.get("ContentType")
|
|
316
|
+
etag = (resp.get("ETag") or "").strip('"')
|
|
317
|
+
content_range = resp.get("ContentRange")
|
|
318
|
+
if status == 206 and content_range and not content_range.startswith("bytes "):
|
|
319
|
+
content_range = f"bytes {content_range}"
|
|
320
|
+
|
|
321
|
+
async def _iter() -> AsyncIterator[bytes]:
|
|
322
|
+
try:
|
|
323
|
+
async for chunk in body.iter_chunks(_CHUNK):
|
|
324
|
+
yield chunk
|
|
325
|
+
finally:
|
|
326
|
+
await client_ctx.__aexit__(None, None, None)
|
|
327
|
+
|
|
328
|
+
return ObjectStream(
|
|
329
|
+
iterator=_iter(),
|
|
330
|
+
content_length=length,
|
|
331
|
+
content_type=content_type,
|
|
332
|
+
etag=etag,
|
|
333
|
+
status_code=status,
|
|
334
|
+
content_range=content_range,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
async def stat(self, key: str) -> ObjectStat | None:
|
|
338
|
+
async with self._client() as s3:
|
|
339
|
+
try:
|
|
340
|
+
resp = await s3.head_object(Bucket=self._bucket, Key=key)
|
|
341
|
+
except Exception:
|
|
342
|
+
return None
|
|
343
|
+
return ObjectStat(
|
|
344
|
+
key=key,
|
|
345
|
+
size=int(resp.get("ContentLength", 0)),
|
|
346
|
+
etag=(resp.get("ETag") or "").strip('"'),
|
|
347
|
+
content_type=resp.get("ContentType"),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
async def delete(self, key: str) -> None:
|
|
351
|
+
async with self._client() as s3:
|
|
352
|
+
await s3.delete_object(Bucket=self._bucket, Key=key)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
# Selection
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
_backend: StorageBackend | None = None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def get_backend() -> StorageBackend:
|
|
364
|
+
"""Return the process-wide backend chosen by settings."""
|
|
365
|
+
global _backend
|
|
366
|
+
if _backend is not None:
|
|
367
|
+
return _backend
|
|
368
|
+
s = get_settings()
|
|
369
|
+
if s.storage_backend == "local":
|
|
370
|
+
_backend = LocalBackend(s.storage_local_root)
|
|
371
|
+
elif s.storage_backend == "s3":
|
|
372
|
+
_backend = S3Backend(
|
|
373
|
+
bucket=s.storage_s3_bucket,
|
|
374
|
+
endpoint_url=s.storage_s3_endpoint,
|
|
375
|
+
region=s.storage_s3_region,
|
|
376
|
+
access_key_id=s.storage_s3_access_key_id,
|
|
377
|
+
secret_access_key=s.storage_s3_secret_access_key,
|
|
378
|
+
)
|
|
379
|
+
else:
|
|
380
|
+
raise BackendError(f"Unknown storage backend: {s.storage_backend!r}")
|
|
381
|
+
return _backend
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def reset_backend() -> None:
|
|
385
|
+
"""Drop the cached backend; tests re-init with overridden settings."""
|
|
386
|
+
global _backend
|
|
387
|
+
_backend = None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def make_object_key(bucket_name: str, path: str) -> str:
|
|
391
|
+
"""Compose the backend key for a logical (bucket, path) pair."""
|
|
392
|
+
return f"{bucket_name}/{path.lstrip('/')}"
|