svc-infra 0.1.595__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/__init__.py +58 -2
- svc_infra/apf_payments/models.py +68 -38
- svc_infra/apf_payments/provider/__init__.py +2 -2
- svc_infra/apf_payments/provider/aiydan.py +39 -23
- svc_infra/apf_payments/provider/base.py +8 -3
- svc_infra/apf_payments/provider/registry.py +3 -5
- svc_infra/apf_payments/provider/stripe.py +74 -52
- svc_infra/apf_payments/schemas.py +84 -83
- svc_infra/apf_payments/service.py +27 -16
- svc_infra/apf_payments/settings.py +12 -11
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +34 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +240 -0
- svc_infra/api/fastapi/apf_payments/router.py +94 -73
- svc_infra/api/fastapi/apf_payments/setup.py +10 -9
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +1 -3
- svc_infra/api/fastapi/auth/add.py +14 -15
- svc_infra/api/fastapi/auth/gaurd.py +32 -20
- svc_infra/api/fastapi/auth/mfa/models.py +3 -4
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
- svc_infra/api/fastapi/auth/mfa/router.py +9 -8
- svc_infra/api/fastapi/auth/mfa/security.py +4 -7
- svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -3
- svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
- svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
- svc_infra/api/fastapi/auth/security.py +25 -15
- svc_infra/api/fastapi/auth/sender.py +5 -0
- svc_infra/api/fastapi/auth/settings.py +18 -19
- svc_infra/api/fastapi/auth/state.py +5 -4
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +71 -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 +10 -9
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +62 -25
- svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
- svc_infra/api/fastapi/db/sql/session.py +19 -2
- svc_infra/api/fastapi/db/sql/users.py +18 -9
- svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
- svc_infra/api/fastapi/docs/add.py +163 -0
- svc_infra/api/fastapi/docs/landing.py +6 -6
- svc_infra/api/fastapi/docs/scoped.py +75 -36
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +2 -2
- svc_infra/api/fastapi/dual/protected.py +123 -10
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +18 -8
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +59 -7
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +2 -2
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
- svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
- svc_infra/api/fastapi/middleware/idempotency.py +190 -68
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
- svc_infra/api/fastapi/middleware/request_id.py +24 -10
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +176 -0
- svc_infra/api/fastapi/object_router.py +1060 -0
- svc_infra/api/fastapi/openapi/apply.py +4 -3
- svc_infra/api/fastapi/openapi/conventions.py +13 -6
- svc_infra/api/fastapi/openapi/mutators.py +144 -17
- 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 -1
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +47 -32
- svc_infra/api/fastapi/routers/__init__.py +16 -10
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +167 -54
- svc_infra/api/fastapi/tenancy/add.py +20 -0
- svc_infra/api/fastapi/tenancy/context.py +113 -0
- svc_infra/api/fastapi/versioned.py +102 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +70 -4
- svc_infra/app/logging/add.py +10 -2
- svc_infra/app/logging/filter.py +1 -1
- svc_infra/app/logging/formats.py +13 -5
- svc_infra/app/root.py +3 -3
- svc_infra/billing/__init__.py +40 -0
- svc_infra/billing/async_service.py +167 -0
- svc_infra/billing/jobs.py +231 -0
- svc_infra/billing/models.py +146 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +34 -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 +21 -5
- svc_infra/cache/add.py +167 -0
- svc_infra/cache/backend.py +9 -7
- svc_infra/cache/decorators.py +75 -20
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +26 -6
- svc_infra/cache/recache.py +26 -27
- svc_infra/cache/resources.py +6 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/ttl.py +2 -3
- svc_infra/cache/utils.py +4 -3
- svc_infra/cli/__init__.py +44 -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 +18 -14
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
- svc_infra/cli/cmds/db/ops_cmds.py +267 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
- svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +110 -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 +42 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/cli/foundation/runner.py +4 -5
- svc_infra/cli/foundation/typer_bootstrap.py +1 -2
- svc_infra/data/__init__.py +83 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +56 -0
- svc_infra/data/erasure.py +46 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +56 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +14 -13
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +2 -0
- svc_infra/db/nosql/constants.py +1 -1
- svc_infra/db/nosql/core.py +19 -5
- svc_infra/db/nosql/indexes.py +12 -9
- svc_infra/db/nosql/management.py +4 -4
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +21 -4
- svc_infra/db/nosql/mongo/settings.py +1 -1
- svc_infra/db/nosql/repository.py +46 -27
- svc_infra/db/nosql/resource.py +28 -16
- svc_infra/db/nosql/scaffold.py +14 -12
- svc_infra/db/nosql/service.py +2 -1
- svc_infra/db/nosql/service_with_hooks.py +4 -3
- svc_infra/db/nosql/utils.py +4 -4
- svc_infra/db/ops.py +380 -0
- svc_infra/db/outbox.py +105 -0
- svc_infra/db/sql/apikey.py +34 -15
- svc_infra/db/sql/authref.py +8 -6
- svc_infra/db/sql/constants.py +5 -1
- svc_infra/db/sql/core.py +13 -13
- svc_infra/db/sql/management.py +5 -6
- svc_infra/db/sql/repository.py +92 -26
- svc_infra/db/sql/resource.py +18 -12
- svc_infra/db/sql/scaffold.py +11 -11
- svc_infra/db/sql/service.py +2 -1
- svc_infra/db/sql/service_with_hooks.py +4 -3
- 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 +80 -0
- svc_infra/db/sql/uniq.py +8 -7
- svc_infra/db/sql/uniq_hooks.py +12 -11
- svc_infra/db/sql/utils.py +105 -47
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/db/utils.py +3 -3
- svc_infra/deploy/__init__.py +531 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +263 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +262 -0
- svc_infra/dx/__init__.py +58 -0
- svc_infra/dx/add.py +63 -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 +863 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +101 -0
- svc_infra/jobs/__init__.py +79 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +93 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +49 -0
- svc_infra/jobs/queue.py +106 -0
- svc_infra/jobs/redis_queue.py +242 -0
- svc_infra/jobs/runner.py +75 -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 +143 -0
- svc_infra/loaders/github.py +309 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +229 -0
- svc_infra/logging/__init__.py +375 -0
- svc_infra/mcp/__init__.py +82 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +68 -11
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +6 -7
- svc_infra/obs/metrics/asgi.py +8 -7
- svc_infra/obs/metrics/base.py +13 -13
- svc_infra/obs/metrics/http.py +3 -3
- svc_infra/obs/metrics/sqlalchemy.py +14 -13
- svc_infra/obs/metrics.py +9 -8
- 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 +213 -0
- svc_infra/security/audit.py +97 -18
- svc_infra/security/audit_service.py +10 -9
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -7
- svc_infra/security/jwt_rotation.py +78 -29
- svc_infra/security/lockout.py +23 -16
- svc_infra/security/models.py +77 -44
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +12 -12
- svc_infra/security/passwords.py +3 -3
- svc_infra/security/permissions.py +31 -7
- svc_infra/security/session.py +7 -8
- svc_infra/security/signed_cookies.py +26 -6
- 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 +213 -0
- svc_infra/storage/backends/s3.py +334 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +181 -0
- svc_infra/storage/settings.py +193 -0
- svc_infra/testing/__init__.py +682 -0
- svc_infra/utils.py +170 -5
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +327 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +69 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +139 -0
- svc_infra/websocket/client.py +283 -0
- svc_infra/websocket/config.py +57 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +343 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-1.1.0.dist-info/LICENSE +21 -0
- svc_infra-1.1.0.dist-info/METADATA +362 -0
- svc_infra-1.1.0.dist-info/RECORD +364 -0
- svc_infra-0.1.595.dist-info/METADATA +0 -80
- svc_infra-0.1.595.dist-info/RECORD +0 -253
- {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Annotated, Any
|
|
5
|
+
|
|
6
|
+
from fastapi import Depends, HTTPException, Request
|
|
7
|
+
|
|
8
|
+
try: # optional import; auth may not be used by all consumers
|
|
9
|
+
from svc_infra.api.fastapi.auth.security import OptionalIdentity
|
|
10
|
+
except Exception: # pragma: no cover - fallback for minimal builds
|
|
11
|
+
OptionalIdentity = None # type: ignore[misc,assignment]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_tenant_resolver: Callable[..., Any] | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def set_tenant_resolver(
|
|
18
|
+
fn: Callable[..., Any] | None,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Set or clear a global override hook for tenant resolution.
|
|
21
|
+
|
|
22
|
+
The function receives (request, identity, tenant_header) and should return a tenant id
|
|
23
|
+
string or None to fall back to default logic.
|
|
24
|
+
"""
|
|
25
|
+
global _tenant_resolver
|
|
26
|
+
_tenant_resolver = fn
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def _maybe_await(x):
|
|
30
|
+
if callable(getattr(x, "__await__", None)):
|
|
31
|
+
return await x
|
|
32
|
+
return x
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def resolve_tenant_id(
|
|
36
|
+
request: Request,
|
|
37
|
+
tenant_header: str | None = None,
|
|
38
|
+
identity: Any = Depends(OptionalIdentity) if OptionalIdentity else None, # type: ignore[arg-type]
|
|
39
|
+
) -> str | None:
|
|
40
|
+
"""Resolve tenant id from override, identity, header, or request.state.
|
|
41
|
+
|
|
42
|
+
Order:
|
|
43
|
+
1) Global override hook (set_tenant_resolver)
|
|
44
|
+
2) Auth identity: user.tenant_id then api_key.tenant_id (if available)
|
|
45
|
+
3) X-Tenant-Id header
|
|
46
|
+
4) request.state.tenant_id
|
|
47
|
+
"""
|
|
48
|
+
# read header value if not provided directly (supports direct calls without DI)
|
|
49
|
+
if tenant_header is None:
|
|
50
|
+
try:
|
|
51
|
+
tenant_header = request.headers.get("X-Tenant-Id")
|
|
52
|
+
except Exception:
|
|
53
|
+
tenant_header = None
|
|
54
|
+
|
|
55
|
+
# 1) global override
|
|
56
|
+
if _tenant_resolver is not None:
|
|
57
|
+
try:
|
|
58
|
+
v = _tenant_resolver(request, identity, tenant_header)
|
|
59
|
+
v2 = await _maybe_await(v)
|
|
60
|
+
if v2:
|
|
61
|
+
return str(v2)
|
|
62
|
+
except Exception:
|
|
63
|
+
# fall through to defaults
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
# 2) from identity
|
|
67
|
+
try:
|
|
68
|
+
if identity and getattr(identity, "user", None) is not None:
|
|
69
|
+
tid = getattr(identity.user, "tenant_id", None)
|
|
70
|
+
if tid:
|
|
71
|
+
return str(tid)
|
|
72
|
+
if identity and getattr(identity, "api_key", None) is not None:
|
|
73
|
+
tid = getattr(identity.api_key, "tenant_id", None)
|
|
74
|
+
if tid:
|
|
75
|
+
return str(tid)
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
# 3) from header
|
|
80
|
+
if tenant_header and isinstance(tenant_header, str) and tenant_header.strip():
|
|
81
|
+
return tenant_header.strip()
|
|
82
|
+
|
|
83
|
+
# 4) request.state
|
|
84
|
+
try:
|
|
85
|
+
st_tid = getattr(getattr(request, "state", object()), "tenant_id", None)
|
|
86
|
+
if st_tid:
|
|
87
|
+
return str(st_tid)
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def require_tenant_id(
|
|
95
|
+
tenant_id: str | None = Depends(resolve_tenant_id),
|
|
96
|
+
) -> str:
|
|
97
|
+
if not tenant_id:
|
|
98
|
+
raise HTTPException(status_code=400, detail="tenant_context_missing")
|
|
99
|
+
return tenant_id
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# DX aliases
|
|
103
|
+
TenantId = Annotated[str, Depends(require_tenant_id)]
|
|
104
|
+
OptionalTenantId = Annotated[str | None, Depends(resolve_tenant_id)]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = [
|
|
108
|
+
"TenantId",
|
|
109
|
+
"OptionalTenantId",
|
|
110
|
+
"resolve_tenant_id",
|
|
111
|
+
"require_tenant_id",
|
|
112
|
+
"set_tenant_resolver",
|
|
113
|
+
]
|
|
@@ -0,0 +1,102 @@
|
|
|
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 collections.abc import Callable
|
|
13
|
+
from typing import Any, TypeVar
|
|
14
|
+
from unittest.mock import patch
|
|
15
|
+
|
|
16
|
+
from fastapi import APIRouter, FastAPI
|
|
17
|
+
|
|
18
|
+
__all__ = ["extract_router"]
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_router(
|
|
24
|
+
add_function: Callable[..., T],
|
|
25
|
+
*,
|
|
26
|
+
prefix: str,
|
|
27
|
+
**kwargs: Any,
|
|
28
|
+
) -> tuple[APIRouter, T]:
|
|
29
|
+
"""
|
|
30
|
+
Capture the router from an add_* function for versioned mounting.
|
|
31
|
+
|
|
32
|
+
This allows you to use integration functions like add_banking(), add_payments(),
|
|
33
|
+
etc. under versioned routing (e.g., /v0/banking) without creating separate
|
|
34
|
+
documentation cards.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
add_function: The add_* function to capture from (e.g., add_banking)
|
|
38
|
+
prefix: URL prefix for the routes (e.g., "/banking")
|
|
39
|
+
**kwargs: Arguments to pass to the add_function
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Tuple of (router, return_value) where:
|
|
43
|
+
- router: The captured APIRouter with all routes
|
|
44
|
+
- return_value: The original return value from add_function (e.g., provider instance)
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
```python
|
|
48
|
+
# In routers/v0/banking.py
|
|
49
|
+
from svc_infra.api.fastapi.versioned import extract_router
|
|
50
|
+
from fin_infra.banking import add_banking
|
|
51
|
+
|
|
52
|
+
router, banking_provider = extract_router(
|
|
53
|
+
add_banking,
|
|
54
|
+
prefix="/banking",
|
|
55
|
+
provider="plaid",
|
|
56
|
+
cache_ttl=60,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# svc-infra auto-discovers 'router' and mounts at /v0/banking
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Pattern:
|
|
63
|
+
1. Creates a mock FastAPI app
|
|
64
|
+
2. Intercepts include_router to capture the router
|
|
65
|
+
3. Patches add_prefixed_docs to prevent separate card creation
|
|
66
|
+
4. Calls the add_function which creates all routes
|
|
67
|
+
5. Returns the captured router for auto-discovery
|
|
68
|
+
|
|
69
|
+
See Also:
|
|
70
|
+
- docs/versioned-integrations.md: Full pattern documentation
|
|
71
|
+
- api/fastapi/dual/public.py: Similar pattern for dual routers
|
|
72
|
+
"""
|
|
73
|
+
# Create mock app to capture router
|
|
74
|
+
mock_app = FastAPI()
|
|
75
|
+
captured_router: APIRouter | None = None
|
|
76
|
+
|
|
77
|
+
def _capture_router(router: APIRouter, **_kwargs: Any) -> None:
|
|
78
|
+
"""Intercept include_router to capture instead of mount."""
|
|
79
|
+
nonlocal captured_router
|
|
80
|
+
captured_router = router
|
|
81
|
+
|
|
82
|
+
mock_app.include_router = _capture_router # type: ignore[method-assign]
|
|
83
|
+
|
|
84
|
+
# Patch add_prefixed_docs to prevent separate card (no-op if function doesn't call it)
|
|
85
|
+
def _noop_docs(*args: Any, **kwargs: Any) -> None:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
# Call add_function with patches active
|
|
89
|
+
with patch("svc_infra.api.fastapi.docs.scoped.add_prefixed_docs", _noop_docs):
|
|
90
|
+
result = add_function(
|
|
91
|
+
mock_app,
|
|
92
|
+
prefix=prefix,
|
|
93
|
+
**kwargs,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if captured_router is None:
|
|
97
|
+
raise RuntimeError(
|
|
98
|
+
f"Failed to capture router from {add_function.__name__}. "
|
|
99
|
+
f"The function may not call app.include_router()."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
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
|
|
svc_infra/app/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from .env import pick
|
|
1
|
+
from .env import MissingSecretError, pick, require_secret
|
|
2
2
|
from .logging import setup_logging
|
|
3
3
|
from .logging.formats import LoggingConfig, LogLevelOptions
|
|
4
4
|
|
|
@@ -7,4 +7,6 @@ __all__ = [
|
|
|
7
7
|
"LoggingConfig",
|
|
8
8
|
"LogLevelOptions",
|
|
9
9
|
"pick",
|
|
10
|
+
"require_secret",
|
|
11
|
+
"MissingSecretError",
|
|
10
12
|
]
|
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:
|
|
@@ -132,7 +132,7 @@ def pick(*, prod, nonprod=None, dev=None, test=None, local=None):
|
|
|
132
132
|
raise ValueError("pick(): No value found for environment and 'nonprod' was not provided.")
|
|
133
133
|
|
|
134
134
|
|
|
135
|
-
def find_env_file(start:
|
|
135
|
+
def find_env_file(start: Path | None = None) -> Path | None:
|
|
136
136
|
env_file = os.getenv("APP_ENV_FILE") or os.getenv("SVC_INFRA_ENV_FILE")
|
|
137
137
|
if env_file:
|
|
138
138
|
p = Path(env_file).expanduser()
|
|
@@ -146,7 +146,7 @@ def find_env_file(start: Optional[Path] = None) -> Optional[Path]:
|
|
|
146
146
|
return None
|
|
147
147
|
|
|
148
148
|
|
|
149
|
-
def load_env_if_present(path:
|
|
149
|
+
def load_env_if_present(path: Path | None, *, override: bool = False) -> list[str]:
|
|
150
150
|
if not path:
|
|
151
151
|
return []
|
|
152
152
|
before = dict(os.environ)
|
|
@@ -166,3 +166,69 @@ def prepare_env() -> Path:
|
|
|
166
166
|
env_file = find_env_file(start=root)
|
|
167
167
|
load_env_if_present(env_file, override=False)
|
|
168
168
|
return root
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class MissingSecretError(RuntimeError):
|
|
172
|
+
"""Raised when a required secret is not configured in production/staging."""
|
|
173
|
+
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def require_secret(
|
|
178
|
+
value: str | None,
|
|
179
|
+
name: str,
|
|
180
|
+
*,
|
|
181
|
+
dev_default: str | None = None,
|
|
182
|
+
environments: tuple[str, ...] = ("prod", "production", "staging", "test"),
|
|
183
|
+
) -> str:
|
|
184
|
+
"""Require a secret to be set in production environments.
|
|
185
|
+
|
|
186
|
+
In development/local environments, falls back to dev_default if provided.
|
|
187
|
+
In production environments, raises MissingSecretError if not set.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
value: The secret value (may be None or empty)
|
|
191
|
+
name: Name of the secret for error messages (e.g., "SESSION_SECRET")
|
|
192
|
+
dev_default: Default value to use in development (NEVER in production)
|
|
193
|
+
environments: Environments where the secret is required
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
The secret value
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
MissingSecretError: If secret is not set in production environments
|
|
200
|
+
|
|
201
|
+
Example:
|
|
202
|
+
>>> secret = require_secret(
|
|
203
|
+
... os.getenv("SESSION_SECRET"),
|
|
204
|
+
... "SESSION_SECRET",
|
|
205
|
+
... dev_default="dev-only-secret",
|
|
206
|
+
... )
|
|
207
|
+
"""
|
|
208
|
+
if value:
|
|
209
|
+
return value
|
|
210
|
+
|
|
211
|
+
current_env = get_current_environment()
|
|
212
|
+
|
|
213
|
+
# Check if we're in a production-like environment
|
|
214
|
+
raw_env = os.getenv("APP_ENV") or os.getenv("RAILWAY_ENVIRONMENT_NAME") or ""
|
|
215
|
+
is_production_like = (
|
|
216
|
+
current_env == PROD_ENV
|
|
217
|
+
or current_env == TEST_ENV # staging/preview
|
|
218
|
+
or raw_env.lower() in environments
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if is_production_like:
|
|
222
|
+
raise MissingSecretError(
|
|
223
|
+
f"SECURITY ERROR: {name} must be set in production/staging environments. "
|
|
224
|
+
f"Current environment: {current_env} (raw: {raw_env!r})"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# In development, use the dev default if provided
|
|
228
|
+
if dev_default is not None:
|
|
229
|
+
return dev_default
|
|
230
|
+
|
|
231
|
+
raise MissingSecretError(
|
|
232
|
+
f"{name} is not set and no dev_default was provided. "
|
|
233
|
+
"Either set the environment variable or provide a dev_default."
|
|
234
|
+
)
|
svc_infra/app/logging/add.py
CHANGED
|
@@ -2,11 +2,15 @@ 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
|
|
7
|
+
from typing import TYPE_CHECKING, cast
|
|
7
8
|
|
|
8
9
|
from svc_infra.app.env import CURRENT_ENVIRONMENT
|
|
9
10
|
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .formats import LogFormatOptions, LogLevelOptions
|
|
13
|
+
|
|
10
14
|
from .filter import filter_logs_for_paths
|
|
11
15
|
from .formats import (
|
|
12
16
|
JsonFormatter,
|
|
@@ -27,7 +31,11 @@ def setup_logging(
|
|
|
27
31
|
) -> None:
|
|
28
32
|
"""Configure logging + optional access-log path filtering."""
|
|
29
33
|
if fmt is not None or level is not None:
|
|
30
|
-
|
|
34
|
+
# Cast to expected Literal types after validation
|
|
35
|
+
LoggingConfig(
|
|
36
|
+
fmt=cast("LogFormatOptions | None", fmt),
|
|
37
|
+
level=cast("LogLevelOptions | None", level),
|
|
38
|
+
) # pydantic validation
|
|
31
39
|
|
|
32
40
|
if level is None:
|
|
33
41
|
level = _read_level()
|
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
|
|
|
@@ -35,7 +36,7 @@ class LoggingConfig(BaseModel):
|
|
|
35
36
|
class JsonFormatter(logging.Formatter):
|
|
36
37
|
"""Structured JSON formatter for prod and CI logs."""
|
|
37
38
|
|
|
38
|
-
def format(self, record: logging.LogRecord) -> str:
|
|
39
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
39
40
|
import json
|
|
40
41
|
import os as _os
|
|
41
42
|
from traceback import format_exception
|
|
@@ -50,15 +51,19 @@ class JsonFormatter(logging.Formatter):
|
|
|
50
51
|
|
|
51
52
|
# Add these two lines:
|
|
52
53
|
if getattr(record, "trace_id", None):
|
|
53
|
-
payload["trace_id"] = record.trace_id
|
|
54
|
+
payload["trace_id"] = record.trace_id # type: ignore[attr-defined]
|
|
54
55
|
if getattr(record, "span_id", None):
|
|
55
|
-
payload["span_id"] = record.span_id
|
|
56
|
+
payload["span_id"] = record.span_id # type: ignore[attr-defined]
|
|
56
57
|
|
|
57
58
|
# Optional correlation id
|
|
58
59
|
req_id = getattr(record, "request_id", None)
|
|
59
60
|
if req_id is not None:
|
|
60
61
|
payload["request_id"] = req_id
|
|
61
62
|
|
|
63
|
+
tenant_id = getattr(record, "tenant_id", None)
|
|
64
|
+
if tenant_id is not None:
|
|
65
|
+
payload["tenant_id"] = tenant_id
|
|
66
|
+
|
|
62
67
|
# Optional HTTP context
|
|
63
68
|
http_ctx = {
|
|
64
69
|
k: v
|
|
@@ -103,7 +108,10 @@ def _read_level() -> str:
|
|
|
103
108
|
return explicit.upper()
|
|
104
109
|
from svc_infra.app.env import pick
|
|
105
110
|
|
|
106
|
-
return
|
|
111
|
+
return cast(
|
|
112
|
+
"str",
|
|
113
|
+
pick(prod="INFO", nonprod="DEBUG", dev="DEBUG", test="DEBUG", local="DEBUG"),
|
|
114
|
+
).upper()
|
|
107
115
|
|
|
108
116
|
|
|
109
117
|
def _read_format() -> str:
|
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] = (),
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
|
17
|
+
from .models import (
|
|
18
|
+
Invoice,
|
|
19
|
+
InvoiceLine,
|
|
20
|
+
Plan,
|
|
21
|
+
PlanEntitlement,
|
|
22
|
+
Price,
|
|
23
|
+
Subscription,
|
|
24
|
+
UsageAggregate,
|
|
25
|
+
UsageEvent,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
# Primary API
|
|
30
|
+
"AsyncBillingService",
|
|
31
|
+
# Models
|
|
32
|
+
"UsageEvent",
|
|
33
|
+
"UsageAggregate",
|
|
34
|
+
"Plan",
|
|
35
|
+
"PlanEntitlement",
|
|
36
|
+
"Subscription",
|
|
37
|
+
"Price",
|
|
38
|
+
"Invoice",
|
|
39
|
+
"InvoiceLine",
|
|
40
|
+
]
|