svc-infra 0.1.506__py3-none-any.whl → 0.1.654__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.
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/alembic.py +11 -0
- svc_infra/apf_payments/models.py +339 -0
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +797 -0
- svc_infra/apf_payments/provider/base.py +270 -0
- svc_infra/apf_payments/provider/registry.py +31 -0
- svc_infra/apf_payments/provider/stripe.py +873 -0
- svc_infra/apf_payments/schemas.py +333 -0
- svc_infra/apf_payments/service.py +892 -0
- svc_infra/apf_payments/settings.py +67 -0
- svc_infra/api/fastapi/__init__.py +6 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
- svc_infra/api/fastapi/apf_payments/router.py +1082 -0
- svc_infra/api/fastapi/apf_payments/setup.py +73 -0
- svc_infra/api/fastapi/auth/add.py +15 -6
- svc_infra/api/fastapi/auth/gaurd.py +67 -5
- svc_infra/api/fastapi/auth/mfa/router.py +18 -9
- svc_infra/api/fastapi/auth/routers/account.py +3 -2
- svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
- svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/security.py +3 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +1 -1
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/db/sql/users.py +14 -2
- svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +254 -0
- svc_infra/api/fastapi/dual/dualize.py +38 -33
- svc_infra/api/fastapi/dual/router.py +48 -1
- svc_infra/api/fastapi/dx.py +3 -3
- svc_infra/api/fastapi/http/__init__.py +0 -0
- svc_infra/api/fastapi/http/concurrency.py +14 -0
- svc_infra/api/fastapi/http/conditional.py +33 -0
- svc_infra/api/fastapi/http/deprecation.py +21 -0
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/idempotency.py +116 -0
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
- svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
- svc_infra/api/fastapi/middleware/request_id.py +23 -0
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +768 -55
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +363 -0
- svc_infra/api/fastapi/paths/auth.py +14 -14
- svc_infra/api/fastapi/paths/prefix.py +0 -1
- svc_infra/api/fastapi/paths/user.py +1 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +48 -15
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +32 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +10 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/outbox.py +104 -0
- svc_infra/db/sql/apikey.py +1 -1
- svc_infra/db/sql/authref.py +61 -0
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/repository.py +52 -12
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +16 -4
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/docs/acceptance-matrix.md +71 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/api.md +59 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +90 -0
- svc_infra/jobs/easy.py +32 -0
- svc_infra/jobs/loader.py +45 -0
- svc_infra/jobs/queue.py +81 -0
- svc_infra/jobs/redis_queue.py +191 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +41 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +53 -0
- svc_infra/obs/metrics.py +52 -0
- svc_infra/security/add.py +201 -0
- svc_infra/security/audit.py +130 -0
- svc_infra/security/audit_service.py +73 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +95 -0
- svc_infra/security/jwt_rotation.py +53 -0
- svc_infra/security/lockout.py +96 -0
- svc_infra/security/models.py +255 -0
- svc_infra/security/org_invites.py +128 -0
- svc_infra/security/passwords.py +77 -0
- svc_infra/security/permissions.py +149 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +80 -0
- svc_infra/webhooks/__init__.py +16 -0
- svc_infra/webhooks/add.py +322 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +67 -0
- svc_infra/webhooks/signing.py +30 -0
- svc_infra-0.1.654.dist-info/METADATA +154 -0
- svc_infra-0.1.654.dist-info/RECORD +352 -0
- svc_infra/api/fastapi/deps.py +0 -3
- svc_infra-0.1.506.dist-info/METADATA +0 -78
- svc_infra-0.1.506.dist-info/RECORD +0 -213
- /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Tenancy model and integration
|
|
2
|
+
|
|
3
|
+
This framework uses a soft-tenant isolation model by default: tenant_id is a column on tenant-scoped tables, and all queries are filtered by this value. Consumers can later adopt schema-per-tenant or DB-per-tenant strategies; the API surfaces remain compatible.
|
|
4
|
+
|
|
5
|
+
## How tenant is resolved
|
|
6
|
+
- `resolve_tenant_id(request)` looks up tenant id in this order:
|
|
7
|
+
1) Global override hook (set via `add_tenancy(app, resolver=...)`)
|
|
8
|
+
2) Auth identity (user.tenant_id or api_key.tenant_id) when auth is enabled
|
|
9
|
+
3) `X-Tenant-Id` request header
|
|
10
|
+
4) `request.state.tenant_id`
|
|
11
|
+
|
|
12
|
+
Use `TenantId` dependency to require it in routes, and `OptionalTenantId` to access it if present.
|
|
13
|
+
|
|
14
|
+
## Enforcement in data layer
|
|
15
|
+
- Wrap services with `TenantSqlService` to automatically:
|
|
16
|
+
- Apply `WHERE model.tenant_id == <tenant>` on list/get/update/delete/search/count.
|
|
17
|
+
- Inject `tenant_id` upon create when the model has the tenant field.
|
|
18
|
+
|
|
19
|
+
## Tenant-aware CRUD router
|
|
20
|
+
- When defining a `SqlResource`, set `tenant_field="tenant_id"` to mount a tenant-aware CRUD router. All endpoints will require `TenantId` and enforce scoping.
|
|
21
|
+
|
|
22
|
+
## Per-tenant rate limits / quotas
|
|
23
|
+
- Global middleware and per-route dependency support tenant-aware policies:
|
|
24
|
+
- `scope_by_tenant=True` puts requests in independent buckets per tenant.
|
|
25
|
+
- `limit_resolver(request, tenant_id)` lets you return dynamic limits (e.g., plan-based quotas).
|
|
26
|
+
|
|
27
|
+
## Export a tenant’s data (SQL)
|
|
28
|
+
- CLI command: `sql export-tenant`
|
|
29
|
+
- Example:
|
|
30
|
+
- `python -m svc_infra.cli sql export-tenant items --tenant-id t1 --output out.json`
|
|
31
|
+
- Flags:
|
|
32
|
+
- `--tenant-id` (required), `--tenant-field` (default `tenant_id`), `--limit` (optional), `--database-url` (or set `SQL_URL`).
|
|
33
|
+
|
|
34
|
+
## Migration to other isolation strategies
|
|
35
|
+
- Schema-per-tenant or DB-per-tenant can be layered by adapting the session factory or repository to select the schema/DB based on `tenant_id`. Your application code that relies on `TenantId` and tenant-aware services/routers remains the same.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Timeouts & Resource Limits
|
|
2
|
+
|
|
3
|
+
This guide covers request/handler timeouts, outbound HTTP client timeouts, database statement timeouts, job/webhook delivery timeouts, and graceful shutdown. It explains defaults, configuration, wiring, and recommended tuning by environment.
|
|
4
|
+
|
|
5
|
+
## Why timeouts?
|
|
6
|
+
|
|
7
|
+
- Protects your service from slowloris uploads and hanging requests
|
|
8
|
+
- Limits blast radius of slow downstreams (HTTP, DB, webhooks)
|
|
9
|
+
- Enables predictable backpressure and faster recovery during incidents
|
|
10
|
+
|
|
11
|
+
## Configuration overview
|
|
12
|
+
|
|
13
|
+
The library exposes simple environment variables with sensible defaults. Use floats for second values unless noted.
|
|
14
|
+
|
|
15
|
+
- REQUEST_BODY_TIMEOUT_SECONDS (int)
|
|
16
|
+
- Default: prod=15, nonprod=30
|
|
17
|
+
- Purpose: Abort slow request body reads (slowloris defense)
|
|
18
|
+
- REQUEST_TIMEOUT_SECONDS (int)
|
|
19
|
+
- Default: prod=30, nonprod=15
|
|
20
|
+
- Purpose: Cap overall handler execution time
|
|
21
|
+
- HTTP_CLIENT_TIMEOUT_SECONDS (float)
|
|
22
|
+
- Default: 10.0
|
|
23
|
+
- Purpose: Default timeout for outbound httpx clients created via helpers
|
|
24
|
+
- DB_STATEMENT_TIMEOUT_MS (int)
|
|
25
|
+
- Default: unset (disabled)
|
|
26
|
+
- Purpose: Per-transaction statement timeout (Postgres via SET LOCAL)
|
|
27
|
+
- JOB_DEFAULT_TIMEOUT_SECONDS (float)
|
|
28
|
+
- Default: unset (disabled)
|
|
29
|
+
- Purpose: Caps per-job handler runtime in the in-process jobs runner
|
|
30
|
+
- WEBHOOK_DELIVERY_TIMEOUT_SECONDS (float)
|
|
31
|
+
- Default: falls back to HTTP client default (10.0)
|
|
32
|
+
- Purpose: Timeout for webhook delivery HTTP calls
|
|
33
|
+
- SHUTDOWN_GRACE_PERIOD_SECONDS (float)
|
|
34
|
+
- Default: prod=20.0, nonprod=5.0
|
|
35
|
+
- Purpose: Wait time for in-flight requests to drain on shutdown
|
|
36
|
+
|
|
37
|
+
See ADR-0010 for design rationale: `src/svc_infra/docs/adr/0010-timeouts-and-resource-limits.md`.
|
|
38
|
+
|
|
39
|
+
## Request/handler timeouts (FastAPI)
|
|
40
|
+
|
|
41
|
+
Two middlewares enforce timeouts inside your ASGI app:
|
|
42
|
+
|
|
43
|
+
- BodyReadTimeoutMiddleware
|
|
44
|
+
- Enforces a per-chunk timeout while reading the incoming request body.
|
|
45
|
+
- If reads stall beyond the timeout, responds with 408 application/problem+json.
|
|
46
|
+
- Module: `svc_infra.api.fastapi.middleware.timeout.BodyReadTimeoutMiddleware`
|
|
47
|
+
- HandlerTimeoutMiddleware
|
|
48
|
+
- Caps overall request handler execution time using asyncio.wait_for.
|
|
49
|
+
- If exceeded, responds with 504 application/problem+json.
|
|
50
|
+
- Module: `svc_infra.api.fastapi.middleware.timeout.HandlerTimeoutMiddleware`
|
|
51
|
+
|
|
52
|
+
Example wiring:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from fastapi import FastAPI
|
|
56
|
+
from svc_infra.api.fastapi.middleware.timeout import (
|
|
57
|
+
BodyReadTimeoutMiddleware,
|
|
58
|
+
HandlerTimeoutMiddleware,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
app = FastAPI()
|
|
62
|
+
|
|
63
|
+
# Abort slow uploads (slowloris) after 15s in prod / 30s nonprod by default
|
|
64
|
+
app.add_middleware(BodyReadTimeoutMiddleware) # or timeout_seconds=20
|
|
65
|
+
|
|
66
|
+
# Cap total handler time (e.g., 30s in prod by default)
|
|
67
|
+
app.add_middleware(HandlerTimeoutMiddleware) # or timeout_seconds=25
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
HTTP semantics:
|
|
71
|
+
|
|
72
|
+
- Body timeout → 408 Request Timeout (Problem+JSON) with fields: type, title, status, detail, instance, trace_id
|
|
73
|
+
- Handler timeout → 504 Gateway Timeout (Problem+JSON) with fields: type, title, status, detail, instance, trace_id
|
|
74
|
+
|
|
75
|
+
## Outbound HTTP client timeouts (httpx)
|
|
76
|
+
|
|
77
|
+
Use the provided helpers to create httpx clients with the default timeout (driven by HTTP_CLIENT_TIMEOUT_SECONDS).
|
|
78
|
+
|
|
79
|
+
- Module: `svc_infra.http.client`
|
|
80
|
+
- `get_default_timeout_seconds()` → float
|
|
81
|
+
- `make_timeout(seconds=None) -> httpx.Timeout`
|
|
82
|
+
- `new_httpx_client(timeout_seconds=None, ...) -> httpx.Client`
|
|
83
|
+
- `new_async_httpx_client(timeout_seconds=None, ...) -> httpx.AsyncClient`
|
|
84
|
+
|
|
85
|
+
Error mapping:
|
|
86
|
+
|
|
87
|
+
- `httpx.TimeoutException` is mapped to 504 Gateway Timeout with Problem+JSON by default when `register_error_handlers(app)` is used.
|
|
88
|
+
- Module: `svc_infra.api.fastapi.middleware.errors.handlers.register_error_handlers`
|
|
89
|
+
|
|
90
|
+
## Database statement timeouts (SQLAlchemy / Postgres)
|
|
91
|
+
|
|
92
|
+
If `DB_STATEMENT_TIMEOUT_MS` is set and Postgres is used, a per-transaction `SET LOCAL statement_timeout = :ms` is executed for sessions yielded by the built-in dependency.
|
|
93
|
+
|
|
94
|
+
- Module: `svc_infra.api.fastapi.db.sql.session.get_session`
|
|
95
|
+
- Non-Postgres dialects (e.g., SQLite) ignore this gracefully.
|
|
96
|
+
|
|
97
|
+
## Jobs and webhooks
|
|
98
|
+
|
|
99
|
+
- Jobs runner
|
|
100
|
+
- Env: `JOB_DEFAULT_TIMEOUT_SECONDS`
|
|
101
|
+
- Module: `svc_infra.jobs.worker.process_one` — wraps job handler with `asyncio.wait_for()` when configured.
|
|
102
|
+
- Webhook delivery
|
|
103
|
+
- Env: `WEBHOOK_DELIVERY_TIMEOUT_SECONDS` (falls back to HTTP client default when unset)
|
|
104
|
+
- Module: `svc_infra.jobs.builtins.webhook_delivery.make_webhook_handler` — uses `new_async_httpx_client` with derived timeout.
|
|
105
|
+
|
|
106
|
+
## Graceful shutdown
|
|
107
|
+
|
|
108
|
+
Install graceful shutdown to wait for in-flight requests (up to a grace period) during application shutdown.
|
|
109
|
+
|
|
110
|
+
- Module: `svc_infra.api.fastapi.middleware.graceful_shutdown.install_graceful_shutdown`
|
|
111
|
+
- Env: `SHUTDOWN_GRACE_PERIOD_SECONDS` (prod=20.0, nonprod=5.0 by default)
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from svc_infra.api.fastapi.middleware.graceful_shutdown import install_graceful_shutdown
|
|
115
|
+
|
|
116
|
+
install_graceful_shutdown(app) # or grace_seconds=30.0
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Tuning recommendations
|
|
120
|
+
|
|
121
|
+
- Production
|
|
122
|
+
- REQUEST_BODY_TIMEOUT_SECONDS: 10–20s (shorter for public APIs)
|
|
123
|
+
- REQUEST_TIMEOUT_SECONDS: 20–30s (align with upstream proxy/gateway timeouts)
|
|
124
|
+
- HTTP_CLIENT_TIMEOUT_SECONDS: 3–10s (favor quick failover with retries)
|
|
125
|
+
- DB_STATEMENT_TIMEOUT_MS: set per-route/transaction if queries are constrained
|
|
126
|
+
- SHUTDOWN_GRACE_PERIOD_SECONDS: 20–60s depending on peak latencies
|
|
127
|
+
- Staging/Dev
|
|
128
|
+
- Relax timeouts slightly to reduce test flakiness (defaults already reflect this)
|
|
129
|
+
- Gateways/Proxies
|
|
130
|
+
- Ensure upstream (e.g., NGINX, ALB) timeouts exceed app’s body timeout and are aligned with handler timeout to avoid double timeouts.
|
|
131
|
+
|
|
132
|
+
## Testing and acceptance
|
|
133
|
+
|
|
134
|
+
- Unit tests cover body read timeout, handler timeout, outbound timeout mapping, and a smoke check for DB statement timeout.
|
|
135
|
+
- Acceptance tests:
|
|
136
|
+
- A2-04: slow handler → 504 Problem
|
|
137
|
+
- A2-05: slow body → 408 Problem or 413 (size) as applicable
|
|
138
|
+
- A2-06: outbound httpx timeout → 504 Problem
|
|
139
|
+
|
|
140
|
+
## Troubleshooting
|
|
141
|
+
|
|
142
|
+
- Seeing 200 instead of 408 for slow uploads under some servers?
|
|
143
|
+
- Some servers buffer the entire body before invoking the app. The BodyReadTimeoutMiddleware greedily drains with per-chunk timeouts and replays to reliably detect slowloris. Ensure HTTP/1.1 parsing with a streaming-capable server implementation (e.g., uvicorn+httptools) in acceptance tests.
|
|
144
|
+
- Outbound timeouts not mapped to Problem?
|
|
145
|
+
- Ensure `register_error_handlers(app)` is installed so `httpx.TimeoutException` returns a 504 Problem.
|
|
146
|
+
- Statement timeout ignored on SQLite?
|
|
147
|
+
- Expected. Non-Postgres dialects skip `SET LOCAL` safely.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Using add_* Functions Under Versioned Routing
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
By default, `add_*` functions from svc-infra and fin-infra mount routes at root level (e.g., `/banking/*`, `/_sql/*`). However, you may want all features consolidated under a single versioned API prefix (e.g., `/v0/banking`) to keep your API organized under version namespaces.
|
|
6
|
+
|
|
7
|
+
## Simple Solution (Recommended)
|
|
8
|
+
|
|
9
|
+
Use the `extract_router()` helper:
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
# src/your_api/routers/v0/banking.py
|
|
13
|
+
from svc_infra.api.fastapi.versioned import extract_router
|
|
14
|
+
from fin_infra.banking import add_banking
|
|
15
|
+
|
|
16
|
+
# Extract router and provider from add_banking()
|
|
17
|
+
router, banking_provider = extract_router(
|
|
18
|
+
add_banking,
|
|
19
|
+
prefix="/banking",
|
|
20
|
+
provider="plaid",
|
|
21
|
+
cache_ttl=60,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# That's it! svc-infra auto-discovers 'router' and mounts at /v0/banking
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Result
|
|
28
|
+
|
|
29
|
+
- ✅ All banking endpoints under `/v0/banking/*`
|
|
30
|
+
- ✅ Banking docs included in `/v0/docs` (not separate card)
|
|
31
|
+
- ✅ Full `add_banking()` functionality preserved
|
|
32
|
+
- ✅ Returns provider instance for additional use
|
|
33
|
+
|
|
34
|
+
## Complete Example
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
# Directory structure
|
|
38
|
+
your_api/
|
|
39
|
+
routers/
|
|
40
|
+
v0/
|
|
41
|
+
__init__.py
|
|
42
|
+
status.py
|
|
43
|
+
banking.py # <- Integration using helper
|
|
44
|
+
payments.py # <- Another integration
|
|
45
|
+
|
|
46
|
+
# banking.py - Clean and simple
|
|
47
|
+
"""Banking integration under v0 routing."""
|
|
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", # or "teller"
|
|
55
|
+
cache_ttl=60,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Optional: Store provider on app state for later use
|
|
59
|
+
# This happens in app.py after router discovery:
|
|
60
|
+
# app.state.banking = banking_provider
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Works With
|
|
64
|
+
|
|
65
|
+
Any svc-infra or fin-infra function that calls `app.include_router()`:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
# Banking integration
|
|
69
|
+
from fin_infra.banking import add_banking
|
|
70
|
+
router, provider = extract_router(add_banking, prefix="/banking", provider="plaid")
|
|
71
|
+
|
|
72
|
+
# Market data
|
|
73
|
+
from fin_infra.markets import add_market_data
|
|
74
|
+
router, provider = extract_router(add_market_data, prefix="/markets")
|
|
75
|
+
|
|
76
|
+
# Analytics
|
|
77
|
+
from fin_infra.analytics import add_analytics
|
|
78
|
+
router, provider = extract_router(add_analytics, prefix="/analytics")
|
|
79
|
+
|
|
80
|
+
# Budgets
|
|
81
|
+
from fin_infra.budgets import add_budgets
|
|
82
|
+
router, provider = extract_router(add_budgets, prefix="/budgets")
|
|
83
|
+
|
|
84
|
+
# Documents
|
|
85
|
+
from fin_infra.documents import add_documents
|
|
86
|
+
router, provider = extract_router(add_documents, prefix="/documents")
|
|
87
|
+
|
|
88
|
+
# Any custom add_* function following the pattern
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## When to Use
|
|
92
|
+
|
|
93
|
+
**Use when:**
|
|
94
|
+
- Building a monolithic versioned API where all features belong under `/v0`, `/v1`, etc.
|
|
95
|
+
- You want unified documentation at `/v0/docs` showing all features together
|
|
96
|
+
- You're consolidating multiple integrations under one version
|
|
97
|
+
- You need version-specific behavior for third-party integrations
|
|
98
|
+
|
|
99
|
+
**Don't use when:**
|
|
100
|
+
- Feature should have its own root-level endpoint (e.g., public webhooks at `/webhooks`)
|
|
101
|
+
- Integration is shared across multiple versions (mount at root instead)
|
|
102
|
+
- You only need a subset of endpoints (define manually)
|
|
103
|
+
|
|
104
|
+
## Alternative: Manual Definition
|
|
105
|
+
|
|
106
|
+
For simple integrations, define routes manually:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
# routers/v0/banking.py
|
|
110
|
+
from svc_infra.api.fastapi.dual.public import public_router
|
|
111
|
+
from fin_infra.banking import easy_banking
|
|
112
|
+
|
|
113
|
+
router = public_router(prefix="/banking", tags=["Banking"])
|
|
114
|
+
banking = easy_banking(provider="plaid")
|
|
115
|
+
|
|
116
|
+
@router.post("/link")
|
|
117
|
+
async def create_link(request: CreateLinkRequest):
|
|
118
|
+
return banking.create_link_token(user_id=request.user_id)
|
|
119
|
+
|
|
120
|
+
# ... define other endpoints
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Use manual definition when:
|
|
124
|
+
- Only need a subset of integration endpoints
|
|
125
|
+
- Want custom validation/transforms per endpoint
|
|
126
|
+
- Integration is very simple (2-3 endpoints)
|
|
127
|
+
- Need version-specific behavior per endpoint
|
|
128
|
+
|
|
129
|
+
## How It Works
|
|
130
|
+
|
|
131
|
+
The `extract_router()` helper:
|
|
132
|
+
|
|
133
|
+
1. **Creates Mock App**: Temporary FastAPI instance to capture router
|
|
134
|
+
2. **Intercepts Router**: Monkey-patches `include_router()` to capture instead of mount
|
|
135
|
+
3. **Calls Integration**: Runs `add_*()` function which creates all routes normally
|
|
136
|
+
4. **Returns Router**: Exports captured router for svc-infra auto-discovery
|
|
137
|
+
5. **Auto-Mounts**: svc-infra finds `router` in `v0.banking` and mounts at `/v0/banking`
|
|
138
|
+
|
|
139
|
+
The provider/integration instance is also returned for additional use if needed.
|
|
140
|
+
|
|
141
|
+
## See Also
|
|
142
|
+
|
|
143
|
+
- [API Versioning](./api.md#versioning) - How svc-infra version routing works
|
|
144
|
+
- [Router Auto-Discovery](./api.md#router-discovery) - How routers are found and mounted
|
|
145
|
+
- [Dual Routers](./api.md#dual-routers) - Similar pattern for public/protected routers
|
|
146
|
+
- `svc_infra.api.fastapi.versioned` - Source code for helper function
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Webhooks Framework
|
|
2
|
+
|
|
3
|
+
This module provides primitives to publish events to external consumers via webhooks, verify inbound signatures, and handle robust retries using the shared JobQueue and Outbox patterns.
|
|
4
|
+
|
|
5
|
+
> ℹ️ Webhook helper environment expectations live in [Environment Reference](environment.md).
|
|
6
|
+
|
|
7
|
+
## Quickstart
|
|
8
|
+
|
|
9
|
+
- Subscriptions and publishing:
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from svc_infra.webhooks.service import InMemoryWebhookSubscriptions, WebhookService
|
|
13
|
+
from svc_infra.db.outbox import InMemoryOutboxStore
|
|
14
|
+
|
|
15
|
+
subs = InMemoryWebhookSubscriptions()
|
|
16
|
+
subs.add("invoice.created", "https://example.com/webhook", "sekrit")
|
|
17
|
+
svc = WebhookService(outbox=InMemoryOutboxStore(), subs=subs)
|
|
18
|
+
svc.publish("invoice.created", {"id": "inv_1", "version": 1})
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- Delivery worker and headers:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from svc_infra.jobs.builtins.webhook_delivery import make_webhook_handler
|
|
25
|
+
from svc_infra.jobs.worker import process_one
|
|
26
|
+
|
|
27
|
+
handler = make_webhook_handler(
|
|
28
|
+
outbox=..., inbox=..., get_webhook_url_for_topic=lambda t: url, get_secret_for_topic=lambda t: secret,
|
|
29
|
+
)
|
|
30
|
+
# process_one(queue, handler) will POST JSON with headers:
|
|
31
|
+
# X-Event-Id, X-Topic, X-Attempt, X-Signature (HMAC-SHA256), X-Signature-Alg, X-Signature-Version, X-Payload-Version
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- Verification (FastAPI):
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from fastapi import Depends, FastAPI
|
|
38
|
+
from svc_infra.webhooks.fastapi import require_signature
|
|
39
|
+
from svc_infra.webhooks.signing import sign
|
|
40
|
+
|
|
41
|
+
app = FastAPI()
|
|
42
|
+
app.post("/webhook")(lambda body=Depends(require_signature(lambda: ["old","new"])): {"ok": True})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## FastAPI wiring
|
|
46
|
+
|
|
47
|
+
- Attach the router with shared in-memory stores (great for tests / local runs):
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from fastapi import FastAPI
|
|
51
|
+
|
|
52
|
+
from svc_infra.webhooks import add_webhooks
|
|
53
|
+
|
|
54
|
+
app = FastAPI()
|
|
55
|
+
add_webhooks(app)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- Respect environment overrides for Redis-backed stores by exporting `REDIS_URL`
|
|
59
|
+
and selecting the backend via `WEBHOOKS_OUTBOX=redis` (optional
|
|
60
|
+
`WEBHOOKS_INBOX=redis` for the dedupe store). The helper records the chosen
|
|
61
|
+
instances on `app.state` for further customisation:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import os
|
|
65
|
+
|
|
66
|
+
os.environ["WEBHOOKS_OUTBOX"] = "redis"
|
|
67
|
+
os.environ["WEBHOOKS_INBOX"] = "redis"
|
|
68
|
+
|
|
69
|
+
app = FastAPI()
|
|
70
|
+
add_webhooks(app) # creates RedisOutboxStore / RedisInboxStore when redis-py is available
|
|
71
|
+
|
|
72
|
+
# Later you can inspect or extend behaviour:
|
|
73
|
+
app.state.webhooks_subscriptions.add("invoice.created", "https://example.com/webhook", "sekrit")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- Provide explicit overrides (e.g. dependency-injected SQL stores) or reuse your
|
|
77
|
+
existing job queue / scheduler. Passing a queue automatically registers the
|
|
78
|
+
outbox tick and delivery handler so your worker loop can process jobs:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from svc_infra.jobs.easy import easy_jobs
|
|
82
|
+
|
|
83
|
+
queue, scheduler = easy_jobs()
|
|
84
|
+
|
|
85
|
+
add_webhooks(
|
|
86
|
+
app,
|
|
87
|
+
outbox=my_outbox_store,
|
|
88
|
+
inbox=lambda: my_inbox_store, # factories are supported
|
|
89
|
+
queue=queue,
|
|
90
|
+
scheduler=scheduler,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# scheduler.add_task(...) is handled internally when both queue and scheduler are supplied
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Runner wiring
|
|
97
|
+
|
|
98
|
+
If you prefer explicit wiring, you can still register the tick manually:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from svc_infra.jobs.easy import easy_jobs
|
|
102
|
+
from svc_infra.jobs.builtins.outbox_processor import make_outbox_tick
|
|
103
|
+
|
|
104
|
+
queue, scheduler = easy_jobs() # uses JOBS_DRIVER and REDIS_URL
|
|
105
|
+
scheduler.add_task("outbox", 1, make_outbox_tick(outbox_store, queue))
|
|
106
|
+
# Start runner: `svc-infra jobs run`
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Notes
|
|
110
|
+
- Retries/backoff are handled by the JobQueue; delivery marks Inbox after success to prevent duplicates.
|
|
111
|
+
- For production subscriptions and inbox/outbox, provide persistent implementations and override DI in your app.
|
|
112
|
+
- Signature rotation supported via `verify_any` and FastAPI dependency accepting multiple secrets.
|
svc_infra/dx/add.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def write_ci_workflow(
|
|
7
|
+
*,
|
|
8
|
+
target_dir: str | Path,
|
|
9
|
+
name: str = "ci.yml",
|
|
10
|
+
python_version: str = "3.12",
|
|
11
|
+
) -> Path:
|
|
12
|
+
"""Write a minimal CI workflow file (GitHub Actions) with tests/lint/type steps."""
|
|
13
|
+
p = Path(target_dir) / ".github" / "workflows" / name
|
|
14
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
content = f"""
|
|
16
|
+
name: CI
|
|
17
|
+
|
|
18
|
+
on:
|
|
19
|
+
push:
|
|
20
|
+
branches: [ main ]
|
|
21
|
+
pull_request:
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
build:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
- uses: actions/setup-python@v5
|
|
29
|
+
with:
|
|
30
|
+
python-version: '{python_version}'
|
|
31
|
+
- name: Install Poetry
|
|
32
|
+
run: pipx install poetry
|
|
33
|
+
- name: Install deps
|
|
34
|
+
run: poetry install
|
|
35
|
+
- name: Lint
|
|
36
|
+
run: poetry run flake8 --select=E,F
|
|
37
|
+
- name: Typecheck
|
|
38
|
+
run: poetry run mypy src
|
|
39
|
+
- name: Tests
|
|
40
|
+
run: poetry run pytest -q -W error
|
|
41
|
+
"""
|
|
42
|
+
p.write_text(content.strip() + "\n")
|
|
43
|
+
return p
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def write_openapi_lint_config(*, target_dir: str | Path, name: str = ".redocly.yaml") -> Path:
|
|
47
|
+
"""Write a minimal OpenAPI lint config placeholder (Redocly)."""
|
|
48
|
+
p = Path(target_dir) / name
|
|
49
|
+
content = """
|
|
50
|
+
apis:
|
|
51
|
+
main:
|
|
52
|
+
root: openapi.json
|
|
53
|
+
|
|
54
|
+
rules:
|
|
55
|
+
operation-operationId: warn
|
|
56
|
+
no-unused-components: warn
|
|
57
|
+
security-defined: off
|
|
58
|
+
"""
|
|
59
|
+
p.write_text(content.strip() + "\n")
|
|
60
|
+
return p
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = ["write_ci_workflow", "write_openapi_lint_config"]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date as _date
|
|
5
|
+
from typing import Sequence
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Commit:
|
|
10
|
+
sha: str
|
|
11
|
+
subject: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_SECTION_ORDER = [
|
|
15
|
+
("feat", "Features"),
|
|
16
|
+
("fix", "Bug Fixes"),
|
|
17
|
+
("perf", "Performance"),
|
|
18
|
+
("refactor", "Refactors"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _classify(subject: str) -> tuple[str, str]:
|
|
23
|
+
"""Return (type, title) where title is display name of the section."""
|
|
24
|
+
lower = subject.strip().lower()
|
|
25
|
+
for t, title in _SECTION_ORDER:
|
|
26
|
+
if lower.startswith(t + ":") or lower.startswith(t + "("):
|
|
27
|
+
return (t, title)
|
|
28
|
+
return ("other", "Other")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _format_item(commit: Commit) -> str:
|
|
32
|
+
subj = commit.subject.strip()
|
|
33
|
+
# Strip leading type(scope): if present
|
|
34
|
+
i = subj.find(": ")
|
|
35
|
+
if i != -1 and i < 20: # conventional commit prefix
|
|
36
|
+
pretty = subj[i + 2 :].strip()
|
|
37
|
+
else:
|
|
38
|
+
pretty = subj
|
|
39
|
+
return f"- {pretty} ({commit.sha})"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def generate_release_section(
|
|
43
|
+
*,
|
|
44
|
+
version: str,
|
|
45
|
+
commits: Sequence[Commit],
|
|
46
|
+
release_date: str | None = None,
|
|
47
|
+
) -> str:
|
|
48
|
+
"""Generate a markdown release section from commits.
|
|
49
|
+
|
|
50
|
+
Group by type: feat, fix, perf, refactor; everything else under Other.
|
|
51
|
+
"""
|
|
52
|
+
if release_date is None:
|
|
53
|
+
release_date = _date.today().isoformat()
|
|
54
|
+
|
|
55
|
+
buckets: dict[str, list[str]] = {k: [] for k, _ in _SECTION_ORDER}
|
|
56
|
+
buckets["other"] = []
|
|
57
|
+
|
|
58
|
+
for c in commits:
|
|
59
|
+
typ, _ = _classify(c.subject)
|
|
60
|
+
buckets.setdefault(typ, []).append(_format_item(c))
|
|
61
|
+
|
|
62
|
+
lines: list[str] = [f"## v{version} - {release_date}", ""]
|
|
63
|
+
for key, title in _SECTION_ORDER + [("other", "Other")]:
|
|
64
|
+
items = buckets.get(key) or []
|
|
65
|
+
if not items:
|
|
66
|
+
continue
|
|
67
|
+
lines.append(f"### {title}")
|
|
68
|
+
lines.extend(items)
|
|
69
|
+
lines.append("")
|
|
70
|
+
|
|
71
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["Commit", "generate_release_section"]
|
svc_infra/dx/checks.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _load_json(path: str | Path) -> dict:
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
p = Path(path)
|
|
10
|
+
return json.loads(p.read_text())
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check_openapi_problem_schema(
|
|
14
|
+
schema: dict | None = None, *, path: str | Path | None = None
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Validate OpenAPI has a Problem schema with required fields and formats.
|
|
17
|
+
|
|
18
|
+
Raises ValueError with a descriptive message on failure.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
if schema is None:
|
|
22
|
+
if path is None:
|
|
23
|
+
raise ValueError("either schema or path must be provided")
|
|
24
|
+
schema = _load_json(path)
|
|
25
|
+
|
|
26
|
+
comps = (schema or {}).get("components") or {}
|
|
27
|
+
prob = (comps.get("schemas") or {}).get("Problem")
|
|
28
|
+
if not isinstance(prob, dict):
|
|
29
|
+
raise ValueError("Problem schema missing under components.schemas.Problem")
|
|
30
|
+
|
|
31
|
+
props = prob.get("properties") or {}
|
|
32
|
+
# Required keys presence
|
|
33
|
+
for key in ("type", "title", "status", "detail", "instance", "code"):
|
|
34
|
+
if key not in props:
|
|
35
|
+
raise ValueError(f"Problem.{key} missing in properties")
|
|
36
|
+
|
|
37
|
+
# instance must be uri-reference per our convention
|
|
38
|
+
inst = props.get("instance") or {}
|
|
39
|
+
if inst.get("format") != "uri-reference":
|
|
40
|
+
raise ValueError("Problem.instance must have format 'uri-reference'")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def check_migrations_up_to_date(*, project_root: str | Path = ".") -> None:
|
|
44
|
+
"""Best-effort migrations check: passes if alembic env present and head is reachable.
|
|
45
|
+
|
|
46
|
+
This is a lightweight stub that can be extended per-project. For now, it checks
|
|
47
|
+
that an Alembic env exists when 'alembic.ini' is present; it does not execute DB calls.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
root = Path(project_root)
|
|
51
|
+
# If alembic.ini is absent, there's nothing to check here
|
|
52
|
+
if not (root / "alembic.ini").exists():
|
|
53
|
+
return
|
|
54
|
+
# Ensure versions/ dir exists under migrations path if configured, default to 'migrations'
|
|
55
|
+
mig_dir = root / "migrations"
|
|
56
|
+
if not mig_dir.exists():
|
|
57
|
+
# tolerate alternative layout via env; keep stub permissive
|
|
58
|
+
return
|
|
59
|
+
versions = mig_dir / "versions"
|
|
60
|
+
if not versions.exists():
|
|
61
|
+
raise ValueError("Alembic migrations directory missing versions/ subfolder")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"check_openapi_problem_schema",
|
|
66
|
+
"check_migrations_up_to_date",
|
|
67
|
+
]
|