svc-infra 0.1.600__py3-none-any.whl → 0.1.664__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/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/setup.py +0 -2
- svc_infra/api/fastapi/auth/add.py +0 -4
- svc_infra/api/fastapi/auth/routers/oauth_router.py +19 -4
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/dependencies/ratelimit.py +57 -7
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +41 -6
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +59 -1
- svc_infra/api/fastapi/middleware/ratelimit_store.py +12 -6
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +114 -0
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +3 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +21 -13
- 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/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -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 +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +28 -8
- svc_infra/cli/cmds/__init__.py +8 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/sql/repository.py +51 -11
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- 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 +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/docs/acceptance-matrix.md +88 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/adr/0012-generic-file-storage.md +498 -0
- svc_infra/docs/api.md +186 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/storage.md +982 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/webhook_delivery.py +14 -2
- svc_infra/jobs/queue.py +9 -1
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/worker.py +17 -1
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +6 -2
- svc_infra/security/models.py +27 -7
- svc_infra/security/oauth_models.py +59 -0
- svc_infra/security/permissions.py +1 -0
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +250 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +331 -0
- svc_infra/storage/backends/memory.py +214 -0
- svc_infra/storage/backends/s3.py +329 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +182 -0
- svc_infra/storage/settings.py +192 -0
- svc_infra/webhooks/service.py +10 -2
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/METADATA +45 -14
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/RECORD +140 -52
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/entry_points.txt +0 -0
svc_infra/api/fastapi/setup.py
CHANGED
|
@@ -14,9 +14,14 @@ from svc_infra.api.fastapi.docs.landing import CardSpec, DocTargets, render_inde
|
|
|
14
14
|
from svc_infra.api.fastapi.docs.scoped import DOC_SCOPES
|
|
15
15
|
from svc_infra.api.fastapi.middleware.errors.catchall import CatchAllExceptionMiddleware
|
|
16
16
|
from svc_infra.api.fastapi.middleware.errors.handlers import register_error_handlers
|
|
17
|
+
from svc_infra.api.fastapi.middleware.graceful_shutdown import install_graceful_shutdown
|
|
17
18
|
from svc_infra.api.fastapi.middleware.idempotency import IdempotencyMiddleware
|
|
18
19
|
from svc_infra.api.fastapi.middleware.ratelimit import SimpleRateLimitMiddleware
|
|
19
20
|
from svc_infra.api.fastapi.middleware.request_id import RequestIdMiddleware
|
|
21
|
+
from svc_infra.api.fastapi.middleware.timeout import (
|
|
22
|
+
BodyReadTimeoutMiddleware,
|
|
23
|
+
HandlerTimeoutMiddleware,
|
|
24
|
+
)
|
|
20
25
|
from svc_infra.api.fastapi.openapi.models import APIVersionSpec, ServiceInfo
|
|
21
26
|
from svc_infra.api.fastapi.openapi.mutators import setup_mutators
|
|
22
27
|
from svc_infra.api.fastapi.openapi.pipeline import apply_mutators
|
|
@@ -79,11 +84,16 @@ def _setup_cors(app: FastAPI, public_cors_origins: list[str] | str | None = None
|
|
|
79
84
|
|
|
80
85
|
def _setup_middlewares(app: FastAPI):
|
|
81
86
|
app.add_middleware(RequestIdMiddleware)
|
|
87
|
+
# Timeouts: enforce body read timeout first, then total handler timeout
|
|
88
|
+
app.add_middleware(BodyReadTimeoutMiddleware)
|
|
89
|
+
app.add_middleware(HandlerTimeoutMiddleware)
|
|
82
90
|
app.add_middleware(CatchAllExceptionMiddleware)
|
|
83
91
|
app.add_middleware(IdempotencyMiddleware)
|
|
84
92
|
app.add_middleware(SimpleRateLimitMiddleware)
|
|
85
93
|
register_error_handlers(app)
|
|
86
94
|
_add_route_logger(app)
|
|
95
|
+
# Graceful shutdown: track in-flight and wait on shutdown
|
|
96
|
+
install_graceful_shutdown(app)
|
|
87
97
|
|
|
88
98
|
|
|
89
99
|
def _coerce_list(value: str | Iterable[str] | None) -> list[str]:
|
|
@@ -148,8 +158,7 @@ def _build_parent_app(
|
|
|
148
158
|
root_server_url: str | None = None,
|
|
149
159
|
root_include_api_key: bool = False,
|
|
150
160
|
) -> FastAPI:
|
|
151
|
-
|
|
152
|
-
|
|
161
|
+
# Root docs are now enabled in all environments to match root card visibility
|
|
153
162
|
parent = FastAPI(
|
|
154
163
|
title=service.name,
|
|
155
164
|
version=service.release,
|
|
@@ -157,9 +166,9 @@ def _build_parent_app(
|
|
|
157
166
|
license_info=_dump_or_none(service.license),
|
|
158
167
|
terms_of_service=service.terms_of_service,
|
|
159
168
|
description=service.description,
|
|
160
|
-
docs_url=
|
|
161
|
-
redoc_url=
|
|
162
|
-
openapi_url=
|
|
169
|
+
docs_url="/docs",
|
|
170
|
+
redoc_url="/redoc",
|
|
171
|
+
openapi_url="/openapi.json",
|
|
163
172
|
)
|
|
164
173
|
|
|
165
174
|
_setup_cors(parent, public_cors_origins)
|
|
@@ -232,19 +241,18 @@ def setup_service_api(
|
|
|
232
241
|
mount_path = f"/{spec.tag.strip('/')}"
|
|
233
242
|
parent.mount(mount_path, child, name=spec.tag.strip("/"))
|
|
234
243
|
|
|
235
|
-
@parent.get("/", include_in_schema=False)
|
|
244
|
+
@parent.get("/", include_in_schema=False, response_class=HTMLResponse)
|
|
236
245
|
def index():
|
|
237
246
|
cards: list[CardSpec] = []
|
|
238
247
|
is_local_dev = CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV)
|
|
239
248
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
docs=DocTargets(swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"),
|
|
246
|
-
)
|
|
249
|
+
# Root card - always show in all environments
|
|
250
|
+
cards.append(
|
|
251
|
+
CardSpec(
|
|
252
|
+
tag="",
|
|
253
|
+
docs=DocTargets(swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"),
|
|
247
254
|
)
|
|
255
|
+
)
|
|
248
256
|
|
|
249
257
|
# Version cards
|
|
250
258
|
for spec in versions:
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Optional
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
|
|
7
|
+
from .context import set_tenant_resolver
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_tenancy(app: FastAPI, *, resolver: Optional[Callable[..., Any]] = None) -> None:
|
|
11
|
+
"""Wire tenancy resolver for the application.
|
|
12
|
+
|
|
13
|
+
Provide a resolver(request, identity, header) -> Optional[str] to override
|
|
14
|
+
the default resolution. Pass None to clear a previous override.
|
|
15
|
+
"""
|
|
16
|
+
set_tenant_resolver(resolver)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ["add_tenancy"]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any, Callable, Optional
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, HTTPException, Request
|
|
6
|
+
|
|
7
|
+
try: # optional import; auth may not be used by all consumers
|
|
8
|
+
from svc_infra.api.fastapi.auth.security import OptionalIdentity
|
|
9
|
+
except Exception: # pragma: no cover - fallback for minimal builds
|
|
10
|
+
OptionalIdentity = None # type: ignore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_tenant_resolver: Optional[Callable[..., Any]] = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_tenant_resolver(
|
|
17
|
+
fn: Optional[Callable[..., Any]],
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Set or clear a global override hook for tenant resolution.
|
|
20
|
+
|
|
21
|
+
The function receives (request, identity, tenant_header) and should return a tenant id
|
|
22
|
+
string or None to fall back to default logic.
|
|
23
|
+
"""
|
|
24
|
+
global _tenant_resolver
|
|
25
|
+
_tenant_resolver = fn
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _maybe_await(x):
|
|
29
|
+
if callable(getattr(x, "__await__", None)):
|
|
30
|
+
return await x # type: ignore[misc]
|
|
31
|
+
return x
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def resolve_tenant_id(
|
|
35
|
+
request: Request,
|
|
36
|
+
tenant_header: Optional[str] = None,
|
|
37
|
+
identity: Any = Depends(OptionalIdentity) if OptionalIdentity else None, # type: ignore[arg-type]
|
|
38
|
+
) -> Optional[str]:
|
|
39
|
+
"""Resolve tenant id from override, identity, header, or request.state.
|
|
40
|
+
|
|
41
|
+
Order:
|
|
42
|
+
1) Global override hook (set_tenant_resolver)
|
|
43
|
+
2) Auth identity: user.tenant_id then api_key.tenant_id (if available)
|
|
44
|
+
3) X-Tenant-Id header
|
|
45
|
+
4) request.state.tenant_id
|
|
46
|
+
"""
|
|
47
|
+
# read header value if not provided directly (supports direct calls without DI)
|
|
48
|
+
if tenant_header is None:
|
|
49
|
+
try:
|
|
50
|
+
tenant_header = request.headers.get("X-Tenant-Id") # type: ignore[assignment]
|
|
51
|
+
except Exception:
|
|
52
|
+
tenant_header = None
|
|
53
|
+
|
|
54
|
+
# 1) global override
|
|
55
|
+
if _tenant_resolver is not None:
|
|
56
|
+
try:
|
|
57
|
+
v = _tenant_resolver(request, identity, tenant_header)
|
|
58
|
+
v2 = await _maybe_await(v)
|
|
59
|
+
if v2:
|
|
60
|
+
return str(v2)
|
|
61
|
+
except Exception:
|
|
62
|
+
# fall through to defaults
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
# 2) from identity
|
|
66
|
+
try:
|
|
67
|
+
if identity and getattr(identity, "user", None) is not None:
|
|
68
|
+
tid = getattr(identity.user, "tenant_id", None)
|
|
69
|
+
if tid:
|
|
70
|
+
return str(tid)
|
|
71
|
+
if identity and getattr(identity, "api_key", None) is not None:
|
|
72
|
+
tid = getattr(identity.api_key, "tenant_id", None)
|
|
73
|
+
if tid:
|
|
74
|
+
return str(tid)
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
# 3) from header
|
|
79
|
+
if tenant_header and isinstance(tenant_header, str) and tenant_header.strip():
|
|
80
|
+
return tenant_header.strip()
|
|
81
|
+
|
|
82
|
+
# 4) request.state
|
|
83
|
+
try:
|
|
84
|
+
st_tid = getattr(getattr(request, "state", object()), "tenant_id", None)
|
|
85
|
+
if st_tid:
|
|
86
|
+
return str(st_tid)
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def require_tenant_id(
|
|
94
|
+
tenant_id: Optional[str] = Depends(resolve_tenant_id),
|
|
95
|
+
) -> str:
|
|
96
|
+
if not tenant_id:
|
|
97
|
+
raise HTTPException(status_code=400, detail="tenant_context_missing")
|
|
98
|
+
return tenant_id
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# DX aliases
|
|
102
|
+
TenantId = Annotated[str, Depends(require_tenant_id)]
|
|
103
|
+
OptionalTenantId = Annotated[Optional[str], Depends(resolve_tenant_id)]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
__all__ = [
|
|
107
|
+
"TenantId",
|
|
108
|
+
"OptionalTenantId",
|
|
109
|
+
"resolve_tenant_id",
|
|
110
|
+
"require_tenant_id",
|
|
111
|
+
"set_tenant_resolver",
|
|
112
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities for capturing routers from add_* functions for versioned routing.
|
|
3
|
+
|
|
4
|
+
This module provides helpers to use integration functions (add_banking, add_payments, etc.)
|
|
5
|
+
under versioned routing without creating separate documentation cards.
|
|
6
|
+
|
|
7
|
+
See: svc-infra/docs/versioned-integrations.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Callable, TypeVar
|
|
13
|
+
from unittest.mock import patch
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, FastAPI
|
|
16
|
+
|
|
17
|
+
__all__ = ["extract_router"]
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def extract_router(
|
|
23
|
+
add_function: Callable[..., T],
|
|
24
|
+
*,
|
|
25
|
+
prefix: str,
|
|
26
|
+
**kwargs: Any,
|
|
27
|
+
) -> tuple[APIRouter, T]:
|
|
28
|
+
"""
|
|
29
|
+
Capture the router from an add_* function for versioned mounting.
|
|
30
|
+
|
|
31
|
+
This allows you to use integration functions like add_banking(), add_payments(),
|
|
32
|
+
etc. under versioned routing (e.g., /v0/banking) without creating separate
|
|
33
|
+
documentation cards.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
add_function: The add_* function to capture from (e.g., add_banking)
|
|
37
|
+
prefix: URL prefix for the routes (e.g., "/banking")
|
|
38
|
+
**kwargs: Arguments to pass to the add_function
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Tuple of (router, return_value) where:
|
|
42
|
+
- router: The captured APIRouter with all routes
|
|
43
|
+
- return_value: The original return value from add_function (e.g., provider instance)
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
```python
|
|
47
|
+
# In routers/v0/banking.py
|
|
48
|
+
from svc_infra.api.fastapi.versioned import extract_router
|
|
49
|
+
from fin_infra.banking import add_banking
|
|
50
|
+
|
|
51
|
+
router, banking_provider = extract_router(
|
|
52
|
+
add_banking,
|
|
53
|
+
prefix="/banking",
|
|
54
|
+
provider="plaid",
|
|
55
|
+
cache_ttl=60,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# svc-infra auto-discovers 'router' and mounts at /v0/banking
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Pattern:
|
|
62
|
+
1. Creates a mock FastAPI app
|
|
63
|
+
2. Intercepts include_router to capture the router
|
|
64
|
+
3. Patches add_prefixed_docs to prevent separate card creation
|
|
65
|
+
4. Calls the add_function which creates all routes
|
|
66
|
+
5. Returns the captured router for auto-discovery
|
|
67
|
+
|
|
68
|
+
See Also:
|
|
69
|
+
- docs/versioned-integrations.md: Full pattern documentation
|
|
70
|
+
- api/fastapi/dual/public.py: Similar pattern for dual routers
|
|
71
|
+
"""
|
|
72
|
+
# Create mock app to capture router
|
|
73
|
+
mock_app = FastAPI()
|
|
74
|
+
captured_router: APIRouter | None = None
|
|
75
|
+
|
|
76
|
+
def _capture_router(router: APIRouter, **_kwargs: Any) -> None:
|
|
77
|
+
"""Intercept include_router to capture instead of mount."""
|
|
78
|
+
nonlocal captured_router
|
|
79
|
+
captured_router = router
|
|
80
|
+
|
|
81
|
+
mock_app.include_router = _capture_router # type: ignore[assignment]
|
|
82
|
+
|
|
83
|
+
# Patch add_prefixed_docs to prevent separate card (no-op if function doesn't call it)
|
|
84
|
+
def _noop_docs(*args: Any, **kwargs: Any) -> None:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
# Call add_function with patches active
|
|
88
|
+
with patch("svc_infra.api.fastapi.docs.scoped.add_prefixed_docs", _noop_docs):
|
|
89
|
+
result = add_function(
|
|
90
|
+
mock_app,
|
|
91
|
+
prefix=prefix,
|
|
92
|
+
**kwargs,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if captured_router is None:
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
f"Failed to capture router from {add_function.__name__}. "
|
|
98
|
+
f"The function may not call app.include_router()."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return captured_router, result
|
svc_infra/app/README.md
CHANGED
|
@@ -14,9 +14,8 @@ This README shows:
|
|
|
14
14
|
|
|
15
15
|
```python
|
|
16
16
|
# main.py (or wherever your app starts)
|
|
17
|
-
from svc_infra.
|
|
17
|
+
from svc_infra.app.logging import setup_logging, LogLevelOptions
|
|
18
18
|
from svc_infra.app.env import pick
|
|
19
|
-
from svc_infra.logging.logging import LogLevelOptions
|
|
20
19
|
```
|
|
21
20
|
|
|
22
21
|
---
|
|
@@ -39,7 +38,8 @@ What you get by default:
|
|
|
39
38
|
Set via code:
|
|
40
39
|
|
|
41
40
|
```python
|
|
42
|
-
from svc_infra.logging.
|
|
41
|
+
from svc_infra.app.logging.formats import LogFormatOptions
|
|
42
|
+
from svc_infra.app.logging import LogLevelOptions
|
|
43
43
|
|
|
44
44
|
setup_logging(
|
|
45
45
|
level=LogLevelOptions.INFO, # or "INFO"
|
|
@@ -119,7 +119,7 @@ Old (pre-filter) example:
|
|
|
119
119
|
|
|
120
120
|
```python
|
|
121
121
|
from svc_infra.app.env import pick
|
|
122
|
-
from svc_infra.
|
|
122
|
+
from svc_infra.app.logging import setup_logging, LogLevelOptions
|
|
123
123
|
|
|
124
124
|
setup_logging(
|
|
125
125
|
level=pick(
|
|
@@ -183,7 +183,7 @@ LOG_DROP_PATHS=/metrics,/health,/healthz
|
|
|
183
183
|
## 7) One-liner quickstart
|
|
184
184
|
|
|
185
185
|
```python
|
|
186
|
-
from svc_infra.logging import setup_logging
|
|
186
|
+
from svc_infra.app.logging import setup_logging
|
|
187
187
|
setup_logging() # done: sensible defaults + filters in prod/test
|
|
188
188
|
```
|
|
189
189
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .models import (
|
|
2
|
+
Invoice,
|
|
3
|
+
InvoiceLine,
|
|
4
|
+
Plan,
|
|
5
|
+
PlanEntitlement,
|
|
6
|
+
Price,
|
|
7
|
+
Subscription,
|
|
8
|
+
UsageAggregate,
|
|
9
|
+
UsageEvent,
|
|
10
|
+
)
|
|
11
|
+
from .service import BillingService
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"UsageEvent",
|
|
15
|
+
"UsageAggregate",
|
|
16
|
+
"Plan",
|
|
17
|
+
"PlanEntitlement",
|
|
18
|
+
"Subscription",
|
|
19
|
+
"Price",
|
|
20
|
+
"Invoice",
|
|
21
|
+
"InvoiceLine",
|
|
22
|
+
"BillingService",
|
|
23
|
+
]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from typing import Optional, Sequence
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import select
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
10
|
+
from .models import Invoice, InvoiceLine, UsageAggregate, UsageEvent
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AsyncBillingService:
|
|
14
|
+
def __init__(self, session: AsyncSession, tenant_id: str):
|
|
15
|
+
self.session = session
|
|
16
|
+
self.tenant_id = tenant_id
|
|
17
|
+
|
|
18
|
+
async def record_usage(
|
|
19
|
+
self,
|
|
20
|
+
*,
|
|
21
|
+
metric: str,
|
|
22
|
+
amount: int,
|
|
23
|
+
at: datetime,
|
|
24
|
+
idempotency_key: str,
|
|
25
|
+
metadata: dict | None,
|
|
26
|
+
) -> str:
|
|
27
|
+
if at.tzinfo is None:
|
|
28
|
+
at = at.replace(tzinfo=timezone.utc)
|
|
29
|
+
evt = UsageEvent(
|
|
30
|
+
id=str(uuid.uuid4()),
|
|
31
|
+
tenant_id=self.tenant_id,
|
|
32
|
+
metric=metric,
|
|
33
|
+
amount=amount,
|
|
34
|
+
at_ts=at,
|
|
35
|
+
idempotency_key=idempotency_key,
|
|
36
|
+
metadata_json=metadata or {},
|
|
37
|
+
)
|
|
38
|
+
self.session.add(evt)
|
|
39
|
+
await self.session.flush()
|
|
40
|
+
return evt.id
|
|
41
|
+
|
|
42
|
+
async def aggregate_daily(self, *, metric: str, day_start: datetime) -> int:
|
|
43
|
+
day_start = day_start.replace(
|
|
44
|
+
hour=0, minute=0, second=0, microsecond=0, tzinfo=timezone.utc
|
|
45
|
+
)
|
|
46
|
+
next_day = day_start + timedelta(days=1)
|
|
47
|
+
total = 0
|
|
48
|
+
rows: Sequence[UsageEvent] = (
|
|
49
|
+
(
|
|
50
|
+
await self.session.execute(
|
|
51
|
+
select(UsageEvent).where(
|
|
52
|
+
UsageEvent.tenant_id == self.tenant_id,
|
|
53
|
+
UsageEvent.metric == metric,
|
|
54
|
+
UsageEvent.at_ts >= day_start,
|
|
55
|
+
UsageEvent.at_ts < next_day,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
.scalars()
|
|
60
|
+
.all()
|
|
61
|
+
)
|
|
62
|
+
for r in rows:
|
|
63
|
+
total += int(r.amount)
|
|
64
|
+
|
|
65
|
+
agg = (
|
|
66
|
+
await self.session.execute(
|
|
67
|
+
select(UsageAggregate).where(
|
|
68
|
+
UsageAggregate.tenant_id == self.tenant_id,
|
|
69
|
+
UsageAggregate.metric == metric,
|
|
70
|
+
UsageAggregate.period_start == day_start,
|
|
71
|
+
UsageAggregate.granularity == "day",
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
).scalar_one_or_none()
|
|
75
|
+
if agg:
|
|
76
|
+
agg.total = total
|
|
77
|
+
else:
|
|
78
|
+
self.session.add(
|
|
79
|
+
UsageAggregate(
|
|
80
|
+
id=str(uuid.uuid4()),
|
|
81
|
+
tenant_id=self.tenant_id,
|
|
82
|
+
metric=metric,
|
|
83
|
+
period_start=day_start,
|
|
84
|
+
granularity="day",
|
|
85
|
+
total=total,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
return total
|
|
89
|
+
|
|
90
|
+
async def list_daily_aggregates(
|
|
91
|
+
self, *, metric: str, date_from: Optional[datetime], date_to: Optional[datetime]
|
|
92
|
+
) -> list[UsageAggregate]:
|
|
93
|
+
q = select(UsageAggregate).where(
|
|
94
|
+
UsageAggregate.tenant_id == self.tenant_id,
|
|
95
|
+
UsageAggregate.metric == metric,
|
|
96
|
+
UsageAggregate.granularity == "day",
|
|
97
|
+
)
|
|
98
|
+
if date_from is not None:
|
|
99
|
+
q = q.where(UsageAggregate.period_start >= date_from)
|
|
100
|
+
if date_to is not None:
|
|
101
|
+
q = q.where(UsageAggregate.period_start < date_to)
|
|
102
|
+
rows: list[UsageAggregate] = (await self.session.execute(q)).scalars().all()
|
|
103
|
+
return rows
|
|
104
|
+
|
|
105
|
+
async def generate_monthly_invoice(
|
|
106
|
+
self, *, period_start: datetime, period_end: datetime, currency: str
|
|
107
|
+
) -> str:
|
|
108
|
+
total = 0
|
|
109
|
+
aggs: Sequence[UsageAggregate] = (
|
|
110
|
+
(
|
|
111
|
+
await self.session.execute(
|
|
112
|
+
select(UsageAggregate).where(
|
|
113
|
+
UsageAggregate.tenant_id == self.tenant_id,
|
|
114
|
+
UsageAggregate.period_start >= period_start,
|
|
115
|
+
UsageAggregate.period_start < period_end,
|
|
116
|
+
UsageAggregate.granularity == "day",
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
.scalars()
|
|
121
|
+
.all()
|
|
122
|
+
)
|
|
123
|
+
for r in aggs:
|
|
124
|
+
total += int(r.total)
|
|
125
|
+
|
|
126
|
+
inv = Invoice(
|
|
127
|
+
id=str(uuid.uuid4()),
|
|
128
|
+
tenant_id=self.tenant_id,
|
|
129
|
+
period_start=period_start,
|
|
130
|
+
period_end=period_end,
|
|
131
|
+
status="created",
|
|
132
|
+
total_amount=total,
|
|
133
|
+
currency=currency,
|
|
134
|
+
)
|
|
135
|
+
self.session.add(inv)
|
|
136
|
+
await self.session.flush()
|
|
137
|
+
|
|
138
|
+
line = InvoiceLine(
|
|
139
|
+
id=str(uuid.uuid4()),
|
|
140
|
+
invoice_id=inv.id,
|
|
141
|
+
price_id=None,
|
|
142
|
+
metric=None,
|
|
143
|
+
quantity=1,
|
|
144
|
+
amount=total,
|
|
145
|
+
)
|
|
146
|
+
self.session.add(line)
|
|
147
|
+
return inv.id
|