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.

@@ -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
- return get_swagger_ui_html(openapi_url=openapi_url, title="API Docs")
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
- return get_redoc_html(openapi_url=openapi_url, title="API ReDoc")
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()
@@ -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,12 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from .dx_cmds import app as dx_app
6
+
7
+
8
+ def register_dx(root: typer.Typer) -> None:
9
+ root.add_typer(dx_app, name="dx")
10
+
11
+
12
+ __all__ = ["register_dx"]
@@ -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.604
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=Ytk4iqWrNqPg3-SELJ5OUNuTieltpTBmIFOPKGnHVag,2563
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=KcWnJ3dsn_VdBG-x0ytddf_vwJ6u-84O9V2vPxQ5Nqk,51307
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=rWsQDwgYVZifCP0tgB8DKm0uPSX56qd9ipiYsVwD-eM,749
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=wlWXa2PPxfmARkqEDQODhjCBrGJoti9eX8eE1IruX7w,804
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.604.dist-info/METADATA,sha256=MWq8dgYUohrtSAQkZXS6YO0oeH29rMtGqHVlQSOtX9Q,8014
287
- svc_infra-0.1.604.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
288
- svc_infra-0.1.604.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
289
- svc_infra-0.1.604.dist-info/RECORD,,
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,,