svc-infra 0.1.604__py3-none-any.whl → 0.1.605__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/api/fastapi/docs/add.py +83 -5
- svc_infra/api/fastapi/openapi/mutators.py +111 -0
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/service.py +115 -0
- svc_infra/cli/__init__.py +8 -0
- svc_infra/cli/cmds/__init__.py +4 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- {svc_infra-0.1.604.dist-info → svc_infra-0.1.605.dist-info}/METADATA +2 -1
- {svc_infra-0.1.604.dist-info → svc_infra-0.1.605.dist-info}/RECORD +17 -8
- {svc_infra-0.1.604.dist-info → svc_infra-0.1.605.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.604.dist-info → svc_infra-0.1.605.dist-info}/entry_points.txt +0 -0
|
@@ -4,10 +4,13 @@ import json
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from typing import Optional
|
|
6
6
|
|
|
7
|
-
from fastapi import FastAPI
|
|
7
|
+
from fastapi import FastAPI, Request
|
|
8
8
|
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
|
9
9
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
10
10
|
|
|
11
|
+
from .landing import CardSpec, DocTargets, render_index_html
|
|
12
|
+
from .scoped import DOC_SCOPES
|
|
13
|
+
|
|
11
14
|
|
|
12
15
|
def add_docs(
|
|
13
16
|
app: FastAPI,
|
|
@@ -16,6 +19,9 @@ def add_docs(
|
|
|
16
19
|
swagger_url: str = "/docs",
|
|
17
20
|
openapi_url: str = "/openapi.json",
|
|
18
21
|
export_openapi_to: Optional[str] = None,
|
|
22
|
+
# Landing page options
|
|
23
|
+
landing_url: str = "/",
|
|
24
|
+
include_landing: bool = True,
|
|
19
25
|
) -> None:
|
|
20
26
|
"""Enable docs endpoints and optionally export OpenAPI schema to disk on startup.
|
|
21
27
|
|
|
@@ -29,14 +35,22 @@ def add_docs(
|
|
|
29
35
|
app.add_api_route(openapi_url, openapi_handler, methods=["GET"], include_in_schema=False)
|
|
30
36
|
|
|
31
37
|
# Swagger UI route
|
|
32
|
-
async def swagger_ui() -> HTMLResponse: # noqa: ANN201
|
|
33
|
-
|
|
38
|
+
async def swagger_ui(request: Request) -> HTMLResponse: # noqa: ANN201
|
|
39
|
+
resp = get_swagger_ui_html(openapi_url=openapi_url, title="API Docs")
|
|
40
|
+
theme = request.query_params.get("theme")
|
|
41
|
+
if theme == "dark":
|
|
42
|
+
return _with_dark_mode(resp)
|
|
43
|
+
return resp
|
|
34
44
|
|
|
35
45
|
app.add_api_route(swagger_url, swagger_ui, methods=["GET"], include_in_schema=False)
|
|
36
46
|
|
|
37
47
|
# Redoc route
|
|
38
|
-
async def redoc_ui() -> HTMLResponse: # noqa: ANN201
|
|
39
|
-
|
|
48
|
+
async def redoc_ui(request: Request) -> HTMLResponse: # noqa: ANN201
|
|
49
|
+
resp = get_redoc_html(openapi_url=openapi_url, title="API ReDoc")
|
|
50
|
+
theme = request.query_params.get("theme")
|
|
51
|
+
if theme == "dark":
|
|
52
|
+
return _with_dark_mode(resp)
|
|
53
|
+
return resp
|
|
40
54
|
|
|
41
55
|
app.add_api_route(redoc_url, redoc_ui, methods=["GET"], include_in_schema=False)
|
|
42
56
|
|
|
@@ -52,6 +66,70 @@ def add_docs(
|
|
|
52
66
|
|
|
53
67
|
app.add_event_handler("startup", _export_docs)
|
|
54
68
|
|
|
69
|
+
# Optional landing page with the same look/feel as setup_service_api
|
|
70
|
+
if include_landing:
|
|
71
|
+
# Avoid path collision; if landing_url is already taken for GET, fallback to "/_docs"
|
|
72
|
+
existing_paths = {
|
|
73
|
+
(getattr(r, "path", None) or getattr(r, "path_format", None))
|
|
74
|
+
for r in getattr(app, "routes", [])
|
|
75
|
+
if getattr(r, "methods", None) and "GET" in r.methods
|
|
76
|
+
}
|
|
77
|
+
landing_path = landing_url or "/"
|
|
78
|
+
if landing_path in existing_paths:
|
|
79
|
+
landing_path = "/_docs"
|
|
80
|
+
|
|
81
|
+
async def _landing() -> HTMLResponse: # noqa: ANN201
|
|
82
|
+
cards: list[CardSpec] = []
|
|
83
|
+
# Root docs card using the provided paths
|
|
84
|
+
cards.append(
|
|
85
|
+
CardSpec(
|
|
86
|
+
tag="",
|
|
87
|
+
docs=DocTargets(swagger=swagger_url, redoc=redoc_url, openapi_json=openapi_url),
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
# Scoped docs (if any were registered via add_prefixed_docs)
|
|
91
|
+
for scope, swagger, redoc, openapi_json, _title in DOC_SCOPES:
|
|
92
|
+
cards.append(
|
|
93
|
+
CardSpec(
|
|
94
|
+
tag=scope.strip("/"),
|
|
95
|
+
docs=DocTargets(swagger=swagger, redoc=redoc, openapi_json=openapi_json),
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
html = render_index_html(
|
|
99
|
+
service_name=app.title or "API", release=app.version or "", cards=cards
|
|
100
|
+
)
|
|
101
|
+
return HTMLResponse(html)
|
|
102
|
+
|
|
103
|
+
app.add_api_route(landing_path, _landing, methods=["GET"], include_in_schema=False)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _with_dark_mode(resp: HTMLResponse) -> HTMLResponse:
|
|
107
|
+
"""Return a copy of the HTMLResponse with a minimal dark-theme CSS injected.
|
|
108
|
+
|
|
109
|
+
We avoid depending on custom Swagger/ReDoc builds; this works by inlining a small CSS
|
|
110
|
+
block and toggling a `.dark` class on the body element.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
body = resp.body.decode("utf-8", errors="ignore")
|
|
114
|
+
except Exception: # pragma: no cover - very unlikely
|
|
115
|
+
return resp
|
|
116
|
+
|
|
117
|
+
css = _DARK_CSS
|
|
118
|
+
if "</head>" in body:
|
|
119
|
+
body = body.replace("</head>", f"<style>\n{css}\n</style></head>", 1)
|
|
120
|
+
# add class to body to allow stronger selectors
|
|
121
|
+
body = body.replace("<body>", '<body class="dark">', 1)
|
|
122
|
+
return HTMLResponse(content=body, status_code=resp.status_code, headers=dict(resp.headers))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_DARK_CSS = """
|
|
126
|
+
/* Minimal dark mode override for Swagger/ReDoc */
|
|
127
|
+
@media (prefers-color-scheme: dark) { :root { color-scheme: dark; } }
|
|
128
|
+
html.dark, body.dark { background: #0b0e14; color: #e0e6f1; }
|
|
129
|
+
#swagger, .redoc-wrap { background: transparent; }
|
|
130
|
+
a { color: #62aef7; }
|
|
131
|
+
"""
|
|
132
|
+
|
|
55
133
|
|
|
56
134
|
def add_sdk_generation_stub(
|
|
57
135
|
app: FastAPI,
|
|
@@ -1102,6 +1102,114 @@ def ensure_success_examples_mutator():
|
|
|
1102
1102
|
return m
|
|
1103
1103
|
|
|
1104
1104
|
|
|
1105
|
+
# --- NEW: attach minimal x-codeSamples for common operations ---
|
|
1106
|
+
def attach_code_samples_mutator():
|
|
1107
|
+
"""Attach minimal curl/httpie x-codeSamples for each operation if missing.
|
|
1108
|
+
|
|
1109
|
+
We avoid templating parameters; samples illustrate method and path only.
|
|
1110
|
+
"""
|
|
1111
|
+
|
|
1112
|
+
def m(schema: dict) -> dict:
|
|
1113
|
+
schema = dict(schema)
|
|
1114
|
+
servers = schema.get("servers") or [{"url": ""}]
|
|
1115
|
+
base = servers[0].get("url") or ""
|
|
1116
|
+
|
|
1117
|
+
for path, method, op in _iter_ops(schema):
|
|
1118
|
+
# Don't override existing samples
|
|
1119
|
+
if isinstance(op.get("x-codeSamples"), list) and op["x-codeSamples"]:
|
|
1120
|
+
continue
|
|
1121
|
+
url = f"{base}{path}"
|
|
1122
|
+
method_up = method.upper()
|
|
1123
|
+
samples = [
|
|
1124
|
+
{
|
|
1125
|
+
"lang": "bash",
|
|
1126
|
+
"label": "curl",
|
|
1127
|
+
"source": f"curl -X {method_up} '{url}'",
|
|
1128
|
+
},
|
|
1129
|
+
{
|
|
1130
|
+
"lang": "bash",
|
|
1131
|
+
"label": "httpie",
|
|
1132
|
+
"source": f"http {method_up} '{url}'",
|
|
1133
|
+
},
|
|
1134
|
+
]
|
|
1135
|
+
op["x-codeSamples"] = samples
|
|
1136
|
+
return schema
|
|
1137
|
+
|
|
1138
|
+
return m
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
# --- NEW: ensure Problem+JSON examples exist for standard error responses ---
|
|
1142
|
+
def ensure_problem_examples_mutator():
|
|
1143
|
+
"""Add example objects for 4xx/5xx responses using Problem schema if absent."""
|
|
1144
|
+
|
|
1145
|
+
try:
|
|
1146
|
+
# Internal helper with sensible defaults
|
|
1147
|
+
from .conventions import _problem_example # type: ignore
|
|
1148
|
+
except Exception: # pragma: no cover - fallback
|
|
1149
|
+
|
|
1150
|
+
def _problem_example(**kw): # type: ignore
|
|
1151
|
+
base = {
|
|
1152
|
+
"type": "about:blank",
|
|
1153
|
+
"title": "Error",
|
|
1154
|
+
"status": 500,
|
|
1155
|
+
"detail": "An error occurred.",
|
|
1156
|
+
"instance": "/request/trace",
|
|
1157
|
+
"code": "INTERNAL_ERROR",
|
|
1158
|
+
}
|
|
1159
|
+
base.update(kw)
|
|
1160
|
+
return base
|
|
1161
|
+
|
|
1162
|
+
def m(schema: dict) -> dict:
|
|
1163
|
+
schema = dict(schema)
|
|
1164
|
+
for _, _, op in _iter_ops(schema):
|
|
1165
|
+
resps = op.get("responses") or {}
|
|
1166
|
+
for code, resp in resps.items():
|
|
1167
|
+
if not isinstance(resp, dict):
|
|
1168
|
+
continue
|
|
1169
|
+
try:
|
|
1170
|
+
ic = int(code)
|
|
1171
|
+
except Exception:
|
|
1172
|
+
continue
|
|
1173
|
+
if ic < 400:
|
|
1174
|
+
continue
|
|
1175
|
+
content = resp.setdefault("content", {})
|
|
1176
|
+
# prefer problem+json but also set application/json if present
|
|
1177
|
+
for mt in ("application/problem+json", "application/json"):
|
|
1178
|
+
mt_obj = content.get(mt)
|
|
1179
|
+
if mt_obj is None:
|
|
1180
|
+
# Create a basic media type referencing Problem schema when appropriate
|
|
1181
|
+
if mt == "application/problem+json":
|
|
1182
|
+
mt_obj = {"schema": {"$ref": "#/components/schemas/Problem"}}
|
|
1183
|
+
content[mt] = mt_obj
|
|
1184
|
+
else:
|
|
1185
|
+
continue
|
|
1186
|
+
if not isinstance(mt_obj, dict):
|
|
1187
|
+
continue
|
|
1188
|
+
if "example" in mt_obj or "examples" in mt_obj:
|
|
1189
|
+
continue
|
|
1190
|
+
mt_obj["example"] = _problem_example(status=ic)
|
|
1191
|
+
return schema
|
|
1192
|
+
|
|
1193
|
+
return m
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
# --- NEW: attach default tags from first path segment when missing ---
|
|
1197
|
+
def attach_default_tags_mutator():
|
|
1198
|
+
"""If an operation has no tags, tag it by its first path segment."""
|
|
1199
|
+
|
|
1200
|
+
def m(schema: dict) -> dict:
|
|
1201
|
+
schema = dict(schema)
|
|
1202
|
+
for path, _method, op in _iter_ops(schema):
|
|
1203
|
+
tags = op.get("tags")
|
|
1204
|
+
if tags:
|
|
1205
|
+
continue
|
|
1206
|
+
seg = path.strip("/").split("/", 1)[0] or "root"
|
|
1207
|
+
op["tags"] = [seg]
|
|
1208
|
+
return schema
|
|
1209
|
+
|
|
1210
|
+
return m
|
|
1211
|
+
|
|
1212
|
+
|
|
1105
1213
|
def dedupe_tags_mutator():
|
|
1106
1214
|
def m(schema: dict) -> dict:
|
|
1107
1215
|
schema = dict(schema)
|
|
@@ -1429,6 +1537,9 @@ def setup_mutators(
|
|
|
1429
1537
|
ensure_media_type_schemas_mutator(),
|
|
1430
1538
|
ensure_examples_for_json_mutator(),
|
|
1431
1539
|
ensure_success_examples_mutator(),
|
|
1540
|
+
attach_default_tags_mutator(),
|
|
1541
|
+
attach_code_samples_mutator(),
|
|
1542
|
+
ensure_problem_examples_mutator(),
|
|
1432
1543
|
ensure_media_examples_mutator(),
|
|
1433
1544
|
scrub_invalid_object_examples_mutator(),
|
|
1434
1545
|
normalize_no_content_204_mutator(),
|
|
@@ -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,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import JSON, DateTime, Index, Numeric, String, UniqueConstraint, text
|
|
7
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
8
|
+
|
|
9
|
+
from svc_infra.db.sql.base import ModelBase
|
|
10
|
+
|
|
11
|
+
TENANT_ID_LEN = 64
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UsageEvent(ModelBase):
|
|
15
|
+
__tablename__ = "billing_usage_events"
|
|
16
|
+
|
|
17
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
18
|
+
tenant_id: Mapped[str] = mapped_column(String(TENANT_ID_LEN), index=True, nullable=False)
|
|
19
|
+
metric: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
|
20
|
+
amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
|
|
21
|
+
at_ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
22
|
+
idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
23
|
+
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
24
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
25
|
+
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__table_args__ = (
|
|
29
|
+
UniqueConstraint("tenant_id", "metric", "idempotency_key", name="uq_usage_idem"),
|
|
30
|
+
Index("ix_usage_tenant_metric_ts", "tenant_id", "metric", "at_ts"),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class UsageAggregate(ModelBase):
|
|
35
|
+
__tablename__ = "billing_usage_aggregates"
|
|
36
|
+
|
|
37
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
38
|
+
tenant_id: Mapped[str] = mapped_column(String(TENANT_ID_LEN), index=True, nullable=False)
|
|
39
|
+
metric: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
|
40
|
+
period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
41
|
+
granularity: Mapped[str] = mapped_column(String(8), nullable=False) # hour|day|month
|
|
42
|
+
total: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
|
|
43
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
44
|
+
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__table_args__ = (
|
|
48
|
+
UniqueConstraint("tenant_id", "metric", "period_start", "granularity", name="uq_usage_agg"),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Plan(ModelBase):
|
|
53
|
+
__tablename__ = "billing_plans"
|
|
54
|
+
|
|
55
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
56
|
+
key: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
|
57
|
+
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
58
|
+
description: Mapped[Optional[str]] = mapped_column(String(255))
|
|
59
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
60
|
+
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PlanEntitlement(ModelBase):
|
|
65
|
+
__tablename__ = "billing_plan_entitlements"
|
|
66
|
+
|
|
67
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
68
|
+
plan_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
|
69
|
+
key: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
70
|
+
limit_per_window: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
|
|
71
|
+
window: Mapped[str] = mapped_column(String(8), nullable=False) # day|month
|
|
72
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
73
|
+
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Subscription(ModelBase):
|
|
78
|
+
__tablename__ = "billing_subscriptions"
|
|
79
|
+
|
|
80
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
81
|
+
tenant_id: Mapped[str] = mapped_column(String(TENANT_ID_LEN), index=True, nullable=False)
|
|
82
|
+
plan_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
|
83
|
+
effective_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
84
|
+
ended_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
85
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
86
|
+
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Price(ModelBase):
|
|
91
|
+
__tablename__ = "billing_prices"
|
|
92
|
+
|
|
93
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
94
|
+
key: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
|
95
|
+
currency: Mapped[str] = mapped_column(String(8), nullable=False)
|
|
96
|
+
unit_amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False) # minor units
|
|
97
|
+
metric: Mapped[Optional[str]] = mapped_column(String(64)) # null for fixed recurring
|
|
98
|
+
recurring_interval: Mapped[Optional[str]] = mapped_column(String(8)) # month|year
|
|
99
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
100
|
+
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Invoice(ModelBase):
|
|
105
|
+
__tablename__ = "billing_invoices"
|
|
106
|
+
|
|
107
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
108
|
+
tenant_id: Mapped[str] = mapped_column(String(TENANT_ID_LEN), index=True, nullable=False)
|
|
109
|
+
period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
110
|
+
period_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
111
|
+
status: Mapped[str] = mapped_column(String(16), index=True, nullable=False)
|
|
112
|
+
total_amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
|
|
113
|
+
currency: Mapped[str] = mapped_column(String(8), nullable=False)
|
|
114
|
+
provider_invoice_id: Mapped[Optional[str]] = mapped_column(String(128), index=True)
|
|
115
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
116
|
+
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class InvoiceLine(ModelBase):
|
|
121
|
+
__tablename__ = "billing_invoice_lines"
|
|
122
|
+
|
|
123
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
124
|
+
invoice_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
|
125
|
+
price_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
|
|
126
|
+
metric: Mapped[Optional[str]] = mapped_column(String(64))
|
|
127
|
+
quantity: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
|
|
128
|
+
amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
|
|
129
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
130
|
+
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
131
|
+
)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Callable, Optional
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
from sqlalchemy.orm import Session
|
|
10
|
+
|
|
11
|
+
from .models import Invoice, InvoiceLine, UsageAggregate, UsageEvent
|
|
12
|
+
|
|
13
|
+
ProviderSyncHook = Callable[[Invoice, list[InvoiceLine]], None]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class BillingService:
|
|
18
|
+
session: Session
|
|
19
|
+
tenant_id: str
|
|
20
|
+
provider_sync: Optional[ProviderSyncHook] = None
|
|
21
|
+
|
|
22
|
+
def record_usage(
|
|
23
|
+
self, *, metric: str, amount: int, at: datetime, idempotency_key: str, metadata: dict | None
|
|
24
|
+
) -> str:
|
|
25
|
+
# Ensure UTC
|
|
26
|
+
if at.tzinfo is None:
|
|
27
|
+
at = at.replace(tzinfo=timezone.utc)
|
|
28
|
+
evt = UsageEvent(
|
|
29
|
+
id=str(uuid.uuid4()),
|
|
30
|
+
tenant_id=self.tenant_id,
|
|
31
|
+
metric=metric,
|
|
32
|
+
amount=amount,
|
|
33
|
+
at_ts=at,
|
|
34
|
+
idempotency_key=idempotency_key,
|
|
35
|
+
metadata_json=metadata or {},
|
|
36
|
+
)
|
|
37
|
+
self.session.add(evt)
|
|
38
|
+
self.session.flush()
|
|
39
|
+
return evt.id
|
|
40
|
+
|
|
41
|
+
def aggregate_daily(self, *, metric: str, day_start: datetime) -> None:
|
|
42
|
+
# Compute [day_start, day_start+1d)
|
|
43
|
+
next_day = day_start.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
|
|
44
|
+
total = 0
|
|
45
|
+
rows = self.session.execute(
|
|
46
|
+
select(UsageEvent).where(
|
|
47
|
+
UsageEvent.tenant_id == self.tenant_id,
|
|
48
|
+
UsageEvent.metric == metric,
|
|
49
|
+
UsageEvent.at_ts >= day_start,
|
|
50
|
+
UsageEvent.at_ts < next_day,
|
|
51
|
+
)
|
|
52
|
+
).scalars()
|
|
53
|
+
for r in rows:
|
|
54
|
+
total += int(r.amount)
|
|
55
|
+
# upsert aggregate
|
|
56
|
+
agg = self.session.execute(
|
|
57
|
+
select(UsageAggregate).where(
|
|
58
|
+
UsageAggregate.tenant_id == self.tenant_id,
|
|
59
|
+
UsageAggregate.metric == metric,
|
|
60
|
+
UsageAggregate.period_start == day_start,
|
|
61
|
+
UsageAggregate.granularity == "day",
|
|
62
|
+
)
|
|
63
|
+
).scalar_one_or_none()
|
|
64
|
+
if agg:
|
|
65
|
+
agg.total = total
|
|
66
|
+
else:
|
|
67
|
+
self.session.add(
|
|
68
|
+
UsageAggregate(
|
|
69
|
+
id=str(uuid.uuid4()),
|
|
70
|
+
tenant_id=self.tenant_id,
|
|
71
|
+
metric=metric,
|
|
72
|
+
period_start=day_start,
|
|
73
|
+
granularity="day",
|
|
74
|
+
total=total,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def generate_monthly_invoice(
|
|
79
|
+
self, *, period_start: datetime, period_end: datetime, currency: str
|
|
80
|
+
) -> str:
|
|
81
|
+
# Minimal: sum all daily aggregates and produce one line
|
|
82
|
+
total = 0
|
|
83
|
+
rows = self.session.execute(
|
|
84
|
+
select(UsageAggregate).where(
|
|
85
|
+
UsageAggregate.tenant_id == self.tenant_id,
|
|
86
|
+
UsageAggregate.period_start >= period_start,
|
|
87
|
+
UsageAggregate.period_start < period_end,
|
|
88
|
+
UsageAggregate.granularity == "day",
|
|
89
|
+
)
|
|
90
|
+
).scalars()
|
|
91
|
+
for r in rows:
|
|
92
|
+
total += int(r.total)
|
|
93
|
+
inv = Invoice(
|
|
94
|
+
id=str(uuid.uuid4()),
|
|
95
|
+
tenant_id=self.tenant_id,
|
|
96
|
+
period_start=period_start,
|
|
97
|
+
period_end=period_end,
|
|
98
|
+
status="created",
|
|
99
|
+
total_amount=total,
|
|
100
|
+
currency=currency,
|
|
101
|
+
)
|
|
102
|
+
self.session.add(inv)
|
|
103
|
+
self.session.flush()
|
|
104
|
+
line = InvoiceLine(
|
|
105
|
+
id=str(uuid.uuid4()),
|
|
106
|
+
invoice_id=inv.id,
|
|
107
|
+
price_id=None,
|
|
108
|
+
metric=None,
|
|
109
|
+
quantity=1,
|
|
110
|
+
amount=total,
|
|
111
|
+
)
|
|
112
|
+
self.session.add(line)
|
|
113
|
+
if self.provider_sync:
|
|
114
|
+
self.provider_sync(inv, [line])
|
|
115
|
+
return inv.id
|
svc_infra/cli/__init__.py
CHANGED
|
@@ -6,9 +6,11 @@ from svc_infra.cli.cmds import (
|
|
|
6
6
|
_HELP,
|
|
7
7
|
jobs_app,
|
|
8
8
|
register_alembic,
|
|
9
|
+
register_dx,
|
|
9
10
|
register_mongo,
|
|
10
11
|
register_mongo_scaffold,
|
|
11
12
|
register_obs,
|
|
13
|
+
register_sdk,
|
|
12
14
|
register_sql_export,
|
|
13
15
|
register_sql_scaffold,
|
|
14
16
|
)
|
|
@@ -29,9 +31,15 @@ register_mongo_scaffold(app)
|
|
|
29
31
|
# -- observability commands ---
|
|
30
32
|
register_obs(app)
|
|
31
33
|
|
|
34
|
+
# -- dx commands ---
|
|
35
|
+
register_dx(app)
|
|
36
|
+
|
|
32
37
|
# -- jobs commands ---
|
|
33
38
|
app.add_typer(jobs_app, name="jobs")
|
|
34
39
|
|
|
40
|
+
# -- sdk commands ---
|
|
41
|
+
register_sdk(app)
|
|
42
|
+
|
|
35
43
|
|
|
36
44
|
def main():
|
|
37
45
|
app()
|
svc_infra/cli/cmds/__init__.py
CHANGED
|
@@ -5,8 +5,10 @@ from svc_infra.cli.cmds.db.nosql.mongo.mongo_scaffold_cmds import (
|
|
|
5
5
|
from svc_infra.cli.cmds.db.sql.alembic_cmds import register as register_alembic
|
|
6
6
|
from svc_infra.cli.cmds.db.sql.sql_export_cmds import register as register_sql_export
|
|
7
7
|
from svc_infra.cli.cmds.db.sql.sql_scaffold_cmds import register as register_sql_scaffold
|
|
8
|
+
from svc_infra.cli.cmds.dx import register_dx
|
|
8
9
|
from svc_infra.cli.cmds.jobs.jobs_cmds import app as jobs_app
|
|
9
10
|
from svc_infra.cli.cmds.obs.obs_cmds import register as register_obs
|
|
11
|
+
from svc_infra.cli.cmds.sdk.sdk_cmds import register as register_sdk
|
|
10
12
|
|
|
11
13
|
from .help import _HELP
|
|
12
14
|
|
|
@@ -18,5 +20,7 @@ __all__ = [
|
|
|
18
20
|
"register_mongo_scaffold",
|
|
19
21
|
"register_obs",
|
|
20
22
|
"jobs_app",
|
|
23
|
+
"register_sdk",
|
|
24
|
+
"register_dx",
|
|
21
25
|
"_HELP",
|
|
22
26
|
]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from svc_infra.dx.changelog import Commit, generate_release_section
|
|
9
|
+
from svc_infra.dx.checks import check_migrations_up_to_date, check_openapi_problem_schema
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("openapi")
|
|
15
|
+
def cmd_openapi(path: str = typer.Argument(..., help="Path to OpenAPI JSON")):
|
|
16
|
+
try:
|
|
17
|
+
check_openapi_problem_schema(path=path)
|
|
18
|
+
except Exception as e: # noqa: BLE001
|
|
19
|
+
typer.secho(f"OpenAPI check failed: {e}", fg=typer.colors.RED, err=True)
|
|
20
|
+
raise typer.Exit(2)
|
|
21
|
+
typer.secho("OpenAPI checks passed", fg=typer.colors.GREEN)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command("migrations")
|
|
25
|
+
def cmd_migrations(project_root: str = typer.Option(".", help="Project root")):
|
|
26
|
+
try:
|
|
27
|
+
check_migrations_up_to_date(project_root=project_root)
|
|
28
|
+
except Exception as e: # noqa: BLE001
|
|
29
|
+
typer.secho(f"Migrations check failed: {e}", fg=typer.colors.RED, err=True)
|
|
30
|
+
raise typer.Exit(2)
|
|
31
|
+
typer.secho("Migrations checks passed", fg=typer.colors.GREEN)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command("changelog")
|
|
35
|
+
def cmd_changelog(
|
|
36
|
+
version: str = typer.Argument(..., help="Version (e.g., 0.1.604)"),
|
|
37
|
+
commits_file: str = typer.Option(None, help="Path to JSON lines of commits (sha,subject)"),
|
|
38
|
+
):
|
|
39
|
+
"""Generate a changelog section from commit messages.
|
|
40
|
+
|
|
41
|
+
Expects Conventional Commits style for best grouping; falls back to Other.
|
|
42
|
+
If commits_file is omitted, prints an example format.
|
|
43
|
+
"""
|
|
44
|
+
import json
|
|
45
|
+
import sys
|
|
46
|
+
|
|
47
|
+
if not commits_file:
|
|
48
|
+
typer.echo(
|
|
49
|
+
'# Provide --commits-file with JSONL: {"sha": "<sha>", "subject": "feat: ..."}',
|
|
50
|
+
err=True,
|
|
51
|
+
)
|
|
52
|
+
raise typer.Exit(2)
|
|
53
|
+
rows = [
|
|
54
|
+
json.loads(line) for line in Path(commits_file).read_text().splitlines() if line.strip()
|
|
55
|
+
]
|
|
56
|
+
commits = [Commit(sha=r["sha"], subject=r["subject"]) for r in rows]
|
|
57
|
+
out = generate_release_section(version=version, commits=commits)
|
|
58
|
+
sys.stdout.write(out)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command("ci")
|
|
62
|
+
def cmd_ci(
|
|
63
|
+
run: bool = typer.Option(False, help="Execute the steps; default just prints a plan"),
|
|
64
|
+
openapi: str | None = typer.Option(None, help="Path to OpenAPI JSON to lint"),
|
|
65
|
+
project_root: str = typer.Option(".", help="Project root for migrations check"),
|
|
66
|
+
):
|
|
67
|
+
"""Print (or run) the CI steps locally to mirror the workflow."""
|
|
68
|
+
steps: list[list[str]] = []
|
|
69
|
+
# Lint, typecheck, tests
|
|
70
|
+
steps.append(["flake8", "--select=E,F"]) # mirrors CI
|
|
71
|
+
steps.append(["mypy", "src"]) # mirrors CI
|
|
72
|
+
if openapi:
|
|
73
|
+
steps.append([sys.executable, "-m", "svc_infra.cli", "dx", "openapi", openapi])
|
|
74
|
+
steps.append(
|
|
75
|
+
[sys.executable, "-m", "svc_infra.cli", "dx", "migrations", "--project-root", project_root]
|
|
76
|
+
)
|
|
77
|
+
steps.append(["pytest", "-q", "-W", "error"]) # mirrors CI
|
|
78
|
+
|
|
79
|
+
if not run:
|
|
80
|
+
typer.echo("CI dry-run plan:")
|
|
81
|
+
for cmd in steps:
|
|
82
|
+
typer.echo(" $ " + " ".join(cmd))
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
import subprocess
|
|
86
|
+
|
|
87
|
+
for cmd in steps:
|
|
88
|
+
typer.echo("Running: " + " ".join(cmd))
|
|
89
|
+
res = subprocess.run(cmd)
|
|
90
|
+
if res.returncode != 0:
|
|
91
|
+
raise typer.Exit(res.returncode)
|
|
92
|
+
typer.echo("All CI steps passed")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main(): # pragma: no cover - CLI entrypoint
|
|
96
|
+
app()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
__all__ = ["main", "app"]
|
|
File without changes
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False, help="Generate SDKs from OpenAPI.")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _echo(cmd: list[str]):
|
|
11
|
+
typer.echo("$ " + " ".join(cmd))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_bool(val: str | bool | None, default: bool = True) -> bool:
|
|
15
|
+
if isinstance(val, bool):
|
|
16
|
+
return val
|
|
17
|
+
if val is None:
|
|
18
|
+
return default
|
|
19
|
+
s = str(val).strip().lower()
|
|
20
|
+
if s in {"1", "true", "yes", "y"}:
|
|
21
|
+
return True
|
|
22
|
+
if s in {"0", "false", "no", "n"}:
|
|
23
|
+
return False
|
|
24
|
+
return default
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("ts")
|
|
28
|
+
def sdk_ts(
|
|
29
|
+
openapi: str = typer.Argument(..., help="Path to OpenAPI JSON"),
|
|
30
|
+
outdir: str = typer.Option("sdk-ts", help="Output directory"),
|
|
31
|
+
dry_run: str = typer.Option("true", help="Print commands instead of running (true/false)"),
|
|
32
|
+
):
|
|
33
|
+
"""Generate a TypeScript SDK (openapi-typescript-codegen as default)."""
|
|
34
|
+
cmd = [
|
|
35
|
+
"npx",
|
|
36
|
+
"openapi-typescript-codegen",
|
|
37
|
+
"--input",
|
|
38
|
+
openapi,
|
|
39
|
+
"--output",
|
|
40
|
+
outdir,
|
|
41
|
+
]
|
|
42
|
+
if _parse_bool(dry_run, True):
|
|
43
|
+
_echo(cmd)
|
|
44
|
+
return
|
|
45
|
+
subprocess.check_call(cmd)
|
|
46
|
+
typer.secho(f"TS SDK generated → {outdir}", fg=typer.colors.GREEN)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command("py")
|
|
50
|
+
def sdk_py(
|
|
51
|
+
openapi: str = typer.Argument(..., help="Path to OpenAPI JSON"),
|
|
52
|
+
outdir: str = typer.Option("sdk-py", help="Output directory"),
|
|
53
|
+
package_name: str = typer.Option("client_sdk", help="Python package name"),
|
|
54
|
+
dry_run: str = typer.Option("true", help="Print commands instead of running (true/false)"),
|
|
55
|
+
):
|
|
56
|
+
"""Generate a Python SDK via openapi-generator-cli with "python" generator."""
|
|
57
|
+
cmd = [
|
|
58
|
+
"npx",
|
|
59
|
+
"-y",
|
|
60
|
+
"@openapitools/openapi-generator-cli",
|
|
61
|
+
"generate",
|
|
62
|
+
"-i",
|
|
63
|
+
openapi,
|
|
64
|
+
"-g",
|
|
65
|
+
"python",
|
|
66
|
+
"-o",
|
|
67
|
+
outdir,
|
|
68
|
+
"--additional-properties",
|
|
69
|
+
f"packageName={package_name}",
|
|
70
|
+
]
|
|
71
|
+
if _parse_bool(dry_run, True):
|
|
72
|
+
_echo(cmd)
|
|
73
|
+
return
|
|
74
|
+
subprocess.check_call(cmd)
|
|
75
|
+
typer.secho(f"Python SDK generated → {outdir}", fg=typer.colors.GREEN)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command("postman")
|
|
79
|
+
def sdk_postman(
|
|
80
|
+
openapi: str = typer.Argument(..., help="Path to OpenAPI JSON"),
|
|
81
|
+
out: str = typer.Option("postman_collection.json", help="Output Postman collection"),
|
|
82
|
+
dry_run: str = typer.Option("true", help="Print commands instead of running (true/false)"),
|
|
83
|
+
):
|
|
84
|
+
"""Convert OpenAPI to a Postman collection via openapi-to-postmanv2."""
|
|
85
|
+
cmd = [
|
|
86
|
+
"npx",
|
|
87
|
+
"-y",
|
|
88
|
+
"openapi-to-postmanv2",
|
|
89
|
+
"-s",
|
|
90
|
+
openapi,
|
|
91
|
+
"-o",
|
|
92
|
+
out,
|
|
93
|
+
]
|
|
94
|
+
if _parse_bool(dry_run, True):
|
|
95
|
+
_echo(cmd)
|
|
96
|
+
return
|
|
97
|
+
subprocess.check_call(cmd)
|
|
98
|
+
typer.secho(f"Postman collection generated → {out}", fg=typer.colors.GREEN)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def register(root: typer.Typer):
|
|
102
|
+
root.add_typer(app, name="sdk")
|
|
@@ -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
|
+
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: svc-infra
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.605
|
|
4
4
|
Summary: Infrastructure for building and deploying prod-ready services
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: fastapi,sqlalchemy,alembic,auth,infra,async,pydantic
|
|
@@ -94,6 +94,7 @@ svc-infra packages the shared building blocks we use to ship production FastAPI
|
|
|
94
94
|
| Ops | Probes, breaker, SLOs & dashboards | [SLOs & Ops](docs/ops.md) |
|
|
95
95
|
| Webhooks | Subscription store, signing, retry worker | [Webhooks framework](docs/webhooks.md) |
|
|
96
96
|
| Security | Password policy, lockout, signed cookies, headers | [Security hardening](docs/security.md) |
|
|
97
|
+
| Contributing | Dev setup and quality gates | [Contributing guide](docs/contributing.md) |
|
|
97
98
|
| Data Lifecycle | Fixtures, retention, erasure, backups | [Data lifecycle](docs/data-lifecycle.md) |
|
|
98
99
|
|
|
99
100
|
## Minimal FastAPI bootstrap
|
|
@@ -56,7 +56,7 @@ svc_infra/api/fastapi/db/sql/session.py,sha256=DUBqKTRJAX4fqRz9B-w9eD9SpzZ8EUS86
|
|
|
56
56
|
svc_infra/api/fastapi/db/sql/users.py,sha256=68HGJgYVTEjKJm4-DPPC8-6nwXJoCukmgrYIIOHEUjs,5346
|
|
57
57
|
svc_infra/api/fastapi/dependencies/ratelimit.py,sha256=DiOC-MJfqTtSydM6RAaeAsiXXL_6oZQoBLvRSpdWzs4,3794
|
|
58
58
|
svc_infra/api/fastapi/docs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
59
|
-
svc_infra/api/fastapi/docs/add.py,sha256=
|
|
59
|
+
svc_infra/api/fastapi/docs/add.py,sha256=41asodVG_0Iokl9dpcGGmFO8z5ak3gNRmsmx6rNHnaM,5689
|
|
60
60
|
svc_infra/api/fastapi/docs/landing.py,sha256=5JqJYCxQDCWy-BeDLfkv7OBlzWQKSGWUCYXQ51hojG8,4627
|
|
61
61
|
svc_infra/api/fastapi/docs/scoped.py,sha256=AuN35Op-9fUvHQCLOBtRjd5eWSpB5C9EAW_7-Boxmfo,7540
|
|
62
62
|
svc_infra/api/fastapi/dual/__init__.py,sha256=scHLcNFkGbgX_R21V702xnAv6GMCkQ4n7yUtNDNgliM,552
|
|
@@ -88,7 +88,7 @@ svc_infra/api/fastapi/openapi/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5N
|
|
|
88
88
|
svc_infra/api/fastapi/openapi/apply.py,sha256=VAwRfcYSLCSKIpO1dp9okG1MXvkZuciU41jrSSuvUpI,1697
|
|
89
89
|
svc_infra/api/fastapi/openapi/conventions.py,sha256=e6gUsFyfEGvz3KkUimjAWMfF7_fonMJ3IoGvQZjpvfs,7171
|
|
90
90
|
svc_infra/api/fastapi/openapi/models.py,sha256=MmXMpPZQjZygajfIHaL4_3q0zluPtpRevsSyOIiE83Y,2562
|
|
91
|
-
svc_infra/api/fastapi/openapi/mutators.py,sha256=
|
|
91
|
+
svc_infra/api/fastapi/openapi/mutators.py,sha256=0bSKVPw-lDA3L_vaPDhcM0rC7xKS-XNTzgR8hxxoM18,55266
|
|
92
92
|
svc_infra/api/fastapi/openapi/pipeline.py,sha256=GAf-qzwmWlYbrAlPirr8w89fEO4-kFrhCoeMj-7mE44,646
|
|
93
93
|
svc_infra/api/fastapi/openapi/responses.py,sha256=pBUoJd0lltBkQBJACS1Zd1wd975gbw6dYyMEqyueRuw,1093
|
|
94
94
|
svc_infra/api/fastapi/openapi/security.py,sha256=U78KMwgc7FilFPLbIE2f6xrp74hq6TDFXpUMGRyK_bg,1248
|
|
@@ -112,6 +112,9 @@ svc_infra/app/logging/add.py,sha256=D5wYNYqnhzsh3MVWXzN6ORY5I0cGw5ZdRbEH3ljpi50,
|
|
|
112
112
|
svc_infra/app/logging/filter.py,sha256=lPHUoCFoTP5AfwORCvwVe5z2Kltb0MwAXANwrcBAIdE,1992
|
|
113
113
|
svc_infra/app/logging/formats.py,sha256=65eHUMWj7aD1RG7lCYIBSkFa_B748TutrZsta0OS_-8,4657
|
|
114
114
|
svc_infra/app/root.py,sha256=344EWBMJCduwzJ1BBo0yGAu15TkryuvOW4qBZ6Gk-8w,1635
|
|
115
|
+
svc_infra/billing/__init__.py,sha256=AdVxgBWibsz0xWk-Z91B7HecA-EhPMSRrXWIYPBgtMA,365
|
|
116
|
+
svc_infra/billing/models.py,sha256=bnCGPKfnK__6x0f0bwKYQsG2GwXjJFi3YRXnq5JYs7c,6083
|
|
117
|
+
svc_infra/billing/service.py,sha256=3SDpPA3NF2lMYiOP4U99sgXpZAXaauexBfZQmYE2kvU,3727
|
|
115
118
|
svc_infra/cache/README.md,sha256=ZgIpmE0UVlGktp2nXUYv6FKJATCdkR_01v-GGxHN6Ao,10795
|
|
116
119
|
svc_infra/cache/__init__.py,sha256=Fz3NS81jrY5sLikRhITCeHDT4MlOLcbMed5EjVecSAg,956
|
|
117
120
|
svc_infra/cache/backend.py,sha256=Vrza_8oi1AYUG1KdFmxg8S11WB4rKpKI246fZn4pdWs,4233
|
|
@@ -123,9 +126,9 @@ svc_infra/cache/resources.py,sha256=BhvPAZvCQ-fitUdniGEOOE4g1ZvljdCA_R5pR8WfJz4,
|
|
|
123
126
|
svc_infra/cache/tags.py,sha256=9URw4BRlnb4QFAYpDI36fMms6642xq4TeV9jqsEjzE8,2625
|
|
124
127
|
svc_infra/cache/ttl.py,sha256=_lWvNx1CTE4RcFEOUYkADd7_k4I13SLmtK0AMRUq2OM,1945
|
|
125
128
|
svc_infra/cache/utils.py,sha256=-LWr5IiJCNm3pwaoeCVlxNknnO2ChNKFcAGlFU98kjg,4856
|
|
126
|
-
svc_infra/cli/__init__.py,sha256=
|
|
129
|
+
svc_infra/cli/__init__.py,sha256=Bdmx-qMHwPINg1S6nIsU7f4Qfrg8QmDiIQ11BblEuL4,864
|
|
127
130
|
svc_infra/cli/__main__.py,sha256=5BjNuyet8AY-POwoF5rGt722rHQ7tJ0Vf0UFUfzzi-I,58
|
|
128
|
-
svc_infra/cli/cmds/__init__.py,sha256=
|
|
131
|
+
svc_infra/cli/cmds/__init__.py,sha256=MqXFdhTyLHua-c0bJGm0O5kFKsS-TXrA48PJy5u5zFU,958
|
|
129
132
|
svc_infra/cli/cmds/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
130
133
|
svc_infra/cli/cmds/db/nosql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
131
134
|
svc_infra/cli/cmds/db/nosql/mongo/README.md,sha256=0u3XLeoBd0XQzXwwfEiFISMIij11TJ9iOGzrysBvsFk,1788
|
|
@@ -136,11 +139,15 @@ svc_infra/cli/cmds/db/sql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJW
|
|
|
136
139
|
svc_infra/cli/cmds/db/sql/alembic_cmds.py,sha256=HfnLsLJMug6llfitiwUvwdrXzcjRqrn-BVyJNcQDLFA,8519
|
|
137
140
|
svc_infra/cli/cmds/db/sql/sql_export_cmds.py,sha256=6MxoQO-9upoXg0cl1RHIqz96yXFVGidiBYp_ewhB0E0,2700
|
|
138
141
|
svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py,sha256=eNTCqHXOxgl9H3WTbGVn9BHXYwCpjIEJsDqhEFdrYMM,4613
|
|
142
|
+
svc_infra/cli/cmds/dx/__init__.py,sha256=wQtl3-kOgoESlpVkjl3YFtqkOnQSIvVsOdutiaZFejM,197
|
|
143
|
+
svc_infra/cli/cmds/dx/dx_cmds.py,sha256=XTKUJzS3UIYn6h3CHzDEWKYJaWn0TzGiUCq3OeW27E0,3326
|
|
139
144
|
svc_infra/cli/cmds/help.py,sha256=wGfZFMYaR2ZPwW2JwKDU7M3m4AtdCd8GRQ412AmEBUM,758
|
|
140
145
|
svc_infra/cli/cmds/jobs/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
|
|
141
146
|
svc_infra/cli/cmds/jobs/jobs_cmds.py,sha256=l-w5GuR82GWR_F1CA7WPYAM895XBD8TQj_hZ6retBv0,1252
|
|
142
147
|
svc_infra/cli/cmds/obs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
143
148
|
svc_infra/cli/cmds/obs/obs_cmds.py,sha256=fltUZu5fcnZdl0_JPJBIxIaA1Xqpw1BXE-SWBP-PRuY,6485
|
|
149
|
+
svc_infra/cli/cmds/sdk/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
150
|
+
svc_infra/cli/cmds/sdk/sdk_cmds.py,sha256=xzEbhA-L5bXMxf-DFzYXkdITfC4ua1Lt8I9x6PoEax0,2886
|
|
144
151
|
svc_infra/cli/foundation/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
145
152
|
svc_infra/cli/foundation/runner.py,sha256=RbfjKwb3aHk1Y0MYU8xMpKRpIqRVMVr8GuL2EDZ6n38,1862
|
|
146
153
|
svc_infra/cli/foundation/typer_bootstrap.py,sha256=KapgH1R-qON9FuYH1KYlVx_5sJvjmAGl25pB61XCpm4,985
|
|
@@ -206,6 +213,8 @@ svc_infra/db/sql/utils.py,sha256=nzuDcDhnVNehx5Y9BZLgxw8fvpfYbxTfXQsgnznVf4w,328
|
|
|
206
213
|
svc_infra/db/sql/versioning.py,sha256=okZu2ad5RAFXNLXJgGpcQvZ5bc6gPjRWzwiBT0rEJJw,400
|
|
207
214
|
svc_infra/db/utils.py,sha256=aTD49VJSEu319kIWJ1uijUoP51co4lNJ3S0_tvuyGio,802
|
|
208
215
|
svc_infra/dx/add.py,sha256=FAnLGP0BPm_q_VCEcpUwfj-b0mEse988chh9DHeS7GU,1474
|
|
216
|
+
svc_infra/dx/changelog.py,sha256=9SD29ZzKzbGTA6kHQXiPLtb7uueL1wrRiiLE2qMzz8o,1941
|
|
217
|
+
svc_infra/dx/checks.py,sha256=R6YqRvpKPr9zQgif4QVx2_Zl4s9YjehSkAvwlxK46lI,2267
|
|
209
218
|
svc_infra/jobs/builtins/outbox_processor.py,sha256=VZoehNyjdaV_MmV74WMcbZR6z9E3VFMtZC-pxEwK0x0,1247
|
|
210
219
|
svc_infra/jobs/builtins/webhook_delivery.py,sha256=z_cl6YKwnduGjGaB8ZoUpKhFcEAhUZqqBma8v2FO1so,2982
|
|
211
220
|
svc_infra/jobs/easy.py,sha256=eix-OxWeE3vdkY3GGNoYM0GAyOxc928SpiSzMkr9k0A,977
|
|
@@ -283,7 +292,7 @@ svc_infra/webhooks/fastapi.py,sha256=BCNvGNxukf6dC2a4i-6en-PrjBGV19YvCWOot5lXWsA
|
|
|
283
292
|
svc_infra/webhooks/router.py,sha256=6JvAVPMEth_xxHX-IsIOcyMgHX7g1H0OVxVXKLuMp9w,1596
|
|
284
293
|
svc_infra/webhooks/service.py,sha256=hWgiJRXKBwKunJOx91C7EcLUkotDtD3Xp0RT6vj2IC0,1797
|
|
285
294
|
svc_infra/webhooks/signing.py,sha256=NCwdZzmravUe7HVIK_uXK0qqf12FG-_MVsgPvOw6lsM,784
|
|
286
|
-
svc_infra-0.1.
|
|
287
|
-
svc_infra-0.1.
|
|
288
|
-
svc_infra-0.1.
|
|
289
|
-
svc_infra-0.1.
|
|
295
|
+
svc_infra-0.1.605.dist-info/METADATA,sha256=1Ng73YrdY7IbUtBEQ4PYWf2n-0qaVnZnrio6woieZ0Q,8106
|
|
296
|
+
svc_infra-0.1.605.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
|
297
|
+
svc_infra-0.1.605.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
|
|
298
|
+
svc_infra-0.1.605.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|