svc-infra 0.1.589__py3-none-any.whl → 0.1.706__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/__init__.py +58 -2
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +871 -0
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +19 -10
- svc_infra/apf_payments/service.py +211 -68
- svc_infra/apf_payments/settings.py +27 -3
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +145 -46
- svc_infra/api/fastapi/apf_payments/setup.py +26 -8
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +27 -14
- svc_infra/api/fastapi/auth/gaurd.py +104 -13
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
- svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +29 -5
- svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
- svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -56
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +52 -0
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +53 -0
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +212 -0
- svc_infra/security/audit_service.py +74 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +101 -0
- svc_infra/security/jwt_rotation.py +105 -0
- svc_infra/security/lockout.py +102 -0
- svc_infra/security/models.py +287 -0
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +130 -0
- svc_infra/security/passwords.py +79 -0
- svc_infra/security/permissions.py +171 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +100 -0
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.589.dist-info/METADATA +0 -79
- svc_infra-0.1.589.dist-info/RECORD +0 -234
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
svc_infra/data/backup.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class BackupHealthReport:
|
|
10
|
+
ok: bool
|
|
11
|
+
last_success: Optional[datetime]
|
|
12
|
+
retention_days: Optional[int]
|
|
13
|
+
message: str = ""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verify_backups(
|
|
17
|
+
*, last_success: Optional[datetime] = None, retention_days: Optional[int] = None
|
|
18
|
+
) -> BackupHealthReport:
|
|
19
|
+
"""Return a basic backup health report.
|
|
20
|
+
|
|
21
|
+
In production, callers should plug a provider-specific checker and translate into this report.
|
|
22
|
+
"""
|
|
23
|
+
if last_success is None:
|
|
24
|
+
return BackupHealthReport(
|
|
25
|
+
ok=False,
|
|
26
|
+
last_success=None,
|
|
27
|
+
retention_days=retention_days,
|
|
28
|
+
message="no_backup_seen",
|
|
29
|
+
)
|
|
30
|
+
now = datetime.now(timezone.utc)
|
|
31
|
+
age_days = (now - last_success).total_seconds() / 86400.0
|
|
32
|
+
ok = retention_days is None or age_days <= max(1, retention_days)
|
|
33
|
+
return BackupHealthReport(
|
|
34
|
+
ok=ok, last_success=last_success, retention_days=retention_days
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = ["BackupHealthReport", "verify_backups"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def make_backup_verification_job(
|
|
42
|
+
checker: Callable[[], BackupHealthReport],
|
|
43
|
+
*,
|
|
44
|
+
on_report: Optional[Callable[[BackupHealthReport], None]] = None,
|
|
45
|
+
):
|
|
46
|
+
"""Return a callable suitable for scheduling in a job runner.
|
|
47
|
+
|
|
48
|
+
The checker should perform provider-specific checks and return a BackupHealthReport.
|
|
49
|
+
If on_report is provided, it will be invoked with the report.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def _job() -> BackupHealthReport:
|
|
53
|
+
rep = checker()
|
|
54
|
+
if on_report:
|
|
55
|
+
on_report(rep)
|
|
56
|
+
return rep
|
|
57
|
+
|
|
58
|
+
return _job
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Awaitable, Callable, Iterable, Optional, Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SqlSession(Protocol): # minimal protocol for tests/integration
|
|
8
|
+
async def execute(self, stmt: Any) -> Any:
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ErasureStep:
|
|
14
|
+
name: str
|
|
15
|
+
run: Callable[[SqlSession, str], Awaitable[int] | int]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ErasurePlan:
|
|
20
|
+
steps: Iterable[ErasureStep]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def run_erasure(
|
|
24
|
+
session: SqlSession,
|
|
25
|
+
principal_id: str,
|
|
26
|
+
plan: ErasurePlan,
|
|
27
|
+
*,
|
|
28
|
+
on_audit: Optional[Callable[[str, dict[str, Any]], None]] = None,
|
|
29
|
+
) -> int:
|
|
30
|
+
"""Run an erasure plan and optionally emit an audit event.
|
|
31
|
+
|
|
32
|
+
Returns total affected rows across steps.
|
|
33
|
+
"""
|
|
34
|
+
total = 0
|
|
35
|
+
for s in plan.steps:
|
|
36
|
+
res = s.run(session, principal_id)
|
|
37
|
+
if hasattr(res, "__await__"):
|
|
38
|
+
res = await res
|
|
39
|
+
total += int(res or 0)
|
|
40
|
+
if on_audit:
|
|
41
|
+
on_audit("erasure.completed", {"principal_id": principal_id, "affected": total})
|
|
42
|
+
return total
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = ["ErasureStep", "ErasurePlan", "run_erasure"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Awaitable, Callable, Iterable, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def run_fixtures(
|
|
9
|
+
loaders: Iterable[Callable[[], None | Awaitable[None]]],
|
|
10
|
+
*,
|
|
11
|
+
run_once_file: Optional[str] = None,
|
|
12
|
+
) -> None:
|
|
13
|
+
"""Run a sequence of fixture loaders (sync or async).
|
|
14
|
+
|
|
15
|
+
- If run_once_file is provided and exists, does nothing.
|
|
16
|
+
- On success, creates the run_once_file sentinel (parent dirs included).
|
|
17
|
+
"""
|
|
18
|
+
if run_once_file:
|
|
19
|
+
sentinel = Path(run_once_file)
|
|
20
|
+
if sentinel.exists():
|
|
21
|
+
return
|
|
22
|
+
for fn in loaders:
|
|
23
|
+
res = fn()
|
|
24
|
+
if inspect.isawaitable(res):
|
|
25
|
+
await res
|
|
26
|
+
if run_once_file:
|
|
27
|
+
sentinel.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
Path(run_once_file).write_text("ok")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def make_on_load_fixtures(
|
|
32
|
+
*loaders: Callable[[], None | Awaitable[None]], run_once_file: Optional[str] = None
|
|
33
|
+
) -> Callable[[], Awaitable[None]]:
|
|
34
|
+
"""Return an async callable suitable for add_data_lifecycle(on_load_fixtures=...)."""
|
|
35
|
+
|
|
36
|
+
async def _runner() -> None:
|
|
37
|
+
await run_fixtures(loaders, run_once_file=run_once_file)
|
|
38
|
+
|
|
39
|
+
return _runner
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = ["run_fixtures", "make_on_load_fixtures"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from typing import Any, Iterable, Optional, Protocol, Sequence
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SqlSession(Protocol): # minimal protocol for tests/integration
|
|
9
|
+
async def execute(self, stmt: Any) -> Any:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class RetentionPolicy:
|
|
15
|
+
name: str
|
|
16
|
+
model: Any # SQLAlchemy model or test double exposing columns
|
|
17
|
+
older_than_days: int
|
|
18
|
+
soft_delete_field: Optional[str] = "deleted_at"
|
|
19
|
+
extra_where: Optional[Sequence[Any]] = None
|
|
20
|
+
hard_delete: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def purge_policy(session: SqlSession, policy: RetentionPolicy) -> int:
|
|
24
|
+
"""Execute a single retention purge according to policy.
|
|
25
|
+
|
|
26
|
+
If hard_delete is False and soft_delete_field exists on model, set timestamp; else DELETE.
|
|
27
|
+
Returns number of affected rows (best-effort; test doubles may return an int directly).
|
|
28
|
+
"""
|
|
29
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=policy.older_than_days)
|
|
30
|
+
m = policy.model
|
|
31
|
+
where = list(policy.extra_where or [])
|
|
32
|
+
created_col = getattr(m, "created_at", None)
|
|
33
|
+
if created_col is not None and hasattr(created_col, "__le__"):
|
|
34
|
+
where.append(created_col <= cutoff)
|
|
35
|
+
|
|
36
|
+
# Soft-delete path when available and requested
|
|
37
|
+
if (
|
|
38
|
+
not policy.hard_delete
|
|
39
|
+
and policy.soft_delete_field
|
|
40
|
+
and hasattr(m, policy.soft_delete_field)
|
|
41
|
+
):
|
|
42
|
+
stmt = m.update().where(*where).values({policy.soft_delete_field: cutoff})
|
|
43
|
+
res = await session.execute(stmt)
|
|
44
|
+
return getattr(res, "rowcount", 0)
|
|
45
|
+
|
|
46
|
+
# Hard delete fallback
|
|
47
|
+
stmt = m.delete().where(*where)
|
|
48
|
+
res = await session.execute(stmt)
|
|
49
|
+
return getattr(res, "rowcount", 0)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def run_retention_purge(
|
|
53
|
+
session: SqlSession, policies: Iterable[RetentionPolicy]
|
|
54
|
+
) -> int:
|
|
55
|
+
total = 0
|
|
56
|
+
for p in policies:
|
|
57
|
+
total += await purge_policy(session, p)
|
|
58
|
+
return total
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = ["RetentionPolicy", "purge_policy", "run_retention_purge"]
|
svc_infra/db/__init__.py
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from svc_infra.db.ops import (
|
|
2
|
+
drop_table_safe,
|
|
3
|
+
get_database_url,
|
|
4
|
+
kill_blocking_queries,
|
|
5
|
+
run_sync_sql,
|
|
6
|
+
wait_for_database,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"drop_table_safe",
|
|
11
|
+
"get_database_url",
|
|
12
|
+
"kill_blocking_queries",
|
|
13
|
+
"run_sync_sql",
|
|
14
|
+
"wait_for_database",
|
|
15
|
+
]
|
svc_infra/db/crud_schema.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
-
from typing import Any, Optional, Sequence
|
|
4
|
+
from typing import Any, Optional, Sequence, cast
|
|
5
5
|
|
|
6
6
|
from pydantic import BaseModel, ConfigDict, create_model
|
|
7
7
|
|
|
@@ -27,9 +27,9 @@ class FieldSpec:
|
|
|
27
27
|
exclude_from_update: bool = False
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
def _opt(t: type[Any]) -> tuple[
|
|
30
|
+
def _opt(t: type[Any]) -> tuple[Any, Any]:
|
|
31
31
|
# convenience: Optional[t] with default None
|
|
32
|
-
return (t | None, None)
|
|
32
|
+
return (t | None, None)
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def make_crud_schemas_from_specs(
|
|
@@ -40,9 +40,9 @@ def make_crud_schemas_from_specs(
|
|
|
40
40
|
update_name: Optional[str],
|
|
41
41
|
json_encoders: Optional[dict[type[Any], Any]] = None,
|
|
42
42
|
) -> tuple[type[BaseModel], type[BaseModel], type[BaseModel]]:
|
|
43
|
-
ann_read: dict[str, tuple[
|
|
44
|
-
ann_create: dict[str, tuple[
|
|
45
|
-
ann_update: dict[str, tuple[
|
|
43
|
+
ann_read: dict[str, tuple[Any, Any]] = {}
|
|
44
|
+
ann_create: dict[str, tuple[Any, Any]] = {}
|
|
45
|
+
ann_update: dict[str, tuple[Any, Any]] = {}
|
|
46
46
|
|
|
47
47
|
for s in specs:
|
|
48
48
|
# READ: include unless excluded; all fields Optional
|
|
@@ -60,9 +60,9 @@ def make_crud_schemas_from_specs(
|
|
|
60
60
|
if not s.exclude_from_update:
|
|
61
61
|
ann_update[s.name] = _opt(s.typ)
|
|
62
62
|
|
|
63
|
-
Read = create_model(read_name or "Read", **ann_read)
|
|
64
|
-
Create = create_model(create_name or "Create", **ann_create)
|
|
65
|
-
Update = create_model(update_name or "Update", **ann_update)
|
|
63
|
+
Read = create_model(read_name or "Read", **cast(dict[str, Any], ann_read))
|
|
64
|
+
Create = create_model(create_name or "Create", **cast(dict[str, Any], ann_create))
|
|
65
|
+
Update = create_model(update_name or "Update", **cast(dict[str, Any], ann_update))
|
|
66
66
|
|
|
67
67
|
cfg = ConfigDict(from_attributes=True)
|
|
68
68
|
if json_encoders:
|
svc_infra/db/inbox.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InboxStore(Protocol):
|
|
8
|
+
def mark_if_new(self, key: str, ttl_seconds: int = 24 * 3600) -> bool:
|
|
9
|
+
"""Mark key as processed if not seen; return True if newly marked, False if duplicate."""
|
|
10
|
+
...
|
|
11
|
+
|
|
12
|
+
def purge_expired(self) -> int:
|
|
13
|
+
"""Optional: remove expired keys, return number purged."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
def is_marked(self, key: str) -> bool:
|
|
17
|
+
"""Return True if key is already marked (not expired), without modifying it."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InMemoryInboxStore:
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self._keys: dict[str, float] = {}
|
|
24
|
+
|
|
25
|
+
def mark_if_new(self, key: str, ttl_seconds: int = 24 * 3600) -> bool:
|
|
26
|
+
now = time.time()
|
|
27
|
+
exp = self._keys.get(key)
|
|
28
|
+
if exp and exp > now:
|
|
29
|
+
return False
|
|
30
|
+
self._keys[key] = now + ttl_seconds
|
|
31
|
+
return True
|
|
32
|
+
|
|
33
|
+
def purge_expired(self) -> int:
|
|
34
|
+
now = time.time()
|
|
35
|
+
to_del = [k for k, e in self._keys.items() if e <= now]
|
|
36
|
+
for k in to_del:
|
|
37
|
+
self._keys.pop(k, None)
|
|
38
|
+
return len(to_del)
|
|
39
|
+
|
|
40
|
+
def is_marked(self, key: str) -> bool:
|
|
41
|
+
now = time.time()
|
|
42
|
+
exp = self._keys.get(key)
|
|
43
|
+
return bool(exp and exp > now)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SqlInboxStore:
|
|
47
|
+
"""Skeleton for a SQL-backed inbox store (dedupe table).
|
|
48
|
+
|
|
49
|
+
Implementations should:
|
|
50
|
+
- INSERT key with expires_at if not exists (unique constraint)
|
|
51
|
+
- Return False on duplicate key violations
|
|
52
|
+
- Periodically DELETE expired rows
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, session_factory):
|
|
56
|
+
self._session_factory = session_factory
|
|
57
|
+
|
|
58
|
+
def mark_if_new(
|
|
59
|
+
self, key: str, ttl_seconds: int = 24 * 3600
|
|
60
|
+
) -> bool: # pragma: no cover - skeleton
|
|
61
|
+
raise NotImplementedError
|
|
62
|
+
|
|
63
|
+
def purge_expired(self) -> int: # pragma: no cover - skeleton
|
|
64
|
+
raise NotImplementedError
|
|
65
|
+
|
|
66
|
+
def is_marked(self, key: str) -> bool: # pragma: no cover - skeleton
|
|
67
|
+
raise NotImplementedError
|
svc_infra/db/nosql/__init__.py
CHANGED
svc_infra/db/nosql/core.py
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
-
from typing import Iterable, Sequence
|
|
4
|
+
from typing import Any, Iterable, Sequence, cast
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
from
|
|
6
|
+
try:
|
|
7
|
+
from motor.motor_asyncio import AsyncIOMotorDatabase
|
|
8
|
+
from pymongo import IndexModel
|
|
9
|
+
|
|
10
|
+
HAS_MOTOR = True
|
|
11
|
+
except ImportError: # pragma: no cover
|
|
12
|
+
HAS_MOTOR = False
|
|
13
|
+
AsyncIOMotorDatabase = Any # type: ignore[assignment, misc]
|
|
14
|
+
IndexModel = Any # type: ignore[assignment, misc]
|
|
8
15
|
|
|
9
16
|
from svc_infra.db.nosql.indexes import normalize_indexes
|
|
10
|
-
from svc_infra.db.nosql.mongo.client import
|
|
17
|
+
from svc_infra.db.nosql.mongo.client import (
|
|
18
|
+
acquire_db,
|
|
19
|
+
close_mongo,
|
|
20
|
+
init_mongo,
|
|
21
|
+
ping_mongo,
|
|
22
|
+
)
|
|
11
23
|
from svc_infra.db.nosql.resource import NoSqlResource
|
|
12
24
|
from svc_infra.db.nosql.utils import (
|
|
13
25
|
get_mongo_dbname_from_env,
|
|
@@ -34,7 +46,8 @@ async def _apply_indexes(
|
|
|
34
46
|
) -> list[str]:
|
|
35
47
|
if not indexes:
|
|
36
48
|
return []
|
|
37
|
-
|
|
49
|
+
result = await db[collection].create_indexes(list(indexes))
|
|
50
|
+
return cast(list[str], result)
|
|
38
51
|
|
|
39
52
|
|
|
40
53
|
# collection + doc used to "lock" the chosen DB name for this app
|
|
@@ -48,7 +61,9 @@ async def assert_db_locked(
|
|
|
48
61
|
registry = db.client.get_database(_REG_DB)
|
|
49
62
|
await registry[_REG_COLL].create_index("service_id", unique=True)
|
|
50
63
|
|
|
51
|
-
doc = await registry[_REG_COLL].find_one(
|
|
64
|
+
doc = await registry[_REG_COLL].find_one(
|
|
65
|
+
{"service_id": service_id}, projection={"db_name": 1}
|
|
66
|
+
)
|
|
52
67
|
if doc is None:
|
|
53
68
|
await registry[_REG_COLL].insert_one(
|
|
54
69
|
{"service_id": service_id, "db_name": expected_db_name}
|
|
@@ -91,9 +106,13 @@ async def prepare_mongo(
|
|
|
91
106
|
|
|
92
107
|
expected_db = get_mongo_dbname_from_env(required=True)
|
|
93
108
|
if db.name != expected_db:
|
|
94
|
-
raise RuntimeError(
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
f"Connected to Mongo DB '{db.name}', but env says '{expected_db}'."
|
|
111
|
+
)
|
|
95
112
|
|
|
96
|
-
await assert_db_locked(
|
|
113
|
+
await assert_db_locked(
|
|
114
|
+
db, expected_db, service_id=service_id, allow_rebind=allow_rebind
|
|
115
|
+
)
|
|
97
116
|
|
|
98
117
|
# collections
|
|
99
118
|
colls = [r.resolved_collection() for r in resources]
|
|
@@ -109,7 +128,9 @@ async def prepare_mongo(
|
|
|
109
128
|
names = await _apply_indexes(db, collection=coll, indexes=idx_models)
|
|
110
129
|
created_idx[coll] = names
|
|
111
130
|
|
|
112
|
-
return PrepareResult(
|
|
131
|
+
return PrepareResult(
|
|
132
|
+
ok=True, created_collections=created_colls, created_indexes=created_idx
|
|
133
|
+
)
|
|
113
134
|
|
|
114
135
|
|
|
115
136
|
def setup_and_prepare(
|
svc_infra/db/nosql/indexes.py
CHANGED
|
@@ -66,7 +66,9 @@ def normalize_index(idx: Union[IndexModel, Alias]) -> IndexModel:
|
|
|
66
66
|
return IndexModel(keys, **kwargs)
|
|
67
67
|
|
|
68
68
|
|
|
69
|
-
def normalize_indexes(
|
|
69
|
+
def normalize_indexes(
|
|
70
|
+
indexes: Optional[Iterable[Union[IndexModel, Alias]]],
|
|
71
|
+
) -> List[IndexModel]:
|
|
70
72
|
if not indexes:
|
|
71
73
|
return []
|
|
72
74
|
return [normalize_index(i) for i in indexes]
|
svc_infra/db/nosql/management.py
CHANGED
|
@@ -83,7 +83,7 @@ def make_document_crud_schemas(
|
|
|
83
83
|
)
|
|
84
84
|
|
|
85
85
|
# Backstop encoders in case any exotic types slip through
|
|
86
|
-
encoders = {ObjectId: str, PyObjectId: str}
|
|
86
|
+
encoders: dict[type, Any] = {ObjectId: str, PyObjectId: str}
|
|
87
87
|
if json_encoders:
|
|
88
88
|
encoders.update(json_encoders)
|
|
89
89
|
|
|
@@ -29,17 +29,17 @@ We provide four CLI commands. You can register them on your Typer app or invoke
|
|
|
29
29
|
|
|
30
30
|
### Commands
|
|
31
31
|
|
|
32
|
-
- `mongo
|
|
33
|
-
- `mongo
|
|
34
|
-
- `mongo
|
|
35
|
-
- `mongo
|
|
32
|
+
- `mongo scaffold` — create both document **and** CRUD schemas
|
|
33
|
+
- `mongo scaffold-documents` — create only the **document** model (Pydantic)
|
|
34
|
+
- `mongo scaffold-schemas` — create only the **CRUD schemas**
|
|
35
|
+
- `mongo scaffold-resources` — create a starter `resources.py` with a `RESOURCES` list
|
|
36
36
|
|
|
37
37
|
### Typical usage
|
|
38
38
|
|
|
39
39
|
#### A) Scaffold documents + schemas together
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
|
-
yourapp mongo
|
|
42
|
+
yourapp mongo scaffold \
|
|
43
43
|
--entity-name Product \
|
|
44
44
|
--documents-dir ./src/your_app/products \
|
|
45
45
|
--schemas-dir ./src/your_app/products \
|
|
@@ -57,7 +57,7 @@ src/your_app/products/schemas.py # ProductRead/ProductCreate/ProductUpdate
|
|
|
57
57
|
B) Documents only
|
|
58
58
|
|
|
59
59
|
```bash
|
|
60
|
-
yourapp mongo
|
|
60
|
+
yourapp mongo scaffold-documents \
|
|
61
61
|
--dest-dir ./src/your_app/products \
|
|
62
62
|
--entity-name Product \
|
|
63
63
|
--documents-filename product_doc.py
|
|
@@ -66,7 +66,7 @@ yourapp mongo-scaffold-documents \
|
|
|
66
66
|
C) Schemas only
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
|
-
yourapp mongo
|
|
69
|
+
yourapp mongo scaffold-schemas \
|
|
70
70
|
--dest-dir ./src/your_app/products \
|
|
71
71
|
--entity-name Product \
|
|
72
72
|
--schemas-filename product_schemas.py
|
|
@@ -75,7 +75,7 @@ yourapp mongo-scaffold-schemas \
|
|
|
75
75
|
D) Starter resources.py
|
|
76
76
|
|
|
77
77
|
```bash
|
|
78
|
-
yourapp mongo
|
|
78
|
+
yourapp mongo scaffold-resources \
|
|
79
79
|
--dest-dir ./src/your_app/mongo \
|
|
80
80
|
--filename resources.py \
|
|
81
81
|
--overwrite
|
|
@@ -131,7 +131,7 @@ There are two flavors:
|
|
|
131
131
|
A) Async, minimal (connect, create collections, apply indexes)
|
|
132
132
|
|
|
133
133
|
```bash
|
|
134
|
-
yourapp mongo
|
|
134
|
+
yourapp mongo prepare \
|
|
135
135
|
--resources your_app.mongo.resources:RESOURCES \
|
|
136
136
|
--mongo-url "$MONGO_URL" \
|
|
137
137
|
--mongo-db "$MONGO_DB"
|
|
@@ -140,7 +140,7 @@ yourapp mongo-prepare \
|
|
|
140
140
|
B) Synchronous wrapper (end-to-end convenience)
|
|
141
141
|
|
|
142
142
|
```bash
|
|
143
|
-
yourapp mongo
|
|
143
|
+
yourapp mongo setup-and-prepare \
|
|
144
144
|
--resources your_app.mongo.resources:RESOURCES \
|
|
145
145
|
--mongo-url "$MONGO_URL" \
|
|
146
146
|
--mongo-db "$MONGO_DB"
|
|
@@ -149,7 +149,7 @@ yourapp mongo-setup-and-prepare \
|
|
|
149
149
|
You can also ping connectivity:
|
|
150
150
|
|
|
151
151
|
```bash
|
|
152
|
-
yourapp mongo
|
|
152
|
+
yourapp mongo ping --mongo-url "$MONGO_URL" --mongo-db "$MONGO_DB"
|
|
153
153
|
```
|
|
154
154
|
|
|
155
155
|
Behind the scenes, preparation also locks a service ID to a DB name to prevent accidental cross-DB usage. You can pass --allow-rebind if you intentionally move environments.
|
|
@@ -430,9 +430,9 @@ NoSqlResource(
|
|
|
430
430
|
• If using explicit schemas with PyObjectId, make sure model_config.json_encoders includes {PyObjectId: str}.
|
|
431
431
|
• When using auto-schemas, we expose ObjectId-like fields as str so no custom encoder is needed.
|
|
432
432
|
• Connected to wrong DB name
|
|
433
|
-
|
|
433
|
+
• The system locks a service_id to the DB name once prepared. If you change DBs, run `mongo prepare` with --allow-rebind.
|
|
434
434
|
• Indexes not created
|
|
435
|
-
|
|
435
|
+
• Double-check RESOURCES[indexes]. Run `mongo prepare` again and inspect the output dictionary of created indexes.
|
|
436
436
|
|
|
437
437
|
⸻
|
|
438
438
|
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Optional
|
|
3
|
+
from typing import Any, Optional
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
try:
|
|
6
|
+
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
|
7
|
+
|
|
8
|
+
HAS_MOTOR = True
|
|
9
|
+
except ImportError: # pragma: no cover
|
|
10
|
+
HAS_MOTOR = False
|
|
11
|
+
AsyncIOMotorClient = Any # type: ignore[assignment, misc]
|
|
12
|
+
AsyncIOMotorDatabase = Any # type: ignore[assignment, misc]
|
|
6
13
|
|
|
7
14
|
from .settings import MongoSettings
|
|
8
15
|
|
|
@@ -10,6 +17,15 @@ _client: Optional[AsyncIOMotorClient] = None
|
|
|
10
17
|
_db: Optional[AsyncIOMotorDatabase] = None
|
|
11
18
|
|
|
12
19
|
|
|
20
|
+
def _require_motor() -> None:
|
|
21
|
+
"""Raise ImportError if motor is not installed."""
|
|
22
|
+
if not HAS_MOTOR:
|
|
23
|
+
raise ImportError(
|
|
24
|
+
"MongoDB support requires the 'motor' package. "
|
|
25
|
+
"Install with: pip install svc-infra[mongodb]"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
13
29
|
def _client_opts(cfg: MongoSettings) -> dict:
|
|
14
30
|
return {
|
|
15
31
|
"appname": cfg.appname,
|
|
@@ -20,6 +36,7 @@ def _client_opts(cfg: MongoSettings) -> dict:
|
|
|
20
36
|
|
|
21
37
|
|
|
22
38
|
async def init_mongo(cfg: MongoSettings | None = None) -> AsyncIOMotorDatabase:
|
|
39
|
+
_require_motor()
|
|
23
40
|
global _client, _db
|
|
24
41
|
cfg = cfg or MongoSettings()
|
|
25
42
|
if _client is None:
|
|
@@ -6,8 +6,12 @@ from pydantic import AnyUrl, BaseModel, Field
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class MongoSettings(BaseModel):
|
|
9
|
-
url: AnyUrl = Field(
|
|
9
|
+
url: AnyUrl = Field(
|
|
10
|
+
default_factory=lambda: os.getenv("MONGO_URL", "mongodb://localhost:27017")
|
|
11
|
+
) # type: ignore[assignment]
|
|
10
12
|
db_name: str = Field(default_factory=lambda: os.getenv("MONGO_DB", ""))
|
|
11
|
-
appname: str = Field(
|
|
13
|
+
appname: str = Field(
|
|
14
|
+
default_factory=lambda: os.getenv("MONGO_APPNAME", "svc-infra")
|
|
15
|
+
)
|
|
12
16
|
min_pool_size: int = int(os.getenv("MONGO_MIN_POOL", "0"))
|
|
13
17
|
max_pool_size: int = int(os.getenv("MONGO_MAX_POOL", "100"))
|