svc-infra 0.1.706__py3-none-any.whl → 1.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.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/apf_payments/models.py +47 -108
- svc_infra/apf_payments/provider/__init__.py +2 -2
- svc_infra/apf_payments/provider/aiydan.py +42 -100
- svc_infra/apf_payments/provider/base.py +10 -26
- svc_infra/apf_payments/provider/registry.py +3 -5
- svc_infra/apf_payments/provider/stripe.py +63 -135
- svc_infra/apf_payments/schemas.py +82 -90
- svc_infra/apf_payments/service.py +40 -86
- svc_infra/apf_payments/settings.py +10 -13
- svc_infra/api/__init__.py +13 -13
- svc_infra/api/fastapi/__init__.py +19 -0
- svc_infra/api/fastapi/admin/add.py +13 -18
- svc_infra/api/fastapi/apf_payments/router.py +47 -84
- svc_infra/api/fastapi/apf_payments/setup.py +7 -13
- svc_infra/api/fastapi/auth/__init__.py +1 -1
- svc_infra/api/fastapi/auth/_cookies.py +3 -9
- svc_infra/api/fastapi/auth/add.py +4 -8
- svc_infra/api/fastapi/auth/gaurd.py +9 -26
- svc_infra/api/fastapi/auth/mfa/models.py +4 -7
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +3 -3
- svc_infra/api/fastapi/auth/mfa/router.py +9 -15
- svc_infra/api/fastapi/auth/mfa/security.py +3 -5
- svc_infra/api/fastapi/auth/mfa/utils.py +3 -2
- svc_infra/api/fastapi/auth/mfa/verify.py +2 -9
- svc_infra/api/fastapi/auth/providers.py +4 -6
- svc_infra/api/fastapi/auth/routers/apikey_router.py +16 -18
- svc_infra/api/fastapi/auth/routers/oauth_router.py +37 -85
- svc_infra/api/fastapi/auth/routers/session_router.py +3 -6
- svc_infra/api/fastapi/auth/security.py +17 -28
- svc_infra/api/fastapi/auth/sender.py +1 -3
- svc_infra/api/fastapi/auth/settings.py +18 -19
- svc_infra/api/fastapi/auth/state.py +6 -7
- svc_infra/api/fastapi/auth/ws_security.py +2 -2
- svc_infra/api/fastapi/billing/router.py +6 -8
- svc_infra/api/fastapi/db/http.py +10 -11
- svc_infra/api/fastapi/db/nosql/mongo/add.py +5 -15
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +14 -15
- svc_infra/api/fastapi/db/sql/add.py +6 -14
- svc_infra/api/fastapi/db/sql/crud_router.py +27 -40
- svc_infra/api/fastapi/db/sql/health.py +1 -3
- svc_infra/api/fastapi/db/sql/session.py +4 -5
- svc_infra/api/fastapi/db/sql/users.py +8 -11
- svc_infra/api/fastapi/dependencies/ratelimit.py +4 -6
- svc_infra/api/fastapi/docs/add.py +13 -23
- svc_infra/api/fastapi/docs/landing.py +6 -8
- svc_infra/api/fastapi/docs/scoped.py +34 -42
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +12 -21
- svc_infra/api/fastapi/dual/router.py +14 -31
- svc_infra/api/fastapi/ease.py +57 -13
- svc_infra/api/fastapi/http/conditional.py +3 -5
- svc_infra/api/fastapi/middleware/errors/catchall.py +2 -6
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -4
- svc_infra/api/fastapi/middleware/errors/handlers.py +12 -18
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +4 -13
- svc_infra/api/fastapi/middleware/idempotency.py +11 -16
- svc_infra/api/fastapi/middleware/idempotency_store.py +14 -14
- svc_infra/api/fastapi/middleware/optimistic_lock.py +5 -8
- svc_infra/api/fastapi/middleware/ratelimit.py +8 -8
- svc_infra/api/fastapi/middleware/ratelimit_store.py +7 -8
- svc_infra/api/fastapi/middleware/request_id.py +1 -3
- svc_infra/api/fastapi/middleware/timeout.py +9 -10
- svc_infra/api/fastapi/object_router.py +1060 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -6
- svc_infra/api/fastapi/openapi/conventions.py +4 -4
- svc_infra/api/fastapi/openapi/mutators.py +13 -31
- svc_infra/api/fastapi/openapi/pipeline.py +2 -2
- svc_infra/api/fastapi/openapi/responses.py +4 -6
- svc_infra/api/fastapi/openapi/security.py +1 -3
- svc_infra/api/fastapi/ops/add.py +7 -9
- svc_infra/api/fastapi/pagination.py +25 -37
- svc_infra/api/fastapi/routers/__init__.py +16 -38
- svc_infra/api/fastapi/setup.py +13 -31
- svc_infra/api/fastapi/tenancy/add.py +3 -2
- svc_infra/api/fastapi/tenancy/context.py +8 -7
- svc_infra/api/fastapi/versioned.py +3 -2
- svc_infra/app/env.py +5 -7
- svc_infra/app/logging/add.py +2 -1
- svc_infra/app/logging/filter.py +1 -1
- svc_infra/app/logging/formats.py +3 -2
- svc_infra/app/root.py +3 -3
- svc_infra/billing/__init__.py +19 -2
- svc_infra/billing/async_service.py +27 -7
- svc_infra/billing/jobs.py +23 -33
- svc_infra/billing/models.py +21 -52
- svc_infra/billing/quotas.py +5 -7
- svc_infra/billing/schemas.py +4 -6
- svc_infra/cache/__init__.py +12 -5
- svc_infra/cache/add.py +6 -9
- svc_infra/cache/backend.py +6 -5
- svc_infra/cache/decorators.py +17 -28
- svc_infra/cache/keys.py +2 -2
- svc_infra/cache/recache.py +22 -35
- svc_infra/cache/resources.py +8 -16
- svc_infra/cache/ttl.py +2 -3
- svc_infra/cache/utils.py +5 -6
- svc_infra/cli/__init__.py +4 -12
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +11 -10
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +6 -9
- svc_infra/cli/cmds/db/ops_cmds.py +3 -6
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +24 -41
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +9 -17
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +10 -10
- svc_infra/cli/cmds/docs/docs_cmds.py +7 -10
- svc_infra/cli/cmds/dx/dx_cmds.py +5 -11
- svc_infra/cli/cmds/jobs/jobs_cmds.py +2 -7
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -7
- svc_infra/cli/cmds/sdk/sdk_cmds.py +5 -15
- svc_infra/cli/foundation/runner.py +6 -11
- svc_infra/cli/foundation/typer_bootstrap.py +1 -2
- svc_infra/data/__init__.py +83 -0
- svc_infra/data/add.py +5 -5
- svc_infra/data/backup.py +8 -10
- svc_infra/data/erasure.py +3 -2
- svc_infra/data/fixtures.py +3 -3
- svc_infra/data/retention.py +8 -13
- svc_infra/db/crud_schema.py +9 -8
- svc_infra/db/nosql/__init__.py +0 -1
- svc_infra/db/nosql/constants.py +1 -1
- svc_infra/db/nosql/core.py +7 -14
- svc_infra/db/nosql/indexes.py +11 -10
- svc_infra/db/nosql/management.py +3 -3
- svc_infra/db/nosql/mongo/client.py +3 -3
- svc_infra/db/nosql/mongo/settings.py +2 -6
- svc_infra/db/nosql/repository.py +27 -28
- svc_infra/db/nosql/resource.py +15 -20
- svc_infra/db/nosql/scaffold.py +13 -17
- svc_infra/db/nosql/service.py +3 -4
- svc_infra/db/nosql/service_with_hooks.py +4 -3
- svc_infra/db/nosql/types.py +2 -6
- svc_infra/db/nosql/utils.py +4 -4
- svc_infra/db/ops.py +14 -18
- svc_infra/db/outbox.py +15 -18
- svc_infra/db/sql/apikey.py +12 -21
- svc_infra/db/sql/authref.py +3 -7
- svc_infra/db/sql/constants.py +9 -9
- svc_infra/db/sql/core.py +11 -11
- svc_infra/db/sql/management.py +2 -6
- svc_infra/db/sql/repository.py +17 -24
- svc_infra/db/sql/resource.py +14 -13
- svc_infra/db/sql/scaffold.py +13 -17
- svc_infra/db/sql/service.py +7 -16
- svc_infra/db/sql/service_with_hooks.py +4 -3
- svc_infra/db/sql/tenant.py +6 -14
- svc_infra/db/sql/uniq.py +8 -7
- svc_infra/db/sql/uniq_hooks.py +14 -19
- svc_infra/db/sql/utils.py +24 -53
- svc_infra/db/utils.py +3 -3
- svc_infra/deploy/__init__.py +8 -15
- svc_infra/documents/add.py +7 -8
- svc_infra/documents/ease.py +8 -8
- svc_infra/documents/models.py +3 -3
- svc_infra/documents/storage.py +11 -13
- svc_infra/dx/__init__.py +58 -0
- svc_infra/dx/add.py +1 -3
- svc_infra/dx/changelog.py +2 -2
- svc_infra/dx/checks.py +1 -1
- svc_infra/health/__init__.py +15 -16
- svc_infra/http/client.py +10 -14
- svc_infra/jobs/__init__.py +79 -0
- svc_infra/jobs/builtins/outbox_processor.py +3 -5
- svc_infra/jobs/builtins/webhook_delivery.py +1 -3
- svc_infra/jobs/loader.py +4 -5
- svc_infra/jobs/queue.py +14 -24
- svc_infra/jobs/redis_queue.py +20 -34
- svc_infra/jobs/runner.py +7 -11
- svc_infra/jobs/scheduler.py +5 -5
- svc_infra/jobs/worker.py +1 -1
- svc_infra/loaders/base.py +5 -4
- svc_infra/loaders/github.py +1 -3
- svc_infra/loaders/url.py +3 -9
- svc_infra/logging/__init__.py +7 -6
- svc_infra/mcp/__init__.py +82 -0
- svc_infra/mcp/svc_infra_mcp.py +2 -2
- svc_infra/obs/add.py +4 -3
- svc_infra/obs/cloud_dash.py +1 -1
- svc_infra/obs/metrics/__init__.py +3 -3
- svc_infra/obs/metrics/asgi.py +9 -14
- svc_infra/obs/metrics/base.py +13 -13
- svc_infra/obs/metrics/http.py +5 -9
- svc_infra/obs/metrics/sqlalchemy.py +9 -12
- svc_infra/obs/metrics.py +3 -3
- svc_infra/obs/settings.py +2 -6
- svc_infra/resilience/__init__.py +44 -0
- svc_infra/resilience/circuit_breaker.py +328 -0
- svc_infra/resilience/retry.py +289 -0
- svc_infra/security/__init__.py +167 -0
- svc_infra/security/add.py +5 -9
- svc_infra/security/audit.py +14 -17
- svc_infra/security/audit_service.py +9 -9
- svc_infra/security/hibp.py +3 -6
- svc_infra/security/jwt_rotation.py +7 -10
- svc_infra/security/lockout.py +12 -11
- svc_infra/security/models.py +37 -46
- svc_infra/security/oauth_models.py +8 -8
- svc_infra/security/org_invites.py +11 -13
- svc_infra/security/passwords.py +4 -6
- svc_infra/security/permissions.py +8 -7
- svc_infra/security/session.py +6 -7
- svc_infra/security/signed_cookies.py +9 -9
- svc_infra/storage/add.py +5 -8
- svc_infra/storage/backends/local.py +13 -21
- svc_infra/storage/backends/memory.py +4 -7
- svc_infra/storage/backends/s3.py +17 -36
- svc_infra/storage/base.py +2 -2
- svc_infra/storage/easy.py +4 -8
- svc_infra/storage/settings.py +16 -18
- svc_infra/testing/__init__.py +36 -39
- svc_infra/utils.py +169 -8
- svc_infra/webhooks/__init__.py +1 -1
- svc_infra/webhooks/add.py +17 -29
- svc_infra/webhooks/encryption.py +2 -2
- svc_infra/webhooks/fastapi.py +2 -4
- svc_infra/webhooks/router.py +3 -3
- svc_infra/webhooks/service.py +5 -6
- svc_infra/webhooks/signing.py +5 -5
- svc_infra/websocket/add.py +2 -3
- svc_infra/websocket/client.py +3 -2
- svc_infra/websocket/config.py +6 -18
- svc_infra/websocket/manager.py +9 -10
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/METADATA +11 -5
- svc_infra-1.1.0.dist-info/RECORD +364 -0
- svc_infra/billing/service.py +0 -123
- svc_infra-0.1.706.dist-info/RECORD +0 -357
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/LICENSE +0 -0
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
svc_infra/db/sql/uniq.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
from sqlalchemy import Index, func
|
|
6
7
|
|
|
@@ -9,13 +10,13 @@ from svc_infra.db.utils import as_tuple as _as_tuple
|
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def make_unique_sql_indexes(
|
|
12
|
-
model:
|
|
13
|
+
model: type[Any],
|
|
13
14
|
*,
|
|
14
15
|
unique_cs: Iterable[KeySpec] = (),
|
|
15
16
|
unique_ci: Iterable[KeySpec] = (),
|
|
16
|
-
tenant_field:
|
|
17
|
+
tenant_field: str | None = None,
|
|
17
18
|
name_prefix: str = "uq",
|
|
18
|
-
) ->
|
|
19
|
+
) -> list[Index]:
|
|
19
20
|
"""Return SQLAlchemy Index objects that enforce uniqueness.
|
|
20
21
|
|
|
21
22
|
- unique_cs: case-sensitive unique specs
|
|
@@ -26,12 +27,12 @@ def make_unique_sql_indexes(
|
|
|
26
27
|
|
|
27
28
|
Declare right after your model class; Alembic or metadata.create_all will pick them up.
|
|
28
29
|
"""
|
|
29
|
-
idxs:
|
|
30
|
+
idxs: list[Index] = []
|
|
30
31
|
|
|
31
32
|
def _col(name: str):
|
|
32
33
|
return getattr(model, name)
|
|
33
34
|
|
|
34
|
-
def _to_sa_cols(spec:
|
|
35
|
+
def _to_sa_cols(spec: tuple[str, ...], *, ci: bool):
|
|
35
36
|
cols = []
|
|
36
37
|
for cname in spec:
|
|
37
38
|
c = _col(cname)
|
|
@@ -40,7 +41,7 @@ def make_unique_sql_indexes(
|
|
|
40
41
|
|
|
41
42
|
tenant_col = _col(tenant_field) if tenant_field else None
|
|
42
43
|
|
|
43
|
-
def _name(ci: bool, spec:
|
|
44
|
+
def _name(ci: bool, spec: tuple[str, ...], null_bucket: str | None = None):
|
|
44
45
|
parts = [name_prefix, model.__tablename__]
|
|
45
46
|
if tenant_field:
|
|
46
47
|
parts.append(tenant_field)
|
svc_infra/db/sql/uniq_hooks.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
from fastapi import HTTPException
|
|
6
7
|
from sqlalchemy import func
|
|
@@ -11,14 +12,14 @@ from svc_infra.db.sql.service_with_hooks import SqlServiceWithHooks
|
|
|
11
12
|
|
|
12
13
|
from .uniq import _as_tuple
|
|
13
14
|
|
|
14
|
-
ColumnSpec =
|
|
15
|
+
ColumnSpec = str | Sequence[str]
|
|
15
16
|
|
|
16
17
|
|
|
17
|
-
def _all_present(data:
|
|
18
|
+
def _all_present(data: dict[str, Any], fields: Sequence[str]) -> bool:
|
|
18
19
|
return all(f in data for f in fields)
|
|
19
20
|
|
|
20
21
|
|
|
21
|
-
def _nice_label(fields: Sequence[str], data:
|
|
22
|
+
def _nice_label(fields: Sequence[str], data: dict[str, Any]) -> str:
|
|
22
23
|
if len(fields) == 1:
|
|
23
24
|
f = fields[0]
|
|
24
25
|
return f"{f}={data.get(f)!r}"
|
|
@@ -30,10 +31,10 @@ def dedupe_sql_service(
|
|
|
30
31
|
*,
|
|
31
32
|
unique_cs: Iterable[ColumnSpec] = (),
|
|
32
33
|
unique_ci: Iterable[ColumnSpec] = (),
|
|
33
|
-
tenant_field:
|
|
34
|
-
messages:
|
|
35
|
-
pre_create:
|
|
36
|
-
pre_update:
|
|
34
|
+
tenant_field: str | None = None,
|
|
35
|
+
messages: dict[tuple[str, ...], str] | None = None,
|
|
36
|
+
pre_create: Callable[[dict], dict] | None = None,
|
|
37
|
+
pre_update: Callable[[dict], dict] | None = None,
|
|
37
38
|
):
|
|
38
39
|
"""
|
|
39
40
|
Build a Service subclass with uniqueness pre-checks:
|
|
@@ -46,9 +47,9 @@ def dedupe_sql_service(
|
|
|
46
47
|
messages = messages or {}
|
|
47
48
|
|
|
48
49
|
def _build_where(
|
|
49
|
-
spec:
|
|
50
|
+
spec: tuple[str, ...], data: dict[str, Any], *, ci: bool, exclude_id: Any | None
|
|
50
51
|
):
|
|
51
|
-
clauses:
|
|
52
|
+
clauses: list[Any] = []
|
|
52
53
|
for col_name in spec:
|
|
53
54
|
col = getattr(Model, col_name)
|
|
54
55
|
val = data.get(col_name)
|
|
@@ -73,9 +74,7 @@ def dedupe_sql_service(
|
|
|
73
74
|
|
|
74
75
|
return clauses
|
|
75
76
|
|
|
76
|
-
async def _precheck(
|
|
77
|
-
session, data: Dict[str, Any], *, exclude_id: Any | None
|
|
78
|
-
) -> None:
|
|
77
|
+
async def _precheck(session, data: dict[str, Any], *, exclude_id: Any | None) -> None:
|
|
79
78
|
# Check CI specs first to catch the broadest conflicts, then CS.
|
|
80
79
|
for ci, spec_list in ((True, unique_ci), (False, unique_cs)):
|
|
81
80
|
for spec in spec_list:
|
|
@@ -99,9 +98,7 @@ def dedupe_sql_service(
|
|
|
99
98
|
return await self.repo.create(session, data)
|
|
100
99
|
except IntegrityError as e:
|
|
101
100
|
# Race fallback: let DB constraint be the last line of defense.
|
|
102
|
-
raise HTTPException(
|
|
103
|
-
status_code=409, detail="Record already exists."
|
|
104
|
-
) from e
|
|
101
|
+
raise HTTPException(status_code=409, detail="Record already exists.") from e
|
|
105
102
|
|
|
106
103
|
async def update(self, session, id_value, data):
|
|
107
104
|
data = await self.pre_update(data)
|
|
@@ -109,8 +106,6 @@ def dedupe_sql_service(
|
|
|
109
106
|
try:
|
|
110
107
|
return await self.repo.update(session, id_value, data)
|
|
111
108
|
except IntegrityError as e:
|
|
112
|
-
raise HTTPException(
|
|
113
|
-
status_code=409, detail="Record already exists."
|
|
114
|
-
) from e
|
|
109
|
+
raise HTTPException(status_code=409, detail="Record already exists.") from e
|
|
115
110
|
|
|
116
111
|
return _Svc(repo, pre_create=pre_create, pre_update=pre_update)
|
svc_infra/db/sql/utils.py
CHANGED
|
@@ -2,8 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import os
|
|
5
|
+
from collections.abc import Sequence
|
|
5
6
|
from pathlib import Path
|
|
6
|
-
from typing import TYPE_CHECKING, Any,
|
|
7
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
7
8
|
|
|
8
9
|
from alembic.config import Config
|
|
9
10
|
from dotenv import load_dotenv
|
|
@@ -34,7 +35,7 @@ except Exception: # pragma: no cover - optional env
|
|
|
34
35
|
|
|
35
36
|
def prepare_process_env(
|
|
36
37
|
project_root: Path | str,
|
|
37
|
-
discover_packages:
|
|
38
|
+
discover_packages: Sequence[str] | None = None,
|
|
38
39
|
) -> None:
|
|
39
40
|
"""
|
|
40
41
|
Prepare process environment so Alembic can import the project cleanly.
|
|
@@ -60,7 +61,7 @@ def prepare_process_env(
|
|
|
60
61
|
os.environ["ALEMBIC_DISCOVER_PACKAGES"] = ",".join(discover_packages)
|
|
61
62
|
|
|
62
63
|
|
|
63
|
-
def _read_secret_from_file(path: str) ->
|
|
64
|
+
def _read_secret_from_file(path: str) -> str | None:
|
|
64
65
|
"""Return file contents if path exists, else None."""
|
|
65
66
|
try:
|
|
66
67
|
p = Path(path)
|
|
@@ -71,7 +72,7 @@ def _read_secret_from_file(path: str) -> Optional[str]:
|
|
|
71
72
|
return None
|
|
72
73
|
|
|
73
74
|
|
|
74
|
-
def _compose_url_from_parts() ->
|
|
75
|
+
def _compose_url_from_parts() -> str | None:
|
|
75
76
|
"""
|
|
76
77
|
Compose a SQLAlchemy URL from component env vars.
|
|
77
78
|
Supports private DNS hostnames and Unix sockets.
|
|
@@ -83,9 +84,7 @@ def _compose_url_from_parts() -> Optional[str]:
|
|
|
83
84
|
DB_PARAMS (raw query string like 'sslmode=require&connect_timeout=5')
|
|
84
85
|
"""
|
|
85
86
|
dialect = os.getenv("DB_DIALECT", "").strip() or "postgresql"
|
|
86
|
-
driver = os.getenv(
|
|
87
|
-
"DB_DRIVER", ""
|
|
88
|
-
).strip() # e.g. asyncpg, psycopg, pymysql, aiosqlite
|
|
87
|
+
driver = os.getenv("DB_DRIVER", "").strip() # e.g. asyncpg, psycopg, pymysql, aiosqlite
|
|
89
88
|
host = os.getenv("DB_HOST", "").strip() or None
|
|
90
89
|
port = os.getenv("DB_PORT", "").strip() or None
|
|
91
90
|
db = os.getenv("DB_NAME", "").strip() or None
|
|
@@ -151,7 +150,7 @@ def get_database_url_from_env(
|
|
|
151
150
|
required: bool = True,
|
|
152
151
|
env_vars: Sequence[str] = DEFAULT_DB_ENV_VARS,
|
|
153
152
|
normalize: bool = True,
|
|
154
|
-
) ->
|
|
153
|
+
) -> str | None:
|
|
155
154
|
"""
|
|
156
155
|
Resolve the database connection string, with support for:
|
|
157
156
|
- Primary env vars (in order): DEFAULT_DB_ENV_VARS
|
|
@@ -263,7 +262,7 @@ def is_async_url(url: URL | str) -> bool:
|
|
|
263
262
|
return bool(ASYNC_DRIVER_HINT.search(dn))
|
|
264
263
|
|
|
265
264
|
|
|
266
|
-
def with_database(url: URL | str, database:
|
|
265
|
+
def with_database(url: URL | str, database: str | None) -> URL:
|
|
267
266
|
"""Return a copy of URL with the database name replaced.
|
|
268
267
|
|
|
269
268
|
Works for most dialects. For SQLite/DuckDB file URLs, `database` is the file path.
|
|
@@ -376,17 +375,11 @@ def _ensure_ssl_default(u: URL) -> URL:
|
|
|
376
375
|
driver = (u.drivername or "").lower()
|
|
377
376
|
|
|
378
377
|
# If any SSL hint already present, do nothing
|
|
379
|
-
if any(
|
|
380
|
-
k in u.query for k in ("sslmode", "ssl", "sslrootcert", "sslcert", "sslkey")
|
|
381
|
-
):
|
|
378
|
+
if any(k in u.query for k in ("sslmode", "ssl", "sslrootcert", "sslcert", "sslkey")):
|
|
382
379
|
return u
|
|
383
380
|
|
|
384
381
|
# Allow env override; support both common spellings
|
|
385
|
-
mode_env = (
|
|
386
|
-
os.getenv("DB_SSLMODE_DEFAULT")
|
|
387
|
-
or os.getenv("PGSSLMODE")
|
|
388
|
-
or os.getenv("PGSSL_MODE")
|
|
389
|
-
)
|
|
382
|
+
mode_env = os.getenv("DB_SSLMODE_DEFAULT") or os.getenv("PGSSLMODE") or os.getenv("PGSSL_MODE")
|
|
390
383
|
mode = (mode_env or "").strip()
|
|
391
384
|
|
|
392
385
|
if "+asyncpg" in driver:
|
|
@@ -403,9 +396,7 @@ def _ensure_ssl_default_async(u: URL) -> URL:
|
|
|
403
396
|
backend = (u.get_backend_name() or "").lower()
|
|
404
397
|
if backend in ("postgresql", "postgres"):
|
|
405
398
|
# asyncpg prefers 'ssl=true' via SQLAlchemy param; if already present, keep it
|
|
406
|
-
if any(
|
|
407
|
-
k in u.query for k in ("ssl", "sslmode", "sslrootcert", "sslcert", "sslkey")
|
|
408
|
-
):
|
|
399
|
+
if any(k in u.query for k in ("ssl", "sslmode", "sslrootcert", "sslcert", "sslkey")):
|
|
409
400
|
return u
|
|
410
401
|
return u.set(query={**u.query, "ssl": "true"})
|
|
411
402
|
return u
|
|
@@ -420,9 +411,7 @@ def _certifi_ca() -> str | None:
|
|
|
420
411
|
return None
|
|
421
412
|
|
|
422
413
|
|
|
423
|
-
def build_engine(
|
|
424
|
-
url: URL | str, echo: bool = False
|
|
425
|
-
) -> Union[SyncEngine, AsyncEngineType]:
|
|
414
|
+
def build_engine(url: URL | str, echo: bool = False) -> SyncEngine | AsyncEngineType:
|
|
426
415
|
u = make_url(url) if isinstance(url, str) else url
|
|
427
416
|
|
|
428
417
|
# Keep your existing PG helpers
|
|
@@ -445,9 +434,7 @@ def build_engine(
|
|
|
445
434
|
# asyncpg doesn't accept sslmode or ssl=true in query params
|
|
446
435
|
# Remove these and set ssl='require' in connect_args
|
|
447
436
|
if "ssl" in u.query or "sslmode" in u.query:
|
|
448
|
-
new_query = {
|
|
449
|
-
k: v for k, v in u.query.items() if k not in ("ssl", "sslmode")
|
|
450
|
-
}
|
|
437
|
+
new_query = {k: v for k, v in u.query.items() if k not in ("ssl", "sslmode")}
|
|
451
438
|
u = u.set(query=new_query)
|
|
452
439
|
# Set ssl in connect_args - 'require' is safest for hosted databases
|
|
453
440
|
connect_args["ssl"] = "require"
|
|
@@ -461,11 +448,7 @@ def build_engine(
|
|
|
461
448
|
import ssl
|
|
462
449
|
|
|
463
450
|
ca = _certifi_ca()
|
|
464
|
-
ctx = (
|
|
465
|
-
ssl.create_default_context(cafile=ca)
|
|
466
|
-
if ca
|
|
467
|
-
else ssl.create_default_context()
|
|
468
|
-
)
|
|
451
|
+
ctx = ssl.create_default_context(cafile=ca) if ca else ssl.create_default_context()
|
|
469
452
|
# if your host uses a public CA, verification works;
|
|
470
453
|
# if not, you can relax verification (not recommended):
|
|
471
454
|
# ctx.check_hostname = False
|
|
@@ -482,9 +465,7 @@ def build_engine(
|
|
|
482
465
|
# ----------------- SYNC -----------------
|
|
483
466
|
u = _coerce_sync_driver(u)
|
|
484
467
|
if _create_engine is None:
|
|
485
|
-
raise RuntimeError(
|
|
486
|
-
"SQLAlchemy create_engine is not available in this environment."
|
|
487
|
-
)
|
|
468
|
+
raise RuntimeError("SQLAlchemy create_engine is not available in this environment.")
|
|
488
469
|
|
|
489
470
|
dn = (u.drivername or "").lower()
|
|
490
471
|
|
|
@@ -617,9 +598,7 @@ async def _mysql_create_database_async(url: URL) -> None:
|
|
|
617
598
|
engine: AsyncEngineType = build_engine(base_url) # type: ignore[assignment]
|
|
618
599
|
async with engine.begin() as conn:
|
|
619
600
|
exists = await conn.scalar(
|
|
620
|
-
text(
|
|
621
|
-
"SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = :name"
|
|
622
|
-
),
|
|
601
|
+
text("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = :name"),
|
|
623
602
|
{"name": target_db},
|
|
624
603
|
)
|
|
625
604
|
if not exists:
|
|
@@ -636,9 +615,7 @@ def _mysql_create_database_sync(url: URL) -> None:
|
|
|
636
615
|
engine: SyncEngine = build_engine(base_url) # type: ignore[assignment]
|
|
637
616
|
with engine.begin() as conn:
|
|
638
617
|
exists = conn.scalar(
|
|
639
|
-
text(
|
|
640
|
-
"SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = :name"
|
|
641
|
-
),
|
|
618
|
+
text("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = :name"),
|
|
642
619
|
{"name": target_db},
|
|
643
620
|
)
|
|
644
621
|
if not exists:
|
|
@@ -865,7 +842,7 @@ def ensure_database_exists(url: URL | str) -> None:
|
|
|
865
842
|
try:
|
|
866
843
|
eng = build_engine(u)
|
|
867
844
|
if is_async_url(u):
|
|
868
|
-
async_eng = cast(AsyncEngineType, eng)
|
|
845
|
+
async_eng = cast("AsyncEngineType", eng)
|
|
869
846
|
|
|
870
847
|
async def _ping_and_dispose():
|
|
871
848
|
async with async_eng.begin() as conn:
|
|
@@ -874,7 +851,7 @@ def ensure_database_exists(url: URL | str) -> None:
|
|
|
874
851
|
|
|
875
852
|
asyncio.run(_ping_and_dispose())
|
|
876
853
|
else:
|
|
877
|
-
sync_eng = cast(SyncEngine, eng)
|
|
854
|
+
sync_eng = cast("SyncEngine", eng)
|
|
878
855
|
with sync_eng.begin() as conn:
|
|
879
856
|
conn.execute(text("SELECT 1"))
|
|
880
857
|
sync_eng.dispose()
|
|
@@ -894,7 +871,7 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
|
|
|
894
871
|
return
|
|
895
872
|
script_location = Path(script_location_str)
|
|
896
873
|
versions_dir = script_location / "versions"
|
|
897
|
-
local_ids:
|
|
874
|
+
local_ids: set[str] = set()
|
|
898
875
|
if versions_dir.exists():
|
|
899
876
|
for p in versions_dir.glob("*.py"):
|
|
900
877
|
try:
|
|
@@ -912,7 +889,7 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
|
|
|
912
889
|
if is_async_url(url_obj):
|
|
913
890
|
|
|
914
891
|
async def _run() -> None:
|
|
915
|
-
eng = cast(AsyncEngineType, build_engine(url_obj))
|
|
892
|
+
eng = cast("AsyncEngineType", build_engine(url_obj))
|
|
916
893
|
try:
|
|
917
894
|
async with eng.begin() as conn:
|
|
918
895
|
# Do sync-y inspector / SQL via run_sync
|
|
@@ -927,9 +904,7 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
|
|
|
927
904
|
)
|
|
928
905
|
missing = any((ver not in local_ids) for (ver,) in rows)
|
|
929
906
|
if missing:
|
|
930
|
-
sync_conn.execute(
|
|
931
|
-
text("DROP TABLE IF EXISTS alembic_version")
|
|
932
|
-
)
|
|
907
|
+
sync_conn.execute(text("DROP TABLE IF EXISTS alembic_version"))
|
|
933
908
|
|
|
934
909
|
await conn.run_sync(_check_and_maybe_drop)
|
|
935
910
|
finally:
|
|
@@ -937,17 +912,13 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
|
|
|
937
912
|
|
|
938
913
|
asyncio.run(_run())
|
|
939
914
|
else:
|
|
940
|
-
eng = cast(SyncEngine, build_engine(url_obj))
|
|
915
|
+
eng = cast("SyncEngine", build_engine(url_obj))
|
|
941
916
|
try:
|
|
942
917
|
with eng.begin() as c:
|
|
943
918
|
insp = inspect(c)
|
|
944
919
|
if not insp.has_table("alembic_version"):
|
|
945
920
|
return
|
|
946
|
-
rows = list(
|
|
947
|
-
c.execute(
|
|
948
|
-
text("SELECT version_num FROM alembic_version")
|
|
949
|
-
).fetchall()
|
|
950
|
-
)
|
|
921
|
+
rows = list(c.execute(text("SELECT version_num FROM alembic_version")).fetchall())
|
|
951
922
|
missing = any((ver not in local_ids) for (ver,) in rows)
|
|
952
923
|
if missing:
|
|
953
924
|
c.execute(text("DROP TABLE IF EXISTS alembic_version"))
|
svc_infra/db/utils.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
1
2
|
from pathlib import Path
|
|
2
|
-
from typing import Sequence, Tuple, Union
|
|
3
3
|
|
|
4
|
-
KeySpec =
|
|
4
|
+
KeySpec = str | Sequence[str]
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
def as_tuple(spec: KeySpec) ->
|
|
7
|
+
def as_tuple(spec: KeySpec) -> tuple[str, ...]:
|
|
8
8
|
return (spec,) if isinstance(spec, str) else tuple(spec)
|
|
9
9
|
|
|
10
10
|
|
svc_infra/deploy/__init__.py
CHANGED
|
@@ -33,7 +33,6 @@ from __future__ import annotations
|
|
|
33
33
|
import os
|
|
34
34
|
from enum import StrEnum
|
|
35
35
|
from functools import cache
|
|
36
|
-
from typing import Optional
|
|
37
36
|
|
|
38
37
|
|
|
39
38
|
class Platform(StrEnum):
|
|
@@ -98,9 +97,7 @@ PLATFORM_SIGNATURES: dict[Platform, tuple[str, ...]] = {
|
|
|
98
97
|
Platform.AZURE_APP_SERVICE: ("WEBSITE_SITE_NAME", "WEBSITE_INSTANCE_ID"),
|
|
99
98
|
# Generic container/orchestration (check last)
|
|
100
99
|
Platform.KUBERNETES: ("KUBERNETES_SERVICE_HOST", "KUBERNETES_PORT"),
|
|
101
|
-
Platform.DOCKER: (
|
|
102
|
-
"DOCKER_CONTAINER",
|
|
103
|
-
), # User must set this; no reliable auto-detect
|
|
100
|
+
Platform.DOCKER: ("DOCKER_CONTAINER",), # User must set this; no reliable auto-detect
|
|
104
101
|
}
|
|
105
102
|
|
|
106
103
|
# Container detection paths (Linux-specific)
|
|
@@ -126,7 +123,7 @@ def _is_in_container() -> bool:
|
|
|
126
123
|
|
|
127
124
|
# Check cgroup (Linux)
|
|
128
125
|
try:
|
|
129
|
-
with open("/proc/1/cgroup"
|
|
126
|
+
with open("/proc/1/cgroup") as f:
|
|
130
127
|
cgroup = f.read()
|
|
131
128
|
if "docker" in cgroup or "kubepods" in cgroup or "containerd" in cgroup:
|
|
132
129
|
return True
|
|
@@ -169,9 +166,7 @@ def get_platform() -> Platform:
|
|
|
169
166
|
|
|
170
167
|
|
|
171
168
|
# Platform category groupings
|
|
172
|
-
AWS_PLATFORMS = frozenset(
|
|
173
|
-
{Platform.AWS_ECS, Platform.AWS_LAMBDA, Platform.AWS_BEANSTALK}
|
|
174
|
-
)
|
|
169
|
+
AWS_PLATFORMS = frozenset({Platform.AWS_ECS, Platform.AWS_LAMBDA, Platform.AWS_BEANSTALK})
|
|
175
170
|
GCP_PLATFORMS = frozenset({Platform.CLOUD_RUN, Platform.APP_ENGINE, Platform.GCE})
|
|
176
171
|
AZURE_PLATFORMS = frozenset(
|
|
177
172
|
{
|
|
@@ -180,9 +175,7 @@ AZURE_PLATFORMS = frozenset(
|
|
|
180
175
|
Platform.AZURE_APP_SERVICE,
|
|
181
176
|
}
|
|
182
177
|
)
|
|
183
|
-
PAAS_PLATFORMS = frozenset(
|
|
184
|
-
{Platform.RAILWAY, Platform.RENDER, Platform.FLY, Platform.HEROKU}
|
|
185
|
-
)
|
|
178
|
+
PAAS_PLATFORMS = frozenset({Platform.RAILWAY, Platform.RENDER, Platform.FLY, Platform.HEROKU})
|
|
186
179
|
|
|
187
180
|
|
|
188
181
|
def is_aws() -> bool:
|
|
@@ -292,7 +285,7 @@ def get_database_url(
|
|
|
292
285
|
*,
|
|
293
286
|
prefer_private: bool = True,
|
|
294
287
|
normalize: bool = True,
|
|
295
|
-
) ->
|
|
288
|
+
) -> str | None:
|
|
296
289
|
"""
|
|
297
290
|
Get database URL with platform-aware resolution.
|
|
298
291
|
|
|
@@ -336,7 +329,7 @@ def get_database_url(
|
|
|
336
329
|
return None
|
|
337
330
|
|
|
338
331
|
|
|
339
|
-
def get_redis_url(*, prefer_private: bool = True) ->
|
|
332
|
+
def get_redis_url(*, prefer_private: bool = True) -> str | None:
|
|
340
333
|
"""
|
|
341
334
|
Get Redis URL with platform-aware resolution.
|
|
342
335
|
|
|
@@ -388,7 +381,7 @@ def get_service_url(
|
|
|
388
381
|
*,
|
|
389
382
|
default_port: int = 8000,
|
|
390
383
|
scheme: str = "http",
|
|
391
|
-
) ->
|
|
384
|
+
) -> str | None:
|
|
392
385
|
"""
|
|
393
386
|
Get URL for an internal service by name.
|
|
394
387
|
|
|
@@ -426,7 +419,7 @@ def get_service_url(
|
|
|
426
419
|
return None
|
|
427
420
|
|
|
428
421
|
|
|
429
|
-
def get_public_url() ->
|
|
422
|
+
def get_public_url() -> str | None:
|
|
430
423
|
"""
|
|
431
424
|
Get the public URL of this service.
|
|
432
425
|
|
svc_infra/documents/add.py
CHANGED
|
@@ -20,7 +20,7 @@ Quick Start:
|
|
|
20
20
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
|
-
from typing import TYPE_CHECKING,
|
|
23
|
+
from typing import TYPE_CHECKING, cast
|
|
24
24
|
|
|
25
25
|
from fastapi import HTTPException, Request, Response
|
|
26
26
|
|
|
@@ -34,7 +34,7 @@ if TYPE_CHECKING:
|
|
|
34
34
|
from .ease import DocumentManager
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
def get_documents_manager(app:
|
|
37
|
+
def get_documents_manager(app: FastAPI) -> DocumentManager:
|
|
38
38
|
"""
|
|
39
39
|
Dependency to get document manager from app state.
|
|
40
40
|
|
|
@@ -49,17 +49,16 @@ def get_documents_manager(app: "FastAPI") -> "DocumentManager":
|
|
|
49
49
|
"""
|
|
50
50
|
if not hasattr(app.state, "documents"):
|
|
51
51
|
raise RuntimeError("Documents not configured. Call add_documents(app) first.")
|
|
52
|
-
from .ease import DocumentManager
|
|
53
52
|
|
|
54
|
-
return cast(DocumentManager, app.state.documents)
|
|
53
|
+
return cast("DocumentManager", app.state.documents)
|
|
55
54
|
|
|
56
55
|
|
|
57
56
|
def add_documents(
|
|
58
|
-
app:
|
|
59
|
-
storage_backend:
|
|
57
|
+
app: FastAPI,
|
|
58
|
+
storage_backend: StorageBackend | None = None,
|
|
60
59
|
prefix: str = "/documents",
|
|
61
|
-
tags:
|
|
62
|
-
) ->
|
|
60
|
+
tags: list[str] | None = None,
|
|
61
|
+
) -> DocumentManager:
|
|
63
62
|
"""
|
|
64
63
|
Add document management endpoints to FastAPI app.
|
|
65
64
|
|
svc_infra/documents/ease.py
CHANGED
|
@@ -27,7 +27,7 @@ Quick Start:
|
|
|
27
27
|
|
|
28
28
|
from __future__ import annotations
|
|
29
29
|
|
|
30
|
-
from typing import TYPE_CHECKING
|
|
30
|
+
from typing import TYPE_CHECKING
|
|
31
31
|
|
|
32
32
|
if TYPE_CHECKING:
|
|
33
33
|
from svc_infra.storage.base import StorageBackend
|
|
@@ -65,7 +65,7 @@ class DocumentManager:
|
|
|
65
65
|
>>> manager.delete(doc.id)
|
|
66
66
|
"""
|
|
67
67
|
|
|
68
|
-
def __init__(self, storage:
|
|
68
|
+
def __init__(self, storage: StorageBackend):
|
|
69
69
|
"""
|
|
70
70
|
Initialize document manager.
|
|
71
71
|
|
|
@@ -79,9 +79,9 @@ class DocumentManager:
|
|
|
79
79
|
user_id: str,
|
|
80
80
|
file: bytes,
|
|
81
81
|
filename: str,
|
|
82
|
-
metadata:
|
|
83
|
-
content_type:
|
|
84
|
-
) ->
|
|
82
|
+
metadata: dict | None = None,
|
|
83
|
+
content_type: str | None = None,
|
|
84
|
+
) -> Document:
|
|
85
85
|
"""
|
|
86
86
|
Upload a document.
|
|
87
87
|
|
|
@@ -133,7 +133,7 @@ class DocumentManager:
|
|
|
133
133
|
|
|
134
134
|
return await download_document(self.storage, document_id)
|
|
135
135
|
|
|
136
|
-
def get(self, document_id: str) ->
|
|
136
|
+
def get(self, document_id: str) -> Document | None:
|
|
137
137
|
"""
|
|
138
138
|
Get document metadata by ID.
|
|
139
139
|
|
|
@@ -174,7 +174,7 @@ class DocumentManager:
|
|
|
174
174
|
user_id: str,
|
|
175
175
|
limit: int = 100,
|
|
176
176
|
offset: int = 0,
|
|
177
|
-
) ->
|
|
177
|
+
) -> list[Document]:
|
|
178
178
|
"""
|
|
179
179
|
List user's documents.
|
|
180
180
|
|
|
@@ -198,7 +198,7 @@ class DocumentManager:
|
|
|
198
198
|
return list_documents(user_id, limit, offset)
|
|
199
199
|
|
|
200
200
|
|
|
201
|
-
def easy_documents(storage:
|
|
201
|
+
def easy_documents(storage: StorageBackend | None = None) -> DocumentManager:
|
|
202
202
|
"""
|
|
203
203
|
Create a document manager with auto-configured storage.
|
|
204
204
|
|
svc_infra/documents/models.py
CHANGED
|
@@ -22,7 +22,7 @@ Quick Start:
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
24
|
from datetime import datetime
|
|
25
|
-
from typing import Any
|
|
25
|
+
from typing import Any
|
|
26
26
|
|
|
27
27
|
from pydantic import BaseModel, ConfigDict, Field
|
|
28
28
|
|
|
@@ -105,10 +105,10 @@ class Document(BaseModel):
|
|
|
105
105
|
)
|
|
106
106
|
storage_path: str = Field(..., description="Storage backend path/key")
|
|
107
107
|
content_type: str = Field(..., description="MIME type (e.g., application/pdf)")
|
|
108
|
-
checksum:
|
|
108
|
+
checksum: str | None = Field(
|
|
109
109
|
None, description="File checksum for integrity validation (e.g., sha256:...)"
|
|
110
110
|
)
|
|
111
|
-
metadata:
|
|
111
|
+
metadata: dict[str, Any] = Field(
|
|
112
112
|
default_factory=dict,
|
|
113
113
|
description="Flexible metadata for custom fields (category, tags, dates, etc.)",
|
|
114
114
|
)
|
svc_infra/documents/storage.py
CHANGED
|
@@ -26,7 +26,7 @@ import hashlib
|
|
|
26
26
|
import mimetypes
|
|
27
27
|
import uuid
|
|
28
28
|
from datetime import datetime
|
|
29
|
-
from typing import TYPE_CHECKING
|
|
29
|
+
from typing import TYPE_CHECKING
|
|
30
30
|
|
|
31
31
|
if TYPE_CHECKING:
|
|
32
32
|
from svc_infra.storage.base import StorageBackend
|
|
@@ -35,17 +35,17 @@ if TYPE_CHECKING:
|
|
|
35
35
|
|
|
36
36
|
# In-memory metadata storage (production: use SQL database)
|
|
37
37
|
# This is a temporary solution until SQL integration is complete
|
|
38
|
-
_documents_metadata:
|
|
38
|
+
_documents_metadata: dict[str, Document] = {}
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
async def upload_document(
|
|
42
|
-
storage:
|
|
42
|
+
storage: StorageBackend,
|
|
43
43
|
user_id: str,
|
|
44
44
|
file: bytes,
|
|
45
45
|
filename: str,
|
|
46
|
-
metadata:
|
|
47
|
-
content_type:
|
|
48
|
-
) ->
|
|
46
|
+
metadata: dict | None = None,
|
|
47
|
+
content_type: str | None = None,
|
|
48
|
+
) -> Document:
|
|
49
49
|
"""
|
|
50
50
|
Upload a document with file content to storage backend.
|
|
51
51
|
|
|
@@ -105,9 +105,7 @@ async def upload_document(
|
|
|
105
105
|
checksum = f"sha256:{hashlib.sha256(file).hexdigest()}"
|
|
106
106
|
|
|
107
107
|
# Upload file to storage backend
|
|
108
|
-
await storage.put(
|
|
109
|
-
storage_path, file, content_type=content_type, metadata=metadata or {}
|
|
110
|
-
)
|
|
108
|
+
await storage.put(storage_path, file, content_type=content_type, metadata=metadata or {})
|
|
111
109
|
|
|
112
110
|
# Create document metadata
|
|
113
111
|
doc = Document(
|
|
@@ -128,7 +126,7 @@ async def upload_document(
|
|
|
128
126
|
return doc
|
|
129
127
|
|
|
130
128
|
|
|
131
|
-
def get_document(document_id: str) ->
|
|
129
|
+
def get_document(document_id: str) -> Document | None:
|
|
132
130
|
"""
|
|
133
131
|
Get document metadata by ID.
|
|
134
132
|
|
|
@@ -146,7 +144,7 @@ def get_document(document_id: str) -> Optional["Document"]:
|
|
|
146
144
|
return _documents_metadata.get(document_id)
|
|
147
145
|
|
|
148
146
|
|
|
149
|
-
async def download_document(storage:
|
|
147
|
+
async def download_document(storage: StorageBackend, document_id: str) -> bytes:
|
|
150
148
|
"""
|
|
151
149
|
Download document file content from storage.
|
|
152
150
|
|
|
@@ -176,7 +174,7 @@ async def download_document(storage: "StorageBackend", document_id: str) -> byte
|
|
|
176
174
|
return await storage.get(doc.storage_path)
|
|
177
175
|
|
|
178
176
|
|
|
179
|
-
async def delete_document(storage:
|
|
177
|
+
async def delete_document(storage: StorageBackend, document_id: str) -> bool:
|
|
180
178
|
"""
|
|
181
179
|
Delete document and its file content.
|
|
182
180
|
|
|
@@ -212,7 +210,7 @@ def list_documents(
|
|
|
212
210
|
user_id: str,
|
|
213
211
|
limit: int = 100,
|
|
214
212
|
offset: int = 0,
|
|
215
|
-
) ->
|
|
213
|
+
) -> list[Document]:
|
|
216
214
|
"""
|
|
217
215
|
List user's documents with pagination.
|
|
218
216
|
|