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/api/fastapi/setup.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
5
|
from collections import defaultdict
|
|
6
|
-
from
|
|
6
|
+
from collections.abc import Iterable, Sequence
|
|
7
7
|
|
|
8
8
|
from fastapi import FastAPI
|
|
9
9
|
from fastapi.middleware.cors import CORSMiddleware
|
|
@@ -85,9 +85,7 @@ def _setup_cors(app: FastAPI, public_cors_origins: list[str] | str | None = None
|
|
|
85
85
|
if isinstance(public_cors_origins, list):
|
|
86
86
|
param_origins = [o.strip() for o in public_cors_origins if o and o.strip()]
|
|
87
87
|
elif isinstance(public_cors_origins, str):
|
|
88
|
-
param_origins = [
|
|
89
|
-
o.strip() for o in public_cors_origins.split(",") if o and o.strip()
|
|
90
|
-
]
|
|
88
|
+
param_origins = [o.strip() for o in public_cors_origins.split(",") if o and o.strip()]
|
|
91
89
|
else:
|
|
92
90
|
param_origins = []
|
|
93
91
|
|
|
@@ -106,7 +104,7 @@ def _setup_cors(app: FastAPI, public_cors_origins: list[str] | str | None = None
|
|
|
106
104
|
if not origins:
|
|
107
105
|
return
|
|
108
106
|
|
|
109
|
-
cors_kwargs =
|
|
107
|
+
cors_kwargs = {"allow_credentials": True, "allow_methods": ["*"], "allow_headers": ["*"]}
|
|
110
108
|
|
|
111
109
|
# Check for "*" (allow all) first
|
|
112
110
|
if "*" in origins:
|
|
@@ -176,9 +174,7 @@ def _dump_or_none(model):
|
|
|
176
174
|
def _build_child_app(
|
|
177
175
|
service: ServiceInfo, spec: APIVersionSpec, skip_paths: list[str] | None = None
|
|
178
176
|
) -> FastAPI:
|
|
179
|
-
title = (
|
|
180
|
-
f"{service.name} • {spec.tag}" if getattr(spec, "tag", None) else service.name
|
|
181
|
-
)
|
|
177
|
+
title = f"{service.name} • {spec.tag}" if getattr(spec, "tag", None) else service.name
|
|
182
178
|
child = FastAPI(
|
|
183
179
|
title=title,
|
|
184
180
|
version=service.release,
|
|
@@ -192,9 +188,7 @@ def _build_child_app(
|
|
|
192
188
|
_setup_middlewares(child, skip_paths=skip_paths)
|
|
193
189
|
|
|
194
190
|
# ---- OpenAPI pipeline (DRY!) ----
|
|
195
|
-
include_api_key = (
|
|
196
|
-
bool(spec.include_api_key) if spec.include_api_key is not None else False
|
|
197
|
-
)
|
|
191
|
+
include_api_key = bool(spec.include_api_key) if spec.include_api_key is not None else False
|
|
198
192
|
tag_str = str(spec.tag).strip("/")
|
|
199
193
|
mount_path = f"/{tag_str}"
|
|
200
194
|
server_url = (
|
|
@@ -272,9 +266,7 @@ def _build_parent_app(
|
|
|
272
266
|
)
|
|
273
267
|
# app-provided root routers
|
|
274
268
|
for pkg in _coerce_list(root_routers):
|
|
275
|
-
register_all_routers(
|
|
276
|
-
parent, base_package=pkg, prefix="", environment=CURRENT_ENVIRONMENT
|
|
277
|
-
)
|
|
269
|
+
register_all_routers(parent, base_package=pkg, prefix="", environment=CURRENT_ENVIRONMENT)
|
|
278
270
|
|
|
279
271
|
return parent
|
|
280
272
|
|
|
@@ -294,8 +286,8 @@ class RouteLoggerMiddleware:
|
|
|
294
286
|
path = scope.get("path", "")
|
|
295
287
|
method = scope.get("method", "")
|
|
296
288
|
|
|
297
|
-
# Skip specified paths
|
|
298
|
-
if any(skip
|
|
289
|
+
# Skip specified paths using prefix matching
|
|
290
|
+
if any(path.startswith(skip) for skip in self.skip_paths):
|
|
299
291
|
await self.app(scope, receive, send)
|
|
300
292
|
return
|
|
301
293
|
|
|
@@ -303,15 +295,11 @@ class RouteLoggerMiddleware:
|
|
|
303
295
|
async def send_wrapper(message):
|
|
304
296
|
if message["type"] == "http.response.start":
|
|
305
297
|
route = scope.get("route")
|
|
306
|
-
route_path = getattr(route, "path_format", None) or getattr(
|
|
307
|
-
route, "path", None
|
|
308
|
-
)
|
|
298
|
+
route_path = getattr(route, "path_format", None) or getattr(route, "path", None)
|
|
309
299
|
if route_path:
|
|
310
300
|
root_path = scope.get("root_path", "") or ""
|
|
311
301
|
headers = list(message.get("headers", []))
|
|
312
|
-
headers.append(
|
|
313
|
-
(b"x-handled-by", f"{method} {root_path}{route_path}".encode())
|
|
314
|
-
)
|
|
302
|
+
headers.append((b"x-handled-by", f"{method} {root_path}{route_path}".encode()))
|
|
315
303
|
message = {**message, "headers": headers}
|
|
316
304
|
await send(message)
|
|
317
305
|
|
|
@@ -367,9 +355,7 @@ def setup_service_api(
|
|
|
367
355
|
cards.append(
|
|
368
356
|
CardSpec(
|
|
369
357
|
tag="",
|
|
370
|
-
docs=DocTargets(
|
|
371
|
-
swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"
|
|
372
|
-
),
|
|
358
|
+
docs=DocTargets(swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"),
|
|
373
359
|
)
|
|
374
360
|
)
|
|
375
361
|
|
|
@@ -393,15 +379,11 @@ def setup_service_api(
|
|
|
393
379
|
cards.append(
|
|
394
380
|
CardSpec(
|
|
395
381
|
tag=scope.strip("/"),
|
|
396
|
-
docs=DocTargets(
|
|
397
|
-
swagger=swagger, redoc=redoc, openapi_json=openapi_json
|
|
398
|
-
),
|
|
382
|
+
docs=DocTargets(swagger=swagger, redoc=redoc, openapi_json=openapi_json),
|
|
399
383
|
)
|
|
400
384
|
)
|
|
401
385
|
|
|
402
|
-
html = render_index_html(
|
|
403
|
-
service_name=service.name, release=service.release, cards=cards
|
|
404
|
-
)
|
|
386
|
+
html = render_index_html(service_name=service.name, release=service.release, cards=cards)
|
|
405
387
|
return HTMLResponse(html)
|
|
406
388
|
|
|
407
389
|
return parent
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
from fastapi import FastAPI
|
|
6
7
|
|
|
7
8
|
from .context import set_tenant_resolver
|
|
8
9
|
|
|
9
10
|
|
|
10
|
-
def add_tenancy(app: FastAPI, *, resolver:
|
|
11
|
+
def add_tenancy(app: FastAPI, *, resolver: Callable[..., Any] | None = None) -> None:
|
|
11
12
|
"""Wire tenancy resolver for the application.
|
|
12
13
|
|
|
13
14
|
Provide a resolver(request, identity, header) -> Optional[str] to override
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Annotated, Any
|
|
4
5
|
|
|
5
6
|
from fastapi import Depends, HTTPException, Request
|
|
6
7
|
|
|
@@ -10,11 +11,11 @@ except Exception: # pragma: no cover - fallback for minimal builds
|
|
|
10
11
|
OptionalIdentity = None # type: ignore[misc,assignment]
|
|
11
12
|
|
|
12
13
|
|
|
13
|
-
_tenant_resolver:
|
|
14
|
+
_tenant_resolver: Callable[..., Any] | None = None
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def set_tenant_resolver(
|
|
17
|
-
fn:
|
|
18
|
+
fn: Callable[..., Any] | None,
|
|
18
19
|
) -> None:
|
|
19
20
|
"""Set or clear a global override hook for tenant resolution.
|
|
20
21
|
|
|
@@ -33,9 +34,9 @@ async def _maybe_await(x):
|
|
|
33
34
|
|
|
34
35
|
async def resolve_tenant_id(
|
|
35
36
|
request: Request,
|
|
36
|
-
tenant_header:
|
|
37
|
+
tenant_header: str | None = None,
|
|
37
38
|
identity: Any = Depends(OptionalIdentity) if OptionalIdentity else None, # type: ignore[arg-type]
|
|
38
|
-
) ->
|
|
39
|
+
) -> str | None:
|
|
39
40
|
"""Resolve tenant id from override, identity, header, or request.state.
|
|
40
41
|
|
|
41
42
|
Order:
|
|
@@ -91,7 +92,7 @@ async def resolve_tenant_id(
|
|
|
91
92
|
|
|
92
93
|
|
|
93
94
|
async def require_tenant_id(
|
|
94
|
-
tenant_id:
|
|
95
|
+
tenant_id: str | None = Depends(resolve_tenant_id),
|
|
95
96
|
) -> str:
|
|
96
97
|
if not tenant_id:
|
|
97
98
|
raise HTTPException(status_code=400, detail="tenant_context_missing")
|
|
@@ -100,7 +101,7 @@ async def require_tenant_id(
|
|
|
100
101
|
|
|
101
102
|
# DX aliases
|
|
102
103
|
TenantId = Annotated[str, Depends(require_tenant_id)]
|
|
103
|
-
OptionalTenantId = Annotated[
|
|
104
|
+
OptionalTenantId = Annotated[str | None, Depends(resolve_tenant_id)]
|
|
104
105
|
|
|
105
106
|
|
|
106
107
|
__all__ = [
|
|
@@ -9,7 +9,8 @@ See: svc-infra/docs/versioned-integrations.md
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
-
from
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any, TypeVar
|
|
13
14
|
from unittest.mock import patch
|
|
14
15
|
|
|
15
16
|
from fastapi import APIRouter, FastAPI
|
|
@@ -78,7 +79,7 @@ def extract_router(
|
|
|
78
79
|
nonlocal captured_router
|
|
79
80
|
captured_router = router
|
|
80
81
|
|
|
81
|
-
|
|
82
|
+
mock_app.include_router = _capture_router # type: ignore[method-assign]
|
|
82
83
|
|
|
83
84
|
# Patch add_prefixed_docs to prevent separate card (no-op if function doesn't call it)
|
|
84
85
|
def _noop_docs(*args: Any, **kwargs: Any) -> None:
|
svc_infra/app/env.py
CHANGED
|
@@ -5,7 +5,7 @@ import warnings
|
|
|
5
5
|
from enum import StrEnum
|
|
6
6
|
from functools import cache
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import
|
|
8
|
+
from typing import NamedTuple
|
|
9
9
|
|
|
10
10
|
from dotenv import load_dotenv
|
|
11
11
|
|
|
@@ -39,7 +39,7 @@ SYNONYMS: dict[str, Environment] = {
|
|
|
39
39
|
"production": PROD_ENV,
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
ALL_ENVIRONMENTS =
|
|
42
|
+
ALL_ENVIRONMENTS = set(Environment)
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
def _normalize(raw: str | None) -> Environment | None:
|
|
@@ -129,12 +129,10 @@ def pick(*, prod, nonprod=None, dev=None, test=None, local=None):
|
|
|
129
129
|
return local
|
|
130
130
|
if nonprod is not None:
|
|
131
131
|
return nonprod
|
|
132
|
-
raise ValueError(
|
|
133
|
-
"pick(): No value found for environment and 'nonprod' was not provided."
|
|
134
|
-
)
|
|
132
|
+
raise ValueError("pick(): No value found for environment and 'nonprod' was not provided.")
|
|
135
133
|
|
|
136
134
|
|
|
137
|
-
def find_env_file(start:
|
|
135
|
+
def find_env_file(start: Path | None = None) -> Path | None:
|
|
138
136
|
env_file = os.getenv("APP_ENV_FILE") or os.getenv("SVC_INFRA_ENV_FILE")
|
|
139
137
|
if env_file:
|
|
140
138
|
p = Path(env_file).expanduser()
|
|
@@ -148,7 +146,7 @@ def find_env_file(start: Optional[Path] = None) -> Optional[Path]:
|
|
|
148
146
|
return None
|
|
149
147
|
|
|
150
148
|
|
|
151
|
-
def load_env_if_present(path:
|
|
149
|
+
def load_env_if_present(path: Path | None, *, override: bool = False) -> list[str]:
|
|
152
150
|
if not path:
|
|
153
151
|
return []
|
|
154
152
|
before = dict(os.environ)
|
svc_infra/app/logging/add.py
CHANGED
|
@@ -2,8 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
|
+
from collections.abc import Sequence
|
|
5
6
|
from logging.config import dictConfig
|
|
6
|
-
from typing import TYPE_CHECKING,
|
|
7
|
+
from typing import TYPE_CHECKING, cast
|
|
7
8
|
|
|
8
9
|
from svc_infra.app.env import CURRENT_ENVIRONMENT
|
|
9
10
|
|
svc_infra/app/logging/filter.py
CHANGED
svc_infra/app/logging/formats.py
CHANGED
|
@@ -2,8 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
|
+
from collections.abc import Sequence
|
|
5
6
|
from enum import StrEnum
|
|
6
|
-
from typing import
|
|
7
|
+
from typing import cast
|
|
7
8
|
|
|
8
9
|
from pydantic import BaseModel
|
|
9
10
|
|
|
@@ -108,7 +109,7 @@ def _read_level() -> str:
|
|
|
108
109
|
from svc_infra.app.env import pick
|
|
109
110
|
|
|
110
111
|
return cast(
|
|
111
|
-
str,
|
|
112
|
+
"str",
|
|
112
113
|
pick(prod="INFO", nonprod="DEBUG", dev="DEBUG", test="DEBUG", local="DEBUG"),
|
|
113
114
|
).upper()
|
|
114
115
|
|
svc_infra/app/root.py
CHANGED
|
@@ -2,8 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import subprocess
|
|
5
|
+
from collections.abc import Iterable
|
|
5
6
|
from pathlib import Path
|
|
6
|
-
from typing import Iterable, Optional
|
|
7
7
|
|
|
8
8
|
DEFAULT_SENTRIES: tuple[str, ...] = (
|
|
9
9
|
".git",
|
|
@@ -36,7 +36,7 @@ def _is_root_marker(dir_: Path, sentries: Iterable[str]) -> bool:
|
|
|
36
36
|
return any((dir_ / name).exists() for name in sentries)
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
def _git_toplevel(start: Path) ->
|
|
39
|
+
def _git_toplevel(start: Path) -> Path | None:
|
|
40
40
|
try:
|
|
41
41
|
out = subprocess.check_output(
|
|
42
42
|
["git", "rev-parse", "--show-toplevel"],
|
|
@@ -50,7 +50,7 @@ def _git_toplevel(start: Path) -> Optional[Path]:
|
|
|
50
50
|
|
|
51
51
|
|
|
52
52
|
def resolve_project_root(
|
|
53
|
-
start:
|
|
53
|
+
start: Path | None = None,
|
|
54
54
|
*,
|
|
55
55
|
env_var: str = ENV_VAR,
|
|
56
56
|
extra_sentries: Iterable[str] = (),
|
svc_infra/billing/__init__.py
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
"""Billing module for usage tracking, metering, and invoicing.
|
|
2
|
+
|
|
3
|
+
Primary API:
|
|
4
|
+
AsyncBillingService - Async billing service
|
|
5
|
+
|
|
6
|
+
Models:
|
|
7
|
+
UsageEvent, UsageAggregate, Invoice, InvoiceLine, Plan, Subscription, etc.
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
from svc_infra.billing import AsyncBillingService
|
|
11
|
+
|
|
12
|
+
service = AsyncBillingService(async_session, tenant_id)
|
|
13
|
+
await service.record_usage(metric="api_calls", amount=1, ...)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .async_service import AsyncBillingService
|
|
1
17
|
from .models import (
|
|
2
18
|
Invoice,
|
|
3
19
|
InvoiceLine,
|
|
@@ -8,9 +24,11 @@ from .models import (
|
|
|
8
24
|
UsageAggregate,
|
|
9
25
|
UsageEvent,
|
|
10
26
|
)
|
|
11
|
-
from .service import BillingService
|
|
12
27
|
|
|
13
28
|
__all__ = [
|
|
29
|
+
# Primary API
|
|
30
|
+
"AsyncBillingService",
|
|
31
|
+
# Models
|
|
14
32
|
"UsageEvent",
|
|
15
33
|
"UsageAggregate",
|
|
16
34
|
"Plan",
|
|
@@ -19,5 +37,4 @@ __all__ = [
|
|
|
19
37
|
"Price",
|
|
20
38
|
"Invoice",
|
|
21
39
|
"InvoiceLine",
|
|
22
|
-
"BillingService",
|
|
23
40
|
]
|
|
@@ -1,8 +1,30 @@
|
|
|
1
|
+
"""Async Billing Service - Primary billing API.
|
|
2
|
+
|
|
3
|
+
This is the recommended billing service for all new code. It provides
|
|
4
|
+
full async/await support for usage tracking, aggregation, and invoicing.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from svc_infra.billing import AsyncBillingService
|
|
8
|
+
|
|
9
|
+
async with async_session_maker() as session:
|
|
10
|
+
service = AsyncBillingService(session, tenant_id="tenant_123")
|
|
11
|
+
await service.record_usage(
|
|
12
|
+
metric="api_calls",
|
|
13
|
+
amount=1,
|
|
14
|
+
at=datetime.now(timezone.utc),
|
|
15
|
+
idempotency_key="unique-key",
|
|
16
|
+
metadata={"endpoint": "/api/v1/users"},
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
See also:
|
|
20
|
+
- models: Invoice, UsageEvent, UsageAggregate, etc.
|
|
21
|
+
"""
|
|
22
|
+
|
|
1
23
|
from __future__ import annotations
|
|
2
24
|
|
|
3
25
|
import uuid
|
|
4
|
-
from
|
|
5
|
-
from
|
|
26
|
+
from collections.abc import Sequence
|
|
27
|
+
from datetime import UTC, datetime, timedelta
|
|
6
28
|
|
|
7
29
|
from sqlalchemy import select
|
|
8
30
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
@@ -25,7 +47,7 @@ class AsyncBillingService:
|
|
|
25
47
|
metadata: dict | None,
|
|
26
48
|
) -> str:
|
|
27
49
|
if at.tzinfo is None:
|
|
28
|
-
at = at.replace(tzinfo=
|
|
50
|
+
at = at.replace(tzinfo=UTC)
|
|
29
51
|
evt = UsageEvent(
|
|
30
52
|
id=str(uuid.uuid4()),
|
|
31
53
|
tenant_id=self.tenant_id,
|
|
@@ -40,9 +62,7 @@ class AsyncBillingService:
|
|
|
40
62
|
return evt.id
|
|
41
63
|
|
|
42
64
|
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
|
-
)
|
|
65
|
+
day_start = day_start.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=UTC)
|
|
46
66
|
next_day = day_start + timedelta(days=1)
|
|
47
67
|
total = 0
|
|
48
68
|
rows: Sequence[UsageEvent] = (
|
|
@@ -88,7 +108,7 @@ class AsyncBillingService:
|
|
|
88
108
|
return total
|
|
89
109
|
|
|
90
110
|
async def list_daily_aggregates(
|
|
91
|
-
self, *, metric: str, date_from:
|
|
111
|
+
self, *, metric: str, date_from: datetime | None, date_to: datetime | None
|
|
92
112
|
) -> list[UsageAggregate]:
|
|
93
113
|
q = select(UsageAggregate).where(
|
|
94
114
|
UsageAggregate.tenant_id == self.tenant_id,
|
svc_infra/billing/jobs.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import inspect
|
|
4
|
-
from
|
|
5
|
-
from
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any
|
|
6
7
|
|
|
7
8
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
8
9
|
|
|
@@ -23,7 +24,7 @@ async def job_aggregate_daily(
|
|
|
23
24
|
"""
|
|
24
25
|
svc = AsyncBillingService(session=session, tenant_id=tenant_id)
|
|
25
26
|
if day_start.tzinfo is None:
|
|
26
|
-
day_start = day_start.replace(tzinfo=
|
|
27
|
+
day_start = day_start.replace(tzinfo=UTC)
|
|
27
28
|
await svc.aggregate_daily(metric=metric, day_start=day_start)
|
|
28
29
|
|
|
29
30
|
|
|
@@ -41,9 +42,9 @@ async def job_generate_monthly_invoice(
|
|
|
41
42
|
"""
|
|
42
43
|
svc = AsyncBillingService(session=session, tenant_id=tenant_id)
|
|
43
44
|
if period_start.tzinfo is None:
|
|
44
|
-
period_start = period_start.replace(tzinfo=
|
|
45
|
+
period_start = period_start.replace(tzinfo=UTC)
|
|
45
46
|
if period_end.tzinfo is None:
|
|
46
|
-
period_end = period_end.replace(tzinfo=
|
|
47
|
+
period_end = period_end.replace(tzinfo=UTC)
|
|
47
48
|
return await svc.generate_monthly_invoice(
|
|
48
49
|
period_start=period_start, period_end=period_end, currency=currency
|
|
49
50
|
)
|
|
@@ -66,7 +67,7 @@ def enqueue_aggregate_daily(
|
|
|
66
67
|
payload = {
|
|
67
68
|
"tenant_id": tenant_id,
|
|
68
69
|
"metric": metric,
|
|
69
|
-
"day_start": day_start.astimezone(
|
|
70
|
+
"day_start": day_start.astimezone(UTC).isoformat(),
|
|
70
71
|
}
|
|
71
72
|
queue.enqueue(BILLING_AGGREGATE_JOB, payload, delay_seconds=delay_seconds)
|
|
72
73
|
|
|
@@ -82,8 +83,8 @@ def enqueue_generate_monthly_invoice(
|
|
|
82
83
|
) -> None:
|
|
83
84
|
payload = {
|
|
84
85
|
"tenant_id": tenant_id,
|
|
85
|
-
"period_start": period_start.astimezone(
|
|
86
|
-
"period_end": period_end.astimezone(
|
|
86
|
+
"period_start": period_start.astimezone(UTC).isoformat(),
|
|
87
|
+
"period_end": period_end.astimezone(UTC).isoformat(),
|
|
87
88
|
"currency": currency,
|
|
88
89
|
}
|
|
89
90
|
queue.enqueue(BILLING_INVOICE_JOB, payload, delay_seconds=delay_seconds)
|
|
@@ -94,7 +95,7 @@ def make_daily_aggregate_tick(
|
|
|
94
95
|
*,
|
|
95
96
|
tenant_id: str,
|
|
96
97
|
metric: str,
|
|
97
|
-
when:
|
|
98
|
+
when: datetime | None = None,
|
|
98
99
|
):
|
|
99
100
|
"""Return an async function that enqueues a daily aggregate job.
|
|
100
101
|
|
|
@@ -103,18 +104,16 @@ def make_daily_aggregate_tick(
|
|
|
103
104
|
"""
|
|
104
105
|
|
|
105
106
|
async def _tick():
|
|
106
|
-
ts = (when or datetime.now(
|
|
107
|
+
ts = (when or datetime.now(UTC)).astimezone(UTC)
|
|
107
108
|
day_start = ts.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
108
|
-
enqueue_aggregate_daily(
|
|
109
|
-
queue, tenant_id=tenant_id, metric=metric, day_start=day_start
|
|
110
|
-
)
|
|
109
|
+
enqueue_aggregate_daily(queue, tenant_id=tenant_id, metric=metric, day_start=day_start)
|
|
111
110
|
|
|
112
111
|
return _tick
|
|
113
112
|
|
|
114
113
|
|
|
115
114
|
def make_billing_job_handler(
|
|
116
115
|
*,
|
|
117
|
-
session_factory:
|
|
116
|
+
session_factory: async_sessionmaker[AsyncSession],
|
|
118
117
|
webhooks: WebhookService,
|
|
119
118
|
) -> Callable[[Job], Awaitable[None]]:
|
|
120
119
|
"""Create a worker handler that processes billing jobs and emits webhooks.
|
|
@@ -139,7 +138,7 @@ def make_billing_job_handler(
|
|
|
139
138
|
|
|
140
139
|
async def _handler(job: Job) -> None:
|
|
141
140
|
name = job.name
|
|
142
|
-
data:
|
|
141
|
+
data: dict[str, Any] = job.payload or {}
|
|
143
142
|
if name == BILLING_AGGREGATE_JOB:
|
|
144
143
|
tenant_id = str(data.get("tenant_id"))
|
|
145
144
|
metric = str(data.get("metric"))
|
|
@@ -156,7 +155,7 @@ def make_billing_job_handler(
|
|
|
156
155
|
{
|
|
157
156
|
"tenant_id": tenant_id,
|
|
158
157
|
"metric": metric,
|
|
159
|
-
"day_start": day_start.astimezone(
|
|
158
|
+
"day_start": day_start.astimezone(UTC).isoformat(),
|
|
160
159
|
"total": int(total),
|
|
161
160
|
},
|
|
162
161
|
)
|
|
@@ -166,12 +165,7 @@ def make_billing_job_handler(
|
|
|
166
165
|
period_start_raw = data.get("period_start")
|
|
167
166
|
period_end_raw = data.get("period_end")
|
|
168
167
|
currency = str(data.get("currency"))
|
|
169
|
-
if
|
|
170
|
-
not tenant_id
|
|
171
|
-
or not period_start_raw
|
|
172
|
-
or not period_end_raw
|
|
173
|
-
or not currency
|
|
174
|
-
):
|
|
168
|
+
if not tenant_id or not period_start_raw or not period_end_raw or not currency:
|
|
175
169
|
return
|
|
176
170
|
period_start = datetime.fromisoformat(str(period_start_raw))
|
|
177
171
|
period_end = datetime.fromisoformat(str(period_end_raw))
|
|
@@ -186,8 +180,8 @@ def make_billing_job_handler(
|
|
|
186
180
|
{
|
|
187
181
|
"tenant_id": tenant_id,
|
|
188
182
|
"invoice_id": invoice_id,
|
|
189
|
-
"period_start": period_start.astimezone(
|
|
190
|
-
"period_end": period_end.astimezone(
|
|
183
|
+
"period_start": period_start.astimezone(UTC).isoformat(),
|
|
184
|
+
"period_end": period_end.astimezone(UTC).isoformat(),
|
|
191
185
|
"currency": currency,
|
|
192
186
|
},
|
|
193
187
|
)
|
|
@@ -218,20 +212,16 @@ def add_billing_jobs(
|
|
|
218
212
|
|
|
219
213
|
async def _tick_fn(tid=tenant_id, m=metric):
|
|
220
214
|
# Enqueue for the current UTC day
|
|
221
|
-
now = datetime.now(
|
|
215
|
+
now = datetime.now(UTC)
|
|
222
216
|
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
223
|
-
enqueue_aggregate_daily(
|
|
224
|
-
queue, tenant_id=tid, metric=m, day_start=day_start
|
|
225
|
-
)
|
|
217
|
+
enqueue_aggregate_daily(queue, tenant_id=tid, metric=m, day_start=day_start)
|
|
226
218
|
|
|
227
|
-
scheduler.add_task(
|
|
228
|
-
f"billing.aggregate.{tenant_id}.{metric}", interval, _tick_fn
|
|
229
|
-
)
|
|
219
|
+
scheduler.add_task(f"billing.aggregate.{tenant_id}.{metric}", interval, _tick_fn)
|
|
230
220
|
elif name == "invoice":
|
|
231
221
|
tenant_id = j["tenant_id"]
|
|
232
222
|
currency = j["currency"]
|
|
233
|
-
pstart = datetime.fromisoformat(j["period_start"]).astimezone(
|
|
234
|
-
pend = datetime.fromisoformat(j["period_end"]).astimezone(
|
|
223
|
+
pstart = datetime.fromisoformat(j["period_start"]).astimezone(UTC)
|
|
224
|
+
pend = datetime.fromisoformat(j["period_end"]).astimezone(UTC)
|
|
235
225
|
|
|
236
226
|
async def _tick_inv(tid=tenant_id, cs=currency, ps=pstart, pe=pend):
|
|
237
227
|
enqueue_generate_monthly_invoice(
|