svc-infra 0.1.595__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/models.py +133 -42
- svc_infra/apf_payments/provider/aiydan.py +121 -47
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +18 -9
- svc_infra/apf_payments/service.py +98 -41
- svc_infra/apf_payments/settings.py +5 -1
- 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 +128 -70
- svc_infra/api/fastapi/apf_payments/setup.py +13 -6
- 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 +17 -14
- svc_infra/api/fastapi/auth/gaurd.py +45 -16
- 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 +146 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- 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 +18 -6
- svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
- 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 +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- 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 -57
- 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/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 +3 -4
- 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 +6 -5
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +92 -10
- svc_infra/security/audit_service.py +4 -3
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -4
- svc_infra/security/jwt_rotation.py +74 -22
- svc_infra/security/lockout.py +11 -5
- svc_infra/security/models.py +54 -12
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +5 -3
- svc_infra/security/passwords.py +3 -1
- svc_infra/security/permissions.py +25 -2
- svc_infra/security/session.py +1 -1
- svc_infra/security/signed_cookies.py +21 -1
- 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.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-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
+
setattr(mock_app, "include_router", _capture_router)
|
|
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
|
|
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
|
@@ -129,7 +129,9 @@ 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(
|
|
132
|
+
raise ValueError(
|
|
133
|
+
"pick(): No value found for environment and 'nonprod' was not provided."
|
|
134
|
+
)
|
|
133
135
|
|
|
134
136
|
|
|
135
137
|
def find_env_file(start: Optional[Path] = None) -> Optional[Path]:
|
|
@@ -166,3 +168,69 @@ def prepare_env() -> Path:
|
|
|
166
168
|
env_file = find_env_file(start=root)
|
|
167
169
|
load_env_if_present(env_file, override=False)
|
|
168
170
|
return root
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class MissingSecretError(RuntimeError):
|
|
174
|
+
"""Raised when a required secret is not configured in production/staging."""
|
|
175
|
+
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def require_secret(
|
|
180
|
+
value: str | None,
|
|
181
|
+
name: str,
|
|
182
|
+
*,
|
|
183
|
+
dev_default: str | None = None,
|
|
184
|
+
environments: tuple[str, ...] = ("prod", "production", "staging", "test"),
|
|
185
|
+
) -> str:
|
|
186
|
+
"""Require a secret to be set in production environments.
|
|
187
|
+
|
|
188
|
+
In development/local environments, falls back to dev_default if provided.
|
|
189
|
+
In production environments, raises MissingSecretError if not set.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
value: The secret value (may be None or empty)
|
|
193
|
+
name: Name of the secret for error messages (e.g., "SESSION_SECRET")
|
|
194
|
+
dev_default: Default value to use in development (NEVER in production)
|
|
195
|
+
environments: Environments where the secret is required
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
The secret value
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
MissingSecretError: If secret is not set in production environments
|
|
202
|
+
|
|
203
|
+
Example:
|
|
204
|
+
>>> secret = require_secret(
|
|
205
|
+
... os.getenv("SESSION_SECRET"),
|
|
206
|
+
... "SESSION_SECRET",
|
|
207
|
+
... dev_default="dev-only-secret",
|
|
208
|
+
... )
|
|
209
|
+
"""
|
|
210
|
+
if value:
|
|
211
|
+
return value
|
|
212
|
+
|
|
213
|
+
current_env = get_current_environment()
|
|
214
|
+
|
|
215
|
+
# Check if we're in a production-like environment
|
|
216
|
+
raw_env = os.getenv("APP_ENV") or os.getenv("RAILWAY_ENVIRONMENT_NAME") or ""
|
|
217
|
+
is_production_like = (
|
|
218
|
+
current_env == PROD_ENV
|
|
219
|
+
or current_env == TEST_ENV # staging/preview
|
|
220
|
+
or raw_env.lower() in environments
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if is_production_like:
|
|
224
|
+
raise MissingSecretError(
|
|
225
|
+
f"SECURITY ERROR: {name} must be set in production/staging environments. "
|
|
226
|
+
f"Current environment: {current_env} (raw: {raw_env!r})"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# In development, use the dev default if provided
|
|
230
|
+
if dev_default is not None:
|
|
231
|
+
return dev_default
|
|
232
|
+
|
|
233
|
+
raise MissingSecretError(
|
|
234
|
+
f"{name} is not set and no dev_default was provided. "
|
|
235
|
+
"Either set the environment variable or provide a dev_default."
|
|
236
|
+
)
|
svc_infra/app/logging/add.py
CHANGED
|
@@ -3,10 +3,13 @@ from __future__ import annotations
|
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
5
|
from logging.config import dictConfig
|
|
6
|
-
from typing import Sequence
|
|
6
|
+
from typing import TYPE_CHECKING, Sequence, cast
|
|
7
7
|
|
|
8
8
|
from svc_infra.app.env import CURRENT_ENVIRONMENT
|
|
9
9
|
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .formats import LogFormatOptions, LogLevelOptions
|
|
12
|
+
|
|
10
13
|
from .filter import filter_logs_for_paths
|
|
11
14
|
from .formats import (
|
|
12
15
|
JsonFormatter,
|
|
@@ -27,7 +30,11 @@ def setup_logging(
|
|
|
27
30
|
) -> None:
|
|
28
31
|
"""Configure logging + optional access-log path filtering."""
|
|
29
32
|
if fmt is not None or level is not None:
|
|
30
|
-
|
|
33
|
+
# Cast to expected Literal types after validation
|
|
34
|
+
LoggingConfig(
|
|
35
|
+
fmt=cast("LogFormatOptions | None", fmt),
|
|
36
|
+
level=cast("LogLevelOptions | None", level),
|
|
37
|
+
) # pydantic validation
|
|
31
38
|
|
|
32
39
|
if level is None:
|
|
33
40
|
level = _read_level()
|
svc_infra/app/logging/formats.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
5
|
from enum import StrEnum
|
|
6
|
-
from typing import Sequence
|
|
6
|
+
from typing import Sequence, cast
|
|
7
7
|
|
|
8
8
|
from pydantic import BaseModel
|
|
9
9
|
|
|
@@ -35,7 +35,7 @@ class LoggingConfig(BaseModel):
|
|
|
35
35
|
class JsonFormatter(logging.Formatter):
|
|
36
36
|
"""Structured JSON formatter for prod and CI logs."""
|
|
37
37
|
|
|
38
|
-
def format(self, record: logging.LogRecord) -> str:
|
|
38
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
39
39
|
import json
|
|
40
40
|
import os as _os
|
|
41
41
|
from traceback import format_exception
|
|
@@ -50,15 +50,19 @@ class JsonFormatter(logging.Formatter):
|
|
|
50
50
|
|
|
51
51
|
# Add these two lines:
|
|
52
52
|
if getattr(record, "trace_id", None):
|
|
53
|
-
payload["trace_id"] = record.trace_id
|
|
53
|
+
payload["trace_id"] = record.trace_id # type: ignore[attr-defined]
|
|
54
54
|
if getattr(record, "span_id", None):
|
|
55
|
-
payload["span_id"] = record.span_id
|
|
55
|
+
payload["span_id"] = record.span_id # type: ignore[attr-defined]
|
|
56
56
|
|
|
57
57
|
# Optional correlation id
|
|
58
58
|
req_id = getattr(record, "request_id", None)
|
|
59
59
|
if req_id is not None:
|
|
60
60
|
payload["request_id"] = req_id
|
|
61
61
|
|
|
62
|
+
tenant_id = getattr(record, "tenant_id", None)
|
|
63
|
+
if tenant_id is not None:
|
|
64
|
+
payload["tenant_id"] = tenant_id
|
|
65
|
+
|
|
62
66
|
# Optional HTTP context
|
|
63
67
|
http_ctx = {
|
|
64
68
|
k: v
|
|
@@ -103,7 +107,10 @@ def _read_level() -> str:
|
|
|
103
107
|
return explicit.upper()
|
|
104
108
|
from svc_infra.app.env import pick
|
|
105
109
|
|
|
106
|
-
return
|
|
110
|
+
return cast(
|
|
111
|
+
str,
|
|
112
|
+
pick(prod="INFO", nonprod="DEBUG", dev="DEBUG", test="DEBUG", local="DEBUG"),
|
|
113
|
+
).upper()
|
|
107
114
|
|
|
108
115
|
|
|
109
116
|
def _read_format() -> str:
|
|
@@ -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((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
|