svc-infra 0.1.600__py3-none-any.whl → 0.1.602__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/db/sql/add.py +32 -13
- svc_infra/api/fastapi/db/sql/crud_router.py +153 -9
- svc_infra/api/fastapi/dependencies/ratelimit.py +57 -7
- svc_infra/api/fastapi/docs/add.py +60 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +41 -1
- svc_infra/api/fastapi/ops/add.py +65 -0
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/cli/__init__.py +2 -0
- svc_infra/cli/cmds/__init__.py +2 -0
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +82 -0
- svc_infra/data/add.py +59 -0
- svc_infra/db/sql/repository.py +46 -9
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/dx/add.py +63 -0
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.602.dist-info}/METADATA +1 -1
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.602.dist-info}/RECORD +20 -12
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.602.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.602.dist-info}/entry_points.txt +0 -0
|
@@ -10,7 +10,7 @@ from svc_infra.db.sql.management import make_crud_schemas
|
|
|
10
10
|
from svc_infra.db.sql.repository import SqlRepository
|
|
11
11
|
from svc_infra.db.sql.resource import SqlResource
|
|
12
12
|
|
|
13
|
-
from .crud_router import make_crud_router_plus_sql
|
|
13
|
+
from .crud_router import make_crud_router_plus_sql, make_tenant_crud_router_plus_sql
|
|
14
14
|
from .health import _make_db_health_router
|
|
15
15
|
from .session import dispose_session, initialize_session
|
|
16
16
|
|
|
@@ -37,18 +37,37 @@ def add_sql_resources(app: FastAPI, resources: Sequence[SqlResource]) -> None:
|
|
|
37
37
|
update_name=r.update_name,
|
|
38
38
|
)
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
40
|
+
if r.tenant_field:
|
|
41
|
+
# wrap service factory/instance through tenant router
|
|
42
|
+
def _factory():
|
|
43
|
+
return svc
|
|
44
|
+
|
|
45
|
+
router = make_tenant_crud_router_plus_sql(
|
|
46
|
+
model=r.model,
|
|
47
|
+
service_factory=_factory,
|
|
48
|
+
read_schema=Read,
|
|
49
|
+
create_schema=Create,
|
|
50
|
+
update_schema=Update,
|
|
51
|
+
prefix=r.prefix,
|
|
52
|
+
tenant_field=r.tenant_field,
|
|
53
|
+
tags=r.tags,
|
|
54
|
+
search_fields=r.search_fields,
|
|
55
|
+
default_ordering=r.ordering_default,
|
|
56
|
+
allowed_order_fields=r.allowed_order_fields,
|
|
57
|
+
)
|
|
58
|
+
else:
|
|
59
|
+
router = make_crud_router_plus_sql(
|
|
60
|
+
model=r.model,
|
|
61
|
+
service=svc,
|
|
62
|
+
read_schema=Read,
|
|
63
|
+
create_schema=Create,
|
|
64
|
+
update_schema=Update,
|
|
65
|
+
prefix=r.prefix,
|
|
66
|
+
tags=r.tags,
|
|
67
|
+
search_fields=r.search_fields,
|
|
68
|
+
default_ordering=r.ordering_default,
|
|
69
|
+
allowed_order_fields=r.allowed_order_fields,
|
|
70
|
+
)
|
|
52
71
|
app.include_router(router)
|
|
53
72
|
|
|
54
73
|
|
|
@@ -15,7 +15,9 @@ from svc_infra.api.fastapi.db.http import (
|
|
|
15
15
|
)
|
|
16
16
|
from svc_infra.api.fastapi.dual.public import public_router
|
|
17
17
|
from svc_infra.db.sql.service import SqlService
|
|
18
|
+
from svc_infra.db.sql.tenant import TenantSqlService
|
|
18
19
|
|
|
20
|
+
from ...tenancy.context import TenantId
|
|
19
21
|
from .session import SqlSessionDep
|
|
20
22
|
|
|
21
23
|
CreateModel = TypeVar("CreateModel", bound=BaseModel)
|
|
@@ -59,7 +61,7 @@ def make_crud_router_plus_sql(
|
|
|
59
61
|
# -------- LIST --------
|
|
60
62
|
@router.get(
|
|
61
63
|
"",
|
|
62
|
-
response_model=cast(Any, Page[
|
|
64
|
+
response_model=cast(Any, Page[Any]),
|
|
63
65
|
description=f"List items of type {model.__name__}",
|
|
64
66
|
)
|
|
65
67
|
async def list_items(
|
|
@@ -92,7 +94,7 @@ def make_crud_router_plus_sql(
|
|
|
92
94
|
# -------- GET by id --------
|
|
93
95
|
@router.get(
|
|
94
96
|
"/{item_id}",
|
|
95
|
-
response_model=cast(Any,
|
|
97
|
+
response_model=cast(Any, Any),
|
|
96
98
|
description=f"Get item of type {model.__name__}",
|
|
97
99
|
)
|
|
98
100
|
async def get_item(item_id: Any, session: SqlSessionDep): # type: ignore[name-defined]
|
|
@@ -104,29 +106,39 @@ def make_crud_router_plus_sql(
|
|
|
104
106
|
# -------- CREATE --------
|
|
105
107
|
@router.post(
|
|
106
108
|
"",
|
|
107
|
-
response_model=cast(Any,
|
|
109
|
+
response_model=cast(Any, Any),
|
|
108
110
|
status_code=201,
|
|
109
111
|
description=f"Create item of type {model.__name__}",
|
|
110
112
|
)
|
|
111
113
|
async def create_item(
|
|
112
114
|
session: SqlSessionDep, # type: ignore[name-defined]
|
|
113
|
-
payload:
|
|
115
|
+
payload: Any = Body(...),
|
|
114
116
|
):
|
|
115
|
-
|
|
117
|
+
if isinstance(payload, BaseModel):
|
|
118
|
+
data = payload.model_dump(exclude_unset=True)
|
|
119
|
+
elif isinstance(payload, dict):
|
|
120
|
+
data = payload
|
|
121
|
+
else:
|
|
122
|
+
raise HTTPException(422, "invalid_payload")
|
|
116
123
|
return await service.create(session, data)
|
|
117
124
|
|
|
118
125
|
# -------- UPDATE --------
|
|
119
126
|
@router.patch(
|
|
120
127
|
"/{item_id}",
|
|
121
|
-
response_model=cast(Any,
|
|
128
|
+
response_model=cast(Any, Any),
|
|
122
129
|
description=f"Update item of type {model.__name__}",
|
|
123
130
|
)
|
|
124
131
|
async def update_item(
|
|
125
132
|
item_id: Any,
|
|
126
133
|
session: SqlSessionDep, # type: ignore[name-defined]
|
|
127
|
-
payload:
|
|
134
|
+
payload: Any = Body(...),
|
|
128
135
|
):
|
|
129
|
-
|
|
136
|
+
if isinstance(payload, BaseModel):
|
|
137
|
+
data = payload.model_dump(exclude_unset=True)
|
|
138
|
+
elif isinstance(payload, dict):
|
|
139
|
+
data = payload
|
|
140
|
+
else:
|
|
141
|
+
raise HTTPException(422, "invalid_payload")
|
|
130
142
|
row = await service.update(session, item_id, data)
|
|
131
143
|
if not row:
|
|
132
144
|
raise HTTPException(404, "Not found")
|
|
@@ -145,4 +157,136 @@ def make_crud_router_plus_sql(
|
|
|
145
157
|
return router
|
|
146
158
|
|
|
147
159
|
|
|
148
|
-
|
|
160
|
+
def make_tenant_crud_router_plus_sql(
|
|
161
|
+
*,
|
|
162
|
+
model: type[Any],
|
|
163
|
+
service_factory: callable, # factory that returns a SqlService (will be wrapped)
|
|
164
|
+
read_schema: Type[ReadModel],
|
|
165
|
+
create_schema: Type[CreateModel],
|
|
166
|
+
update_schema: Type[UpdateModel],
|
|
167
|
+
prefix: str,
|
|
168
|
+
tenant_field: str = "tenant_id",
|
|
169
|
+
tags: list[str] | None = None,
|
|
170
|
+
search_fields: Optional[Sequence[str]] = None,
|
|
171
|
+
default_ordering: Optional[str] = None,
|
|
172
|
+
allowed_order_fields: Optional[list[str]] = None,
|
|
173
|
+
mount_under_db_prefix: bool = True,
|
|
174
|
+
) -> APIRouter:
|
|
175
|
+
"""Like make_crud_router_plus_sql, but requires TenantId and scopes all operations."""
|
|
176
|
+
router_prefix = ("/_sql" + prefix) if mount_under_db_prefix else prefix
|
|
177
|
+
router = public_router(
|
|
178
|
+
prefix=router_prefix,
|
|
179
|
+
tags=tags or [prefix.strip("/")],
|
|
180
|
+
redirect_slashes=False,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def _parse_ordering_to_fields(order_spec: Optional[str]) -> list[str]:
|
|
184
|
+
if not order_spec:
|
|
185
|
+
return []
|
|
186
|
+
pieces = [p.strip() for p in order_spec.split(",") if p.strip()]
|
|
187
|
+
fields: list[str] = []
|
|
188
|
+
for p in pieces:
|
|
189
|
+
name = p[1:] if p.startswith("-") else p
|
|
190
|
+
if allowed_order_fields and name not in (allowed_order_fields or []):
|
|
191
|
+
continue
|
|
192
|
+
fields.append(p)
|
|
193
|
+
return fields
|
|
194
|
+
|
|
195
|
+
# create per-request service with tenant scoping
|
|
196
|
+
async def _svc(session: SqlSessionDep, tenant_id: TenantId): # type: ignore[name-defined]
|
|
197
|
+
base = service_factory # consumer-provided factory or instance
|
|
198
|
+
svc = base # assume already a SqlService by default
|
|
199
|
+
if callable(base):
|
|
200
|
+
svc = base # the consumer likely closed over repo
|
|
201
|
+
# if callable returns a service, call it now
|
|
202
|
+
try:
|
|
203
|
+
svc = base() # type: ignore[misc]
|
|
204
|
+
except TypeError:
|
|
205
|
+
svc = base # already instance
|
|
206
|
+
if not isinstance(svc, TenantSqlService):
|
|
207
|
+
svc = TenantSqlService(getattr(svc, "repo", svc), tenant_id=tenant_id, tenant_field=tenant_field) # type: ignore[arg-type]
|
|
208
|
+
return svc # type: ignore[return-value]
|
|
209
|
+
|
|
210
|
+
@router.get("", response_model=cast(Any, Page[Any]))
|
|
211
|
+
async def list_items(
|
|
212
|
+
lp: Annotated[LimitOffsetParams, Depends(dep_limit_offset)],
|
|
213
|
+
op: Annotated[OrderParams, Depends(dep_order)],
|
|
214
|
+
sp: Annotated[SearchParams, Depends(dep_search)],
|
|
215
|
+
session: SqlSessionDep, # type: ignore[name-defined]
|
|
216
|
+
tenant_id: TenantId,
|
|
217
|
+
):
|
|
218
|
+
svc = await _svc(session, tenant_id)
|
|
219
|
+
order_spec = op.order_by or default_ordering
|
|
220
|
+
order_fields = _parse_ordering_to_fields(order_spec)
|
|
221
|
+
order_by = build_order_by(model, order_fields)
|
|
222
|
+
if sp.q:
|
|
223
|
+
fields = [
|
|
224
|
+
f.strip()
|
|
225
|
+
for f in (sp.fields or (",".join(search_fields or []) or "")).split(",")
|
|
226
|
+
if f.strip()
|
|
227
|
+
]
|
|
228
|
+
items = await svc.search(
|
|
229
|
+
session, q=sp.q, fields=fields, limit=lp.limit, offset=lp.offset, order_by=order_by
|
|
230
|
+
)
|
|
231
|
+
total = await svc.count_filtered(session, q=sp.q, fields=fields)
|
|
232
|
+
else:
|
|
233
|
+
items = await svc.list(session, limit=lp.limit, offset=lp.offset, order_by=order_by)
|
|
234
|
+
total = await svc.count(session)
|
|
235
|
+
return Page[read_schema].from_items(
|
|
236
|
+
total=total, items=items, limit=lp.limit, offset=lp.offset
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
@router.get("/{item_id}", response_model=cast(Any, Any))
|
|
240
|
+
async def get_item(item_id: Any, session: SqlSessionDep, tenant_id: TenantId): # type: ignore[name-defined]
|
|
241
|
+
svc = await _svc(session, tenant_id)
|
|
242
|
+
row = await svc.get(session, item_id)
|
|
243
|
+
if not row:
|
|
244
|
+
raise HTTPException(404, "Not found")
|
|
245
|
+
return row
|
|
246
|
+
|
|
247
|
+
@router.post("", response_model=cast(Any, Any), status_code=201)
|
|
248
|
+
async def create_item(
|
|
249
|
+
session: SqlSessionDep, # type: ignore[name-defined]
|
|
250
|
+
tenant_id: TenantId,
|
|
251
|
+
payload: Any = Body(...),
|
|
252
|
+
):
|
|
253
|
+
svc = await _svc(session, tenant_id)
|
|
254
|
+
if isinstance(payload, BaseModel):
|
|
255
|
+
data = payload.model_dump(exclude_unset=True)
|
|
256
|
+
elif isinstance(payload, dict):
|
|
257
|
+
data = payload
|
|
258
|
+
else:
|
|
259
|
+
raise HTTPException(422, "invalid_payload")
|
|
260
|
+
return await svc.create(session, data)
|
|
261
|
+
|
|
262
|
+
@router.patch("/{item_id}", response_model=cast(Any, Any))
|
|
263
|
+
async def update_item(
|
|
264
|
+
item_id: Any,
|
|
265
|
+
session: SqlSessionDep, # type: ignore[name-defined]
|
|
266
|
+
tenant_id: TenantId,
|
|
267
|
+
payload: Any = Body(...),
|
|
268
|
+
):
|
|
269
|
+
svc = await _svc(session, tenant_id)
|
|
270
|
+
if isinstance(payload, BaseModel):
|
|
271
|
+
data = payload.model_dump(exclude_unset=True)
|
|
272
|
+
elif isinstance(payload, dict):
|
|
273
|
+
data = payload
|
|
274
|
+
else:
|
|
275
|
+
raise HTTPException(422, "invalid_payload")
|
|
276
|
+
row = await svc.update(session, item_id, data)
|
|
277
|
+
if not row:
|
|
278
|
+
raise HTTPException(404, "Not found")
|
|
279
|
+
return row
|
|
280
|
+
|
|
281
|
+
@router.delete("/{item_id}", status_code=204)
|
|
282
|
+
async def delete_item(item_id: Any, session: SqlSessionDep, tenant_id: TenantId): # type: ignore[name-defined]
|
|
283
|
+
svc = await _svc(session, tenant_id)
|
|
284
|
+
ok = await svc.delete(session, item_id)
|
|
285
|
+
if not ok:
|
|
286
|
+
raise HTTPException(404, "Not found")
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
return router
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
__all__ = ["make_crud_router_plus_sql", "make_tenant_crud_router_plus_sql"]
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import time
|
|
4
|
-
from typing import Callable
|
|
4
|
+
from typing import Callable, Optional
|
|
5
5
|
|
|
6
6
|
from fastapi import HTTPException
|
|
7
7
|
from starlette.requests import Request
|
|
8
8
|
|
|
9
9
|
from svc_infra.api.fastapi.middleware.ratelimit_store import InMemoryRateLimitStore, RateLimitStore
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from svc_infra.api.fastapi.tenancy.context import resolve_tenant_id as _resolve_tenant_id
|
|
13
|
+
except Exception: # pragma: no cover - minimal builds
|
|
14
|
+
_resolve_tenant_id = None # type: ignore
|
|
10
15
|
from svc_infra.obs.metrics import emit_rate_limited
|
|
11
16
|
|
|
12
17
|
|
|
@@ -17,20 +22,44 @@ class RateLimiter:
|
|
|
17
22
|
limit: int,
|
|
18
23
|
window: int = 60,
|
|
19
24
|
key_fn: Callable = lambda r: "global",
|
|
25
|
+
limit_resolver: Optional[Callable[[Request, Optional[str]], Optional[int]]] = None,
|
|
26
|
+
scope_by_tenant: bool = False,
|
|
20
27
|
store: RateLimitStore | None = None,
|
|
21
28
|
):
|
|
22
29
|
self.limit = limit
|
|
23
30
|
self.window = window
|
|
24
31
|
self.key_fn = key_fn
|
|
32
|
+
self._limit_resolver = limit_resolver
|
|
33
|
+
self.scope_by_tenant = scope_by_tenant
|
|
25
34
|
self.store = store or InMemoryRateLimitStore(limit=limit)
|
|
26
35
|
|
|
27
36
|
async def __call__(self, request: Request):
|
|
37
|
+
# Try resolving tenant when asked
|
|
38
|
+
tenant_id = None
|
|
39
|
+
if self.scope_by_tenant or self._limit_resolver:
|
|
40
|
+
try:
|
|
41
|
+
if _resolve_tenant_id is not None:
|
|
42
|
+
tenant_id = await _resolve_tenant_id(request)
|
|
43
|
+
except Exception:
|
|
44
|
+
tenant_id = None
|
|
45
|
+
|
|
28
46
|
key = self.key_fn(request)
|
|
29
|
-
|
|
30
|
-
|
|
47
|
+
if self.scope_by_tenant and tenant_id:
|
|
48
|
+
key = f"{key}:tenant:{tenant_id}"
|
|
49
|
+
|
|
50
|
+
eff_limit = self.limit
|
|
51
|
+
if self._limit_resolver:
|
|
52
|
+
try:
|
|
53
|
+
v = self._limit_resolver(request, tenant_id)
|
|
54
|
+
eff_limit = int(v) if v is not None else self.limit
|
|
55
|
+
except Exception:
|
|
56
|
+
eff_limit = self.limit
|
|
57
|
+
|
|
58
|
+
count, store_limit, reset = self.store.incr(str(key), self.window)
|
|
59
|
+
if count > eff_limit:
|
|
31
60
|
retry = max(0, reset - int(time.time()))
|
|
32
61
|
try:
|
|
33
|
-
emit_rate_limited(str(key),
|
|
62
|
+
emit_rate_limited(str(key), eff_limit, retry)
|
|
34
63
|
except Exception:
|
|
35
64
|
pass
|
|
36
65
|
raise HTTPException(
|
|
@@ -46,17 +75,38 @@ def rate_limiter(
|
|
|
46
75
|
limit: int,
|
|
47
76
|
window: int = 60,
|
|
48
77
|
key_fn: Callable = lambda r: "global",
|
|
78
|
+
limit_resolver: Optional[Callable[[Request, Optional[str]], Optional[int]]] = None,
|
|
79
|
+
scope_by_tenant: bool = False,
|
|
49
80
|
store: RateLimitStore | None = None,
|
|
50
81
|
):
|
|
51
82
|
store_ = store or InMemoryRateLimitStore(limit=limit)
|
|
52
83
|
|
|
53
84
|
async def dep(request: Request):
|
|
85
|
+
tenant_id = None
|
|
86
|
+
if scope_by_tenant or limit_resolver:
|
|
87
|
+
try:
|
|
88
|
+
if _resolve_tenant_id is not None:
|
|
89
|
+
tenant_id = await _resolve_tenant_id(request)
|
|
90
|
+
except Exception:
|
|
91
|
+
tenant_id = None
|
|
92
|
+
|
|
54
93
|
key = key_fn(request)
|
|
55
|
-
|
|
56
|
-
|
|
94
|
+
if scope_by_tenant and tenant_id:
|
|
95
|
+
key = f"{key}:tenant:{tenant_id}"
|
|
96
|
+
|
|
97
|
+
eff_limit = limit
|
|
98
|
+
if limit_resolver:
|
|
99
|
+
try:
|
|
100
|
+
v = limit_resolver(request, tenant_id)
|
|
101
|
+
eff_limit = int(v) if v is not None else limit
|
|
102
|
+
except Exception:
|
|
103
|
+
eff_limit = limit
|
|
104
|
+
|
|
105
|
+
count, _store_limit, reset = store_.incr(str(key), window)
|
|
106
|
+
if count > eff_limit:
|
|
57
107
|
retry = max(0, reset - int(time.time()))
|
|
58
108
|
try:
|
|
59
|
-
emit_rate_limited(str(key),
|
|
109
|
+
emit_rate_limited(str(key), eff_limit, retry)
|
|
60
110
|
except Exception:
|
|
61
111
|
pass
|
|
62
112
|
raise HTTPException(
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_docs(
|
|
11
|
+
app: FastAPI,
|
|
12
|
+
*,
|
|
13
|
+
redoc_url: str = "/redoc",
|
|
14
|
+
swagger_url: str = "/docs",
|
|
15
|
+
openapi_url: str = "/openapi.json",
|
|
16
|
+
export_openapi_to: Optional[str] = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Enable docs endpoints and optionally export OpenAPI schema to disk on startup."""
|
|
19
|
+
# Configure FastAPI docs URLs
|
|
20
|
+
app.docs_url = swagger_url
|
|
21
|
+
app.redoc_url = redoc_url
|
|
22
|
+
app.openapi_url = openapi_url
|
|
23
|
+
|
|
24
|
+
if export_openapi_to:
|
|
25
|
+
export_path = Path(export_openapi_to)
|
|
26
|
+
|
|
27
|
+
@app.on_event("startup")
|
|
28
|
+
async def _export_spec() -> None: # noqa: ANN202
|
|
29
|
+
spec = app.openapi()
|
|
30
|
+
export_path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
export_path.write_text(json.dumps(spec, indent=2))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def add_sdk_generation_stub(
|
|
35
|
+
app: FastAPI,
|
|
36
|
+
*,
|
|
37
|
+
on_generate: Optional[callable] = None,
|
|
38
|
+
openapi_path: str = "/openapi.json",
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Hook to add an SDK generation stub.
|
|
41
|
+
|
|
42
|
+
Provide `on_generate()` to run generation (e.g., openapi-generator). This is a stub only; we
|
|
43
|
+
don't ship a hard dependency. If `on_generate` is provided, we expose `/_docs/generate-sdk`.
|
|
44
|
+
"""
|
|
45
|
+
from svc_infra.api.fastapi.dual.public import public_router
|
|
46
|
+
|
|
47
|
+
if not on_generate:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
router = public_router(prefix="/_docs", include_in_schema=False)
|
|
51
|
+
|
|
52
|
+
@router.post("/generate-sdk")
|
|
53
|
+
async def _generate() -> dict: # noqa: ANN201
|
|
54
|
+
on_generate()
|
|
55
|
+
return {"status": "ok"}
|
|
56
|
+
|
|
57
|
+
app.include_router(router)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = ["add_docs", "add_sdk_generation_stub"]
|
|
@@ -7,6 +7,12 @@ from svc_infra.obs.metrics import emit_rate_limited
|
|
|
7
7
|
|
|
8
8
|
from .ratelimit_store import InMemoryRateLimitStore, RateLimitStore
|
|
9
9
|
|
|
10
|
+
try:
|
|
11
|
+
# Optional import: tenancy may not be enabled in all apps
|
|
12
|
+
from svc_infra.api.fastapi.tenancy.context import resolve_tenant_id as _resolve_tenant_id
|
|
13
|
+
except Exception: # pragma: no cover - fallback for minimal builds
|
|
14
|
+
_resolve_tenant_id = None # type: ignore
|
|
15
|
+
|
|
10
16
|
|
|
11
17
|
class SimpleRateLimitMiddleware(BaseHTTPMiddleware):
|
|
12
18
|
def __init__(
|
|
@@ -15,18 +21,52 @@ class SimpleRateLimitMiddleware(BaseHTTPMiddleware):
|
|
|
15
21
|
limit: int = 120,
|
|
16
22
|
window: int = 60,
|
|
17
23
|
key_fn=None,
|
|
24
|
+
*,
|
|
25
|
+
# When provided, dynamically computes a limit for the current request (e.g. per-tenant quotas)
|
|
26
|
+
# Signature: (request: Request, tenant_id: Optional[str]) -> int | None
|
|
27
|
+
limit_resolver=None,
|
|
28
|
+
# If True, automatically scopes the bucket key by tenant id when available
|
|
29
|
+
scope_by_tenant: bool = False,
|
|
18
30
|
store: RateLimitStore | None = None,
|
|
19
31
|
):
|
|
20
32
|
super().__init__(app)
|
|
21
33
|
self.limit, self.window = limit, window
|
|
22
34
|
self.key_fn = key_fn or (lambda r: r.headers.get("X-API-Key") or r.client.host)
|
|
35
|
+
self._limit_resolver = limit_resolver
|
|
36
|
+
self.scope_by_tenant = scope_by_tenant
|
|
23
37
|
self.store = store or InMemoryRateLimitStore(limit=limit)
|
|
24
38
|
|
|
25
39
|
async def dispatch(self, request, call_next):
|
|
40
|
+
# Resolve tenant when possible
|
|
41
|
+
tenant_id = None
|
|
42
|
+
if self.scope_by_tenant or self._limit_resolver:
|
|
43
|
+
try:
|
|
44
|
+
if _resolve_tenant_id is not None:
|
|
45
|
+
tenant_id = await _resolve_tenant_id(request)
|
|
46
|
+
except Exception:
|
|
47
|
+
tenant_id = None
|
|
48
|
+
|
|
26
49
|
key = self.key_fn(request)
|
|
50
|
+
if self.scope_by_tenant and tenant_id:
|
|
51
|
+
key = f"{key}:tenant:{tenant_id}"
|
|
52
|
+
|
|
53
|
+
# Allow dynamic limit overrides
|
|
54
|
+
eff_limit = self.limit
|
|
55
|
+
if self._limit_resolver:
|
|
56
|
+
try:
|
|
57
|
+
v = self._limit_resolver(request, tenant_id)
|
|
58
|
+
eff_limit = int(v) if v is not None else self.limit
|
|
59
|
+
except Exception:
|
|
60
|
+
eff_limit = self.limit
|
|
61
|
+
|
|
27
62
|
now = int(time.time())
|
|
28
63
|
# Increment counter in store
|
|
29
|
-
|
|
64
|
+
# Update store limit if it differs; stores capture configured limit internally
|
|
65
|
+
# For in-memory store, we can temporarily adjust per-request by swapping a new store instance
|
|
66
|
+
# but to keep API simple, we reuse store and clamp by eff_limit below.
|
|
67
|
+
count, store_limit, reset = self.store.incr(str(key), self.window)
|
|
68
|
+
# Enforce the effective limit selected for this request
|
|
69
|
+
limit = eff_limit
|
|
30
70
|
remaining = max(0, limit - count)
|
|
31
71
|
|
|
32
72
|
if remaining < 0: # defensive clamp
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
7
|
+
from starlette.responses import JSONResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_probes(
|
|
11
|
+
app: FastAPI,
|
|
12
|
+
*,
|
|
13
|
+
prefix: str = "/_ops",
|
|
14
|
+
include_in_schema: bool = False,
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Mount basic liveness/readiness/startup probes under prefix."""
|
|
17
|
+
from svc_infra.api.fastapi.dual.public import public_router
|
|
18
|
+
|
|
19
|
+
router = public_router(prefix=prefix, tags=["ops"], include_in_schema=include_in_schema)
|
|
20
|
+
|
|
21
|
+
@router.get("/live")
|
|
22
|
+
async def live() -> JSONResponse: # noqa: D401, ANN201
|
|
23
|
+
return JSONResponse({"status": "ok"})
|
|
24
|
+
|
|
25
|
+
@router.get("/ready")
|
|
26
|
+
async def ready() -> JSONResponse: # noqa: D401, ANN201
|
|
27
|
+
# In the future, add checks (DB ping, cache ping) via DI hooks.
|
|
28
|
+
return JSONResponse({"status": "ok"})
|
|
29
|
+
|
|
30
|
+
@router.get("/startup")
|
|
31
|
+
async def startup_probe() -> JSONResponse: # noqa: D401, ANN201
|
|
32
|
+
return JSONResponse({"status": "ok"})
|
|
33
|
+
|
|
34
|
+
app.include_router(router)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def add_maintenance_mode(app: FastAPI, *, env_var: str = "MAINTENANCE_MODE") -> None:
|
|
38
|
+
"""Enable a simple maintenance gate controlled by an env var.
|
|
39
|
+
|
|
40
|
+
When MAINTENANCE_MODE is truthy, all non-GET requests return 503.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
@app.middleware("http")
|
|
44
|
+
async def _maintenance_gate(request: Request, call_next): # noqa: ANN001, ANN202
|
|
45
|
+
flag = str(os.getenv(env_var, "")).lower() in {"1", "true", "yes", "on"}
|
|
46
|
+
if flag and request.method not in {"GET", "HEAD", "OPTIONS"}:
|
|
47
|
+
return JSONResponse({"detail": "maintenance"}, status_code=503)
|
|
48
|
+
return await call_next(request)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def circuit_breaker_dependency(limit: int = 100, window_seconds: int = 60) -> Callable:
|
|
52
|
+
"""Return a dependency that can trip rejective errors based on external metrics.
|
|
53
|
+
|
|
54
|
+
This is a placeholder; callers can swap with a provider that tracks failures and opens the
|
|
55
|
+
breaker. Here, we read an env var to simulate an open breaker.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
async def _dep(_: Request) -> None: # noqa: D401, ANN202
|
|
59
|
+
if str(os.getenv("CIRCUIT_OPEN", "")).lower() in {"1", "true", "yes", "on"}:
|
|
60
|
+
raise HTTPException(status_code=503, detail="circuit open")
|
|
61
|
+
|
|
62
|
+
return _dep
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
__all__ = ["add_probes", "add_maintenance_mode", "circuit_breaker_dependency"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Optional
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
|
|
7
|
+
from .context import set_tenant_resolver
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_tenancy(app: FastAPI, *, resolver: Optional[Callable[..., Any]] = None) -> None:
|
|
11
|
+
"""Wire tenancy resolver for the application.
|
|
12
|
+
|
|
13
|
+
Provide a resolver(request, identity, header) -> Optional[str] to override
|
|
14
|
+
the default resolution. Pass None to clear a previous override.
|
|
15
|
+
"""
|
|
16
|
+
set_tenant_resolver(resolver)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ["add_tenancy"]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any, Callable, Optional
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, HTTPException, Request
|
|
6
|
+
|
|
7
|
+
try: # optional import; auth may not be used by all consumers
|
|
8
|
+
from svc_infra.api.fastapi.auth.security import OptionalIdentity
|
|
9
|
+
except Exception: # pragma: no cover - fallback for minimal builds
|
|
10
|
+
OptionalIdentity = None # type: ignore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_tenant_resolver: Optional[Callable[..., Any]] = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_tenant_resolver(
|
|
17
|
+
fn: Optional[Callable[..., Any]],
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Set or clear a global override hook for tenant resolution.
|
|
20
|
+
|
|
21
|
+
The function receives (request, identity, tenant_header) and should return a tenant id
|
|
22
|
+
string or None to fall back to default logic.
|
|
23
|
+
"""
|
|
24
|
+
global _tenant_resolver
|
|
25
|
+
_tenant_resolver = fn
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _maybe_await(x):
|
|
29
|
+
if callable(getattr(x, "__await__", None)):
|
|
30
|
+
return await x # type: ignore[misc]
|
|
31
|
+
return x
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def resolve_tenant_id(
|
|
35
|
+
request: Request,
|
|
36
|
+
tenant_header: Optional[str] = None,
|
|
37
|
+
identity: Any = Depends(OptionalIdentity) if OptionalIdentity else None, # type: ignore[arg-type]
|
|
38
|
+
) -> Optional[str]:
|
|
39
|
+
"""Resolve tenant id from override, identity, header, or request.state.
|
|
40
|
+
|
|
41
|
+
Order:
|
|
42
|
+
1) Global override hook (set_tenant_resolver)
|
|
43
|
+
2) Auth identity: user.tenant_id then api_key.tenant_id (if available)
|
|
44
|
+
3) X-Tenant-Id header
|
|
45
|
+
4) request.state.tenant_id
|
|
46
|
+
"""
|
|
47
|
+
# read header value if not provided directly (supports direct calls without DI)
|
|
48
|
+
if tenant_header is None:
|
|
49
|
+
try:
|
|
50
|
+
tenant_header = request.headers.get("X-Tenant-Id") # type: ignore[assignment]
|
|
51
|
+
except Exception:
|
|
52
|
+
tenant_header = None
|
|
53
|
+
|
|
54
|
+
# 1) global override
|
|
55
|
+
if _tenant_resolver is not None:
|
|
56
|
+
try:
|
|
57
|
+
v = _tenant_resolver(request, identity, tenant_header)
|
|
58
|
+
v2 = await _maybe_await(v)
|
|
59
|
+
if v2:
|
|
60
|
+
return str(v2)
|
|
61
|
+
except Exception:
|
|
62
|
+
# fall through to defaults
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
# 2) from identity
|
|
66
|
+
try:
|
|
67
|
+
if identity and getattr(identity, "user", None) is not None:
|
|
68
|
+
tid = getattr(identity.user, "tenant_id", None)
|
|
69
|
+
if tid:
|
|
70
|
+
return str(tid)
|
|
71
|
+
if identity and getattr(identity, "api_key", None) is not None:
|
|
72
|
+
tid = getattr(identity.api_key, "tenant_id", None)
|
|
73
|
+
if tid:
|
|
74
|
+
return str(tid)
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
# 3) from header
|
|
79
|
+
if tenant_header and isinstance(tenant_header, str) and tenant_header.strip():
|
|
80
|
+
return tenant_header.strip()
|
|
81
|
+
|
|
82
|
+
# 4) request.state
|
|
83
|
+
try:
|
|
84
|
+
st_tid = getattr(getattr(request, "state", object()), "tenant_id", None)
|
|
85
|
+
if st_tid:
|
|
86
|
+
return str(st_tid)
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def require_tenant_id(
|
|
94
|
+
tenant_id: Optional[str] = Depends(resolve_tenant_id),
|
|
95
|
+
) -> str:
|
|
96
|
+
if not tenant_id:
|
|
97
|
+
raise HTTPException(status_code=400, detail="tenant_context_missing")
|
|
98
|
+
return tenant_id
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# DX aliases
|
|
102
|
+
TenantId = Annotated[str, Depends(require_tenant_id)]
|
|
103
|
+
OptionalTenantId = Annotated[Optional[str], Depends(resolve_tenant_id)]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
__all__ = [
|
|
107
|
+
"TenantId",
|
|
108
|
+
"OptionalTenantId",
|
|
109
|
+
"resolve_tenant_id",
|
|
110
|
+
"require_tenant_id",
|
|
111
|
+
"set_tenant_resolver",
|
|
112
|
+
]
|
svc_infra/cli/__init__.py
CHANGED
|
@@ -9,6 +9,7 @@ from svc_infra.cli.cmds import (
|
|
|
9
9
|
register_mongo,
|
|
10
10
|
register_mongo_scaffold,
|
|
11
11
|
register_obs,
|
|
12
|
+
register_sql_export,
|
|
12
13
|
register_sql_scaffold,
|
|
13
14
|
)
|
|
14
15
|
from svc_infra.cli.foundation.typer_bootstrap import pre_cli
|
|
@@ -19,6 +20,7 @@ pre_cli(app)
|
|
|
19
20
|
# --- sql commands ---
|
|
20
21
|
register_alembic(app)
|
|
21
22
|
register_sql_scaffold(app)
|
|
23
|
+
register_sql_export(app)
|
|
22
24
|
|
|
23
25
|
# --- nosql commands ---
|
|
24
26
|
register_mongo(app)
|
svc_infra/cli/cmds/__init__.py
CHANGED
|
@@ -3,6 +3,7 @@ from svc_infra.cli.cmds.db.nosql.mongo.mongo_scaffold_cmds import (
|
|
|
3
3
|
register as register_mongo_scaffold,
|
|
4
4
|
)
|
|
5
5
|
from svc_infra.cli.cmds.db.sql.alembic_cmds import register as register_alembic
|
|
6
|
+
from svc_infra.cli.cmds.db.sql.sql_export_cmds import register as register_sql_export
|
|
6
7
|
from svc_infra.cli.cmds.db.sql.sql_scaffold_cmds import register as register_sql_scaffold
|
|
7
8
|
from svc_infra.cli.cmds.jobs.jobs_cmds import app as jobs_app
|
|
8
9
|
from svc_infra.cli.cmds.obs.obs_cmds import register as register_obs
|
|
@@ -12,6 +13,7 @@ from .help import _HELP
|
|
|
12
13
|
__all__ = [
|
|
13
14
|
"register_alembic",
|
|
14
15
|
"register_sql_scaffold",
|
|
16
|
+
"register_sql_export",
|
|
15
17
|
"register_mongo",
|
|
16
18
|
"register_mongo_scaffold",
|
|
17
19
|
"register_obs",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from sqlalchemy import text
|
|
12
|
+
|
|
13
|
+
from svc_infra.db.sql.utils import build_engine
|
|
14
|
+
|
|
15
|
+
try: # SQLAlchemy async extras are optional
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
17
|
+
except Exception: # pragma: no cover - fallback when async extras unavailable
|
|
18
|
+
AsyncEngine = None # type: ignore[assignment]
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(help="SQL data export commands")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command("export-tenant")
|
|
24
|
+
def export_tenant(
|
|
25
|
+
table: str = typer.Argument(..., help="Qualified table name to export (e.g., public.items)"),
|
|
26
|
+
tenant_id: str = typer.Option(..., "--tenant-id", help="Tenant id value to filter by."),
|
|
27
|
+
tenant_field: str = typer.Option("tenant_id", help="Column name for tenant id filter."),
|
|
28
|
+
output: Optional[Path] = typer.Option(
|
|
29
|
+
None, "--output", help="Output file; defaults to stdout."
|
|
30
|
+
),
|
|
31
|
+
limit: Optional[int] = typer.Option(None, help="Max rows to export."),
|
|
32
|
+
database_url: Optional[str] = typer.Option(
|
|
33
|
+
None, "--database-url", help="Overrides env SQL_URL for this command."
|
|
34
|
+
),
|
|
35
|
+
):
|
|
36
|
+
"""Export rows for a tenant from a given SQL table as JSON array."""
|
|
37
|
+
if database_url:
|
|
38
|
+
os.environ["SQL_URL"] = database_url
|
|
39
|
+
|
|
40
|
+
url = os.getenv("SQL_URL")
|
|
41
|
+
if not url:
|
|
42
|
+
typer.echo("SQL_URL is required (or pass --database-url)", err=True)
|
|
43
|
+
raise typer.Exit(code=2)
|
|
44
|
+
|
|
45
|
+
engine = build_engine(url)
|
|
46
|
+
rows: list[dict[str, Any]]
|
|
47
|
+
query = f"SELECT * FROM {table} WHERE {tenant_field} = :tenant_id"
|
|
48
|
+
if limit and limit > 0:
|
|
49
|
+
query += " LIMIT :limit"
|
|
50
|
+
|
|
51
|
+
params: dict[str, Any] = {"tenant_id": tenant_id}
|
|
52
|
+
if limit and limit > 0:
|
|
53
|
+
params["limit"] = int(limit)
|
|
54
|
+
|
|
55
|
+
stmt = text(query)
|
|
56
|
+
|
|
57
|
+
is_async_engine = AsyncEngine is not None and isinstance(engine, AsyncEngine)
|
|
58
|
+
|
|
59
|
+
if is_async_engine:
|
|
60
|
+
assert AsyncEngine is not None # for type checkers
|
|
61
|
+
|
|
62
|
+
async def _fetch() -> list[dict[str, Any]]:
|
|
63
|
+
async with engine.connect() as conn: # type: ignore[call-arg]
|
|
64
|
+
result = await conn.execute(stmt, params)
|
|
65
|
+
return [dict(row) for row in result.mappings()]
|
|
66
|
+
|
|
67
|
+
rows = asyncio.run(_fetch())
|
|
68
|
+
else:
|
|
69
|
+
with engine.connect() as conn: # type: ignore[attr-defined]
|
|
70
|
+
result = conn.execute(stmt, params)
|
|
71
|
+
rows = [dict(row) for row in result.mappings()]
|
|
72
|
+
|
|
73
|
+
data = json.dumps(rows, indent=2)
|
|
74
|
+
if output:
|
|
75
|
+
output.write_text(data)
|
|
76
|
+
typer.echo(str(output))
|
|
77
|
+
else:
|
|
78
|
+
sys.stdout.write(data)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def register(app_root: typer.Typer) -> None:
|
|
82
|
+
app_root.add_typer(app, name="sql")
|
svc_infra/data/add.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Iterable, Optional
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
|
|
7
|
+
from svc_infra.cli.cmds.db.sql.alembic_cmds import cmd_setup_and_migrate
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_data_lifecycle(
|
|
11
|
+
app: FastAPI,
|
|
12
|
+
*,
|
|
13
|
+
auto_migrate: bool = True,
|
|
14
|
+
database_url: str | None = None,
|
|
15
|
+
discover_packages: Optional[list[str]] = None,
|
|
16
|
+
with_payments: bool | None = None,
|
|
17
|
+
on_load_fixtures: Optional[Callable[[], None]] = None,
|
|
18
|
+
retention_jobs: Optional[Iterable[Callable[[], None]]] = None,
|
|
19
|
+
erasure_job: Optional[Callable[[str], None]] = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""
|
|
22
|
+
Wire data lifecycle conveniences:
|
|
23
|
+
|
|
24
|
+
- auto_migrate: run end-to-end Alembic setup-and-migrate on startup (idempotent).
|
|
25
|
+
- on_load_fixtures: optional callback to load reference/fixture data once at startup.
|
|
26
|
+
- retention_jobs: optional list of callables to register purge tasks (scheduler integration is external).
|
|
27
|
+
- erasure_job: optional callable to trigger a GDPR erasure workflow for a given principal ID.
|
|
28
|
+
|
|
29
|
+
This helper is intentionally minimal: it coordinates existing building blocks
|
|
30
|
+
and offers extension points. Jobs should be scheduled using svc_infra.jobs helpers.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
@app.on_event("startup")
|
|
34
|
+
async def _data_lifecycle_startup() -> None: # noqa: D401, ANN202
|
|
35
|
+
if auto_migrate:
|
|
36
|
+
# Use existing CLI function to perform end-to-end setup and migrate.
|
|
37
|
+
cmd_setup_and_migrate(
|
|
38
|
+
database_url=database_url,
|
|
39
|
+
overwrite_scaffold=False,
|
|
40
|
+
create_db_if_missing=True,
|
|
41
|
+
create_followup_revision=True,
|
|
42
|
+
initial_message="initial schema",
|
|
43
|
+
followup_message="autogen",
|
|
44
|
+
discover_packages=discover_packages,
|
|
45
|
+
with_payments=with_payments,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if on_load_fixtures:
|
|
49
|
+
# Run user-provided fixture loader (idempotent expected).
|
|
50
|
+
on_load_fixtures()
|
|
51
|
+
|
|
52
|
+
# Store optional jobs on app.state for external schedulers to discover/register.
|
|
53
|
+
if retention_jobs is not None:
|
|
54
|
+
app.state.data_retention_jobs = list(retention_jobs)
|
|
55
|
+
if erasure_job is not None:
|
|
56
|
+
app.state.data_erasure_job = erasure_job
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
__all__ = ["add_data_lifecycle"]
|
svc_infra/db/sql/repository.py
CHANGED
|
@@ -56,20 +56,31 @@ class SqlRepository:
|
|
|
56
56
|
limit: int,
|
|
57
57
|
offset: int,
|
|
58
58
|
order_by: Optional[Sequence[Any]] = None,
|
|
59
|
+
where: Optional[Sequence[Any]] = None,
|
|
59
60
|
) -> Sequence[Any]:
|
|
60
|
-
stmt = self._base_select()
|
|
61
|
+
stmt = self._base_select()
|
|
62
|
+
if where:
|
|
63
|
+
stmt = stmt.where(and_(*where))
|
|
64
|
+
stmt = stmt.limit(limit).offset(offset)
|
|
61
65
|
if order_by:
|
|
62
66
|
stmt = stmt.order_by(*order_by)
|
|
63
67
|
rows = (await session.execute(stmt)).scalars().all()
|
|
64
68
|
return rows
|
|
65
69
|
|
|
66
|
-
async def count(self, session: AsyncSession) -> int:
|
|
67
|
-
|
|
70
|
+
async def count(self, session: AsyncSession, *, where: Optional[Sequence[Any]] = None) -> int:
|
|
71
|
+
base = self._base_select()
|
|
72
|
+
if where:
|
|
73
|
+
base = base.where(and_(*where))
|
|
74
|
+
stmt = select(func.count()).select_from(base.subquery())
|
|
68
75
|
return (await session.execute(stmt)).scalar_one()
|
|
69
76
|
|
|
70
|
-
async def get(
|
|
77
|
+
async def get(
|
|
78
|
+
self, session: AsyncSession, id_value: Any, *, where: Optional[Sequence[Any]] = None
|
|
79
|
+
) -> Any | None:
|
|
71
80
|
# honors soft-delete if configured
|
|
72
81
|
stmt = self._base_select().where(self._id_column() == id_value)
|
|
82
|
+
if where:
|
|
83
|
+
stmt = stmt.where(and_(*where))
|
|
73
84
|
return (await session.execute(stmt)).scalars().first()
|
|
74
85
|
|
|
75
86
|
async def create(self, session: AsyncSession, data: dict[str, Any]) -> Any:
|
|
@@ -81,9 +92,14 @@ class SqlRepository:
|
|
|
81
92
|
return obj
|
|
82
93
|
|
|
83
94
|
async def update(
|
|
84
|
-
self,
|
|
95
|
+
self,
|
|
96
|
+
session: AsyncSession,
|
|
97
|
+
id_value: Any,
|
|
98
|
+
data: dict[str, Any],
|
|
99
|
+
*,
|
|
100
|
+
where: Optional[Sequence[Any]] = None,
|
|
85
101
|
) -> Any | None:
|
|
86
|
-
obj = await self.get(session, id_value)
|
|
102
|
+
obj = await self.get(session, id_value, where=where)
|
|
87
103
|
if not obj:
|
|
88
104
|
return None
|
|
89
105
|
valid = self._model_columns()
|
|
@@ -93,8 +109,17 @@ class SqlRepository:
|
|
|
93
109
|
await session.flush()
|
|
94
110
|
return obj
|
|
95
111
|
|
|
96
|
-
async def delete(
|
|
97
|
-
|
|
112
|
+
async def delete(
|
|
113
|
+
self, session: AsyncSession, id_value: Any, *, where: Optional[Sequence[Any]] = None
|
|
114
|
+
) -> bool:
|
|
115
|
+
# Fast path: when no extra filters provided, use session.get for simplicity (matches tests)
|
|
116
|
+
if not where:
|
|
117
|
+
obj = await session.get(self.model, id_value)
|
|
118
|
+
else:
|
|
119
|
+
# Respect soft-delete and optional tenant/extra filters by selecting through base select
|
|
120
|
+
stmt = self._base_select().where(self._id_column() == id_value)
|
|
121
|
+
stmt = stmt.where(and_(*where))
|
|
122
|
+
obj = (await session.execute(stmt)).scalars().first()
|
|
98
123
|
if not obj:
|
|
99
124
|
return False
|
|
100
125
|
if self.soft_delete:
|
|
@@ -118,6 +143,7 @@ class SqlRepository:
|
|
|
118
143
|
limit: int,
|
|
119
144
|
offset: int,
|
|
120
145
|
order_by: Optional[Sequence[Any]] = None,
|
|
146
|
+
where: Optional[Sequence[Any]] = None,
|
|
121
147
|
) -> Sequence[Any]:
|
|
122
148
|
ilike = f"%{q}%"
|
|
123
149
|
conditions = []
|
|
@@ -130,6 +156,8 @@ class SqlRepository:
|
|
|
130
156
|
# skip columns that cannot be used in ilike even with cast
|
|
131
157
|
continue
|
|
132
158
|
stmt = self._base_select()
|
|
159
|
+
if where:
|
|
160
|
+
stmt = stmt.where(and_(*where))
|
|
133
161
|
if conditions:
|
|
134
162
|
stmt = stmt.where(or_(*conditions))
|
|
135
163
|
stmt = stmt.limit(limit).offset(offset)
|
|
@@ -137,7 +165,14 @@ class SqlRepository:
|
|
|
137
165
|
stmt = stmt.order_by(*order_by)
|
|
138
166
|
return (await session.execute(stmt)).scalars().all()
|
|
139
167
|
|
|
140
|
-
async def count_filtered(
|
|
168
|
+
async def count_filtered(
|
|
169
|
+
self,
|
|
170
|
+
session: AsyncSession,
|
|
171
|
+
*,
|
|
172
|
+
q: str,
|
|
173
|
+
fields: Sequence[str],
|
|
174
|
+
where: Optional[Sequence[Any]] = None,
|
|
175
|
+
) -> int:
|
|
141
176
|
ilike = f"%{q}%"
|
|
142
177
|
conditions = []
|
|
143
178
|
for f in fields:
|
|
@@ -148,6 +183,8 @@ class SqlRepository:
|
|
|
148
183
|
except Exception:
|
|
149
184
|
continue
|
|
150
185
|
stmt = self._base_select()
|
|
186
|
+
if where:
|
|
187
|
+
stmt = stmt.where(and_(*where))
|
|
151
188
|
if conditions:
|
|
152
189
|
stmt = stmt.where(or_(*conditions))
|
|
153
190
|
# SELECT COUNT(*) FROM (<stmt>) as t
|
svc_infra/db/sql/resource.py
CHANGED
|
@@ -34,3 +34,8 @@ class SqlResource:
|
|
|
34
34
|
|
|
35
35
|
# Only a type reference; no runtime dependency on FastAPI layer
|
|
36
36
|
service_factory: Optional[Callable[[SqlRepository], "SqlService"]] = None
|
|
37
|
+
|
|
38
|
+
# Tenancy
|
|
39
|
+
tenant_field: Optional[str] = (
|
|
40
|
+
None # when set, CRUD router will require TenantId and scope by field
|
|
41
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Sequence
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
|
|
7
|
+
from .service import SqlService
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TenantSqlService(SqlService):
|
|
11
|
+
"""
|
|
12
|
+
SQL service wrapper that automatically scopes operations to a tenant.
|
|
13
|
+
|
|
14
|
+
- Adds a where filter (model.tenant_field == tenant_id) for list/get/update/delete/search/count.
|
|
15
|
+
- On create, if the model has the tenant field and it's not set in data, injects tenant_id.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, repo, *, tenant_id: str, tenant_field: str = "tenant_id"):
|
|
19
|
+
super().__init__(repo)
|
|
20
|
+
self.tenant_id = tenant_id
|
|
21
|
+
self.tenant_field = tenant_field
|
|
22
|
+
|
|
23
|
+
def _where(self) -> Sequence[Any]:
|
|
24
|
+
model = self.repo.model
|
|
25
|
+
col = getattr(model, self.tenant_field, None)
|
|
26
|
+
if col is None:
|
|
27
|
+
return []
|
|
28
|
+
return [col == self.tenant_id]
|
|
29
|
+
|
|
30
|
+
async def list(self, session: AsyncSession, *, limit: int, offset: int, order_by=None):
|
|
31
|
+
return await self.repo.list(
|
|
32
|
+
session, limit=limit, offset=offset, order_by=order_by, where=self._where()
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
async def count(self, session: AsyncSession) -> int:
|
|
36
|
+
return await self.repo.count(session, where=self._where())
|
|
37
|
+
|
|
38
|
+
async def get(self, session: AsyncSession, id_value: Any):
|
|
39
|
+
return await self.repo.get(session, id_value, where=self._where())
|
|
40
|
+
|
|
41
|
+
async def create(self, session: AsyncSession, data: dict[str, Any]):
|
|
42
|
+
data = await self.pre_create(data)
|
|
43
|
+
# inject tenant_id if model supports it and value missing
|
|
44
|
+
if self.tenant_field in self.repo._model_columns() and self.tenant_field not in data:
|
|
45
|
+
data[self.tenant_field] = self.tenant_id
|
|
46
|
+
return await self.repo.create(session, data)
|
|
47
|
+
|
|
48
|
+
async def update(self, session: AsyncSession, id_value: Any, data: dict[str, Any]):
|
|
49
|
+
data = await self.pre_update(data)
|
|
50
|
+
return await self.repo.update(session, id_value, data, where=self._where())
|
|
51
|
+
|
|
52
|
+
async def delete(self, session: AsyncSession, id_value: Any) -> bool:
|
|
53
|
+
return await self.repo.delete(session, id_value, where=self._where())
|
|
54
|
+
|
|
55
|
+
async def search(
|
|
56
|
+
self,
|
|
57
|
+
session: AsyncSession,
|
|
58
|
+
*,
|
|
59
|
+
q: str,
|
|
60
|
+
fields: Sequence[str],
|
|
61
|
+
limit: int,
|
|
62
|
+
offset: int,
|
|
63
|
+
order_by=None,
|
|
64
|
+
):
|
|
65
|
+
return await self.repo.search(
|
|
66
|
+
session,
|
|
67
|
+
q=q,
|
|
68
|
+
fields=fields,
|
|
69
|
+
limit=limit,
|
|
70
|
+
offset=offset,
|
|
71
|
+
order_by=order_by,
|
|
72
|
+
where=self._where(),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def count_filtered(self, session: AsyncSession, *, q: str, fields: Sequence[str]) -> int:
|
|
76
|
+
return await self.repo.count_filtered(session, q=q, fields=fields, where=self._where())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = ["TenantSqlService"]
|
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"]
|
|
@@ -49,13 +49,14 @@ svc_infra/api/fastapi/db/nosql/mongo/crud_router.py,sha256=9z_H-3SFCFurHv_Najtlj
|
|
|
49
49
|
svc_infra/api/fastapi/db/nosql/mongo/health.py,sha256=OM4_Pig3IRSrBhz2yHweIhGlSBrW1bubY_mDt5uyORA,507
|
|
50
50
|
svc_infra/api/fastapi/db/sql/README.md,sha256=uF_k4wbNSeWR6JF_8evWkeSFidBEXMXcIiYyR6sEv48,3082
|
|
51
51
|
svc_infra/api/fastapi/db/sql/__init__.py,sha256=R9P1Vy2Uqf9gFISxChMhO9tOGciIjEymVPhpXmsY3zc,255
|
|
52
|
-
svc_infra/api/fastapi/db/sql/add.py,sha256=
|
|
53
|
-
svc_infra/api/fastapi/db/sql/crud_router.py,sha256=
|
|
52
|
+
svc_infra/api/fastapi/db/sql/add.py,sha256=xGZnbGnP9PQtWvr5vuXA3_PXzTKldPM_cD3L0uztAvo,4768
|
|
53
|
+
svc_infra/api/fastapi/db/sql/crud_router.py,sha256=t4SYCYfzLIAnCXFfR2xDmIHjAI8septLd_wUBKFF1Xg,10816
|
|
54
54
|
svc_infra/api/fastapi/db/sql/health.py,sha256=ELLgQerooHHnvZRhGueSAc4QJsb3C4RojUGIu_U-hA4,792
|
|
55
55
|
svc_infra/api/fastapi/db/sql/session.py,sha256=DUBqKTRJAX4fqRz9B-w9eD9SpzZ8EUS862-GsjCL3ts,1869
|
|
56
56
|
svc_infra/api/fastapi/db/sql/users.py,sha256=68HGJgYVTEjKJm4-DPPC8-6nwXJoCukmgrYIIOHEUjs,5346
|
|
57
|
-
svc_infra/api/fastapi/dependencies/ratelimit.py,sha256=
|
|
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=qbGO6GHW7tXH34njnGjqQM8Php_jT3iLVe_yxDPmkpU,1671
|
|
59
60
|
svc_infra/api/fastapi/docs/landing.py,sha256=5JqJYCxQDCWy-BeDLfkv7OBlzWQKSGWUCYXQ51hojG8,4627
|
|
60
61
|
svc_infra/api/fastapi/docs/scoped.py,sha256=AuN35Op-9fUvHQCLOBtRjd5eWSpB5C9EAW_7-Boxmfo,7540
|
|
61
62
|
svc_infra/api/fastapi/dual/__init__.py,sha256=scHLcNFkGbgX_R21V702xnAv6GMCkQ4n7yUtNDNgliM,552
|
|
@@ -79,7 +80,7 @@ svc_infra/api/fastapi/middleware/errors/handlers.py,sha256=9Em_Z6PXTm9gM9ulEYwY0
|
|
|
79
80
|
svc_infra/api/fastapi/middleware/idempotency.py,sha256=vnBQgMWzJVaF8oWgfw2ATjEKCyQifDeGPUc9z1N7ebE,5051
|
|
80
81
|
svc_infra/api/fastapi/middleware/idempotency_store.py,sha256=BQN_Cq_jf_cuZRhze4EF5v0lOMQXpUWoRo7CsSTprug,5528
|
|
81
82
|
svc_infra/api/fastapi/middleware/optimistic_lock.py,sha256=9lOMBI4VNIVndXnrMmgSq4qeR7xPjNR1H9d1F71M5S8,1271
|
|
82
|
-
svc_infra/api/fastapi/middleware/ratelimit.py,sha256=
|
|
83
|
+
svc_infra/api/fastapi/middleware/ratelimit.py,sha256=8A_J6JdU-cs9QYqP6Ufbp4vDkiH-H6CsZwege1nqf24,3855
|
|
83
84
|
svc_infra/api/fastapi/middleware/ratelimit_store.py,sha256=LmJR8-kkW42rzOjls9lG1SBtCKjVY7L2Y_bNKHNY3-A,2553
|
|
84
85
|
svc_infra/api/fastapi/middleware/request_id.py,sha256=Iru7ypTdK_n76lwziEGDWoVF4FKS0Ps1PMASYmzK8ek,768
|
|
85
86
|
svc_infra/api/fastapi/middleware/request_size_limit.py,sha256=AcGqaB-F7Tbhg-at7ViT4Bpifst34jFneDBlUBjgo5I,1248
|
|
@@ -91,6 +92,7 @@ svc_infra/api/fastapi/openapi/mutators.py,sha256=KcWnJ3dsn_VdBG-x0ytddf_vwJ6u-84
|
|
|
91
92
|
svc_infra/api/fastapi/openapi/pipeline.py,sha256=GAf-qzwmWlYbrAlPirr8w89fEO4-kFrhCoeMj-7mE44,646
|
|
92
93
|
svc_infra/api/fastapi/openapi/responses.py,sha256=pBUoJd0lltBkQBJACS1Zd1wd975gbw6dYyMEqyueRuw,1093
|
|
93
94
|
svc_infra/api/fastapi/openapi/security.py,sha256=U78KMwgc7FilFPLbIE2f6xrp74hq6TDFXpUMGRyK_bg,1248
|
|
95
|
+
svc_infra/api/fastapi/ops/add.py,sha256=39puYLuJdZuIBkpmMiHF6N8H4D-96TRtFdYidbzqndI,2311
|
|
94
96
|
svc_infra/api/fastapi/pagination.py,sha256=770RbYyzgJotMA8gcc_y5zeAiP3em7fOHKuKDDlpOkI,10867
|
|
95
97
|
svc_infra/api/fastapi/paths/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
96
98
|
svc_infra/api/fastapi/paths/auth.py,sha256=hy9N0QFQnpv7dBOuHStui5eP9oIGHrawa0sADIVVD64,553
|
|
@@ -100,6 +102,8 @@ svc_infra/api/fastapi/paths/user.py,sha256=z8xv_A3dPhG366ezJU1c839oHbU3tEg06rbx3
|
|
|
100
102
|
svc_infra/api/fastapi/routers/__init__.py,sha256=pbyrfVZzrMFgX11K47TvTS94yN0q-t-BdrVrGG9QyDk,9163
|
|
101
103
|
svc_infra/api/fastapi/routers/ping.py,sha256=4M1xJ5nPu_CSvHHSD2DQJKzS7Mf5m8J-Ok9bXSEwudA,540
|
|
102
104
|
svc_infra/api/fastapi/setup.py,sha256=zm7mgyhTZVFNbrRaCerBN8fDfglpWStAYLMZHfVmEjU,9574
|
|
105
|
+
svc_infra/api/fastapi/tenancy/add.py,sha256=47lhWoHjuqbgSckJBTNGz-Mo5R6W7SQ0hQDiOGC0HU0,509
|
|
106
|
+
svc_infra/api/fastapi/tenancy/context.py,sha256=nYnUKKP88Xy5mZCY7B7ob9PTDAnaKIhrj7pN6s-lBqg,3308
|
|
103
107
|
svc_infra/app/README.md,sha256=jWbgyvP90NobTgxlkwyzCZpFOS-zDSkKw0JoRNauCrs,5074
|
|
104
108
|
svc_infra/app/__init__.py,sha256=za20ALo_kvJMgf7R_kF98DWk3h9acmvhYrCMhThcSmU,209
|
|
105
109
|
svc_infra/app/env.py,sha256=AOs4ksLZDXoAWrXeSnkYZ2yyS4UP68QUYfaWDcQ1QfA,4426
|
|
@@ -119,9 +123,9 @@ svc_infra/cache/resources.py,sha256=BhvPAZvCQ-fitUdniGEOOE4g1ZvljdCA_R5pR8WfJz4,
|
|
|
119
123
|
svc_infra/cache/tags.py,sha256=9URw4BRlnb4QFAYpDI36fMms6642xq4TeV9jqsEjzE8,2625
|
|
120
124
|
svc_infra/cache/ttl.py,sha256=_lWvNx1CTE4RcFEOUYkADd7_k4I13SLmtK0AMRUq2OM,1945
|
|
121
125
|
svc_infra/cache/utils.py,sha256=-LWr5IiJCNm3pwaoeCVlxNknnO2ChNKFcAGlFU98kjg,4856
|
|
122
|
-
svc_infra/cli/__init__.py,sha256=
|
|
126
|
+
svc_infra/cli/__init__.py,sha256=rWsQDwgYVZifCP0tgB8DKm0uPSX56qd9ipiYsVwD-eM,749
|
|
123
127
|
svc_infra/cli/__main__.py,sha256=5BjNuyet8AY-POwoF5rGt722rHQ7tJ0Vf0UFUfzzi-I,58
|
|
124
|
-
svc_infra/cli/cmds/__init__.py,sha256=
|
|
128
|
+
svc_infra/cli/cmds/__init__.py,sha256=wlWXa2PPxfmARkqEDQODhjCBrGJoti9eX8eE1IruX7w,804
|
|
125
129
|
svc_infra/cli/cmds/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
126
130
|
svc_infra/cli/cmds/db/nosql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
127
131
|
svc_infra/cli/cmds/db/nosql/mongo/README.md,sha256=0u3XLeoBd0XQzXwwfEiFISMIij11TJ9iOGzrysBvsFk,1788
|
|
@@ -130,6 +134,7 @@ svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py,sha256=83_I0-63aRyR2uRLhpG1DKavH
|
|
|
130
134
|
svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py,sha256=gsv7V3eGxyhQQm4J8IPYV9xQkdv0DoX7txcPLgbiejk,4277
|
|
131
135
|
svc_infra/cli/cmds/db/sql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
132
136
|
svc_infra/cli/cmds/db/sql/alembic_cmds.py,sha256=KjumtKSOZR1UxbpZUuqllpknerDLNcY-0kqqqxiOnL4,7664
|
|
137
|
+
svc_infra/cli/cmds/db/sql/sql_export_cmds.py,sha256=6MxoQO-9upoXg0cl1RHIqz96yXFVGidiBYp_ewhB0E0,2700
|
|
133
138
|
svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py,sha256=eNTCqHXOxgl9H3WTbGVn9BHXYwCpjIEJsDqhEFdrYMM,4613
|
|
134
139
|
svc_infra/cli/cmds/help.py,sha256=wGfZFMYaR2ZPwW2JwKDU7M3m4AtdCd8GRQ412AmEBUM,758
|
|
135
140
|
svc_infra/cli/cmds/jobs/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
|
|
@@ -139,6 +144,7 @@ svc_infra/cli/cmds/obs/obs_cmds.py,sha256=fltUZu5fcnZdl0_JPJBIxIaA1Xqpw1BXE-SWBP
|
|
|
139
144
|
svc_infra/cli/foundation/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
140
145
|
svc_infra/cli/foundation/runner.py,sha256=RbfjKwb3aHk1Y0MYU8xMpKRpIqRVMVr8GuL2EDZ6n38,1862
|
|
141
146
|
svc_infra/cli/foundation/typer_bootstrap.py,sha256=KapgH1R-qON9FuYH1KYlVx_5sJvjmAGl25pB61XCpm4,985
|
|
147
|
+
svc_infra/data/add.py,sha256=GbTdPDDsHg9ahQuMlLn4M_YoBFcc16Mf20FeWv1p1sU,2252
|
|
142
148
|
svc_infra/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
143
149
|
svc_infra/db/crud_schema.py,sha256=-fv-Om1lHVt6lcNbie6A2kRcPex4SDByUPfks6SpmUc,2521
|
|
144
150
|
svc_infra/db/inbox.py,sha256=drxLRLHaMRrCDgo_8wj12do80wDh5ssHV6LGkaM98no,1996
|
|
@@ -172,8 +178,8 @@ svc_infra/db/sql/base.py,sha256=yeOM4Xo2CtTzsfx0hN8KLFTgOPZp6BrbV7Omz_bZUMg,317
|
|
|
172
178
|
svc_infra/db/sql/constants.py,sha256=FqtoBWqP13gW8avIWpr8E41PDW5aQ88tjGmnokCV9Lk,1656
|
|
173
179
|
svc_infra/db/sql/core.py,sha256=2W7o1uyEZBDbqX1ptArP5Fa5obaVpZqHpczJCdf1NZk,10855
|
|
174
180
|
svc_infra/db/sql/management.py,sha256=bVpzj8BapwcghxAwmBsTnntWf3kaNf7CYETndppaiUM,3620
|
|
175
|
-
svc_infra/db/sql/repository.py,sha256=
|
|
176
|
-
svc_infra/db/sql/resource.py,sha256=
|
|
181
|
+
svc_infra/db/sql/repository.py,sha256=SG3cBf0BUnfds47BuOWjZJiFtrd2SmRkMINGioUhVn0,7047
|
|
182
|
+
svc_infra/db/sql/resource.py,sha256=CfBmRvMq_1-cgYBS9uc_G4i1W5e6wk6zo4MGdKTPT9I,1194
|
|
177
183
|
svc_infra/db/sql/scaffold.py,sha256=aRC572W9Jqs1nLprR_zmmgERthMlJwoUjQ_xbRh53RU,9107
|
|
178
184
|
svc_infra/db/sql/service.py,sha256=Jtb89FQgXWNRrh_bPLCvNyM7Up0_rn_wx1_CGWfiqNs,2790
|
|
179
185
|
svc_infra/db/sql/service_with_hooks.py,sha256=E2VHC-nqDp3AAop37WIM9nWyfTOsxzsa7W6jCFWxl9Q,721
|
|
@@ -188,12 +194,14 @@ svc_infra/db/sql/templates/setup/alembic.ini.tmpl,sha256=7SHSOyUq9S7XqVdKpWLJW7g
|
|
|
188
194
|
svc_infra/db/sql/templates/setup/env_async.py.tmpl,sha256=inKnexIhdnvwQ9gy-ODBgK1mdSKpzw2Q3pdTS_9AN0A,12781
|
|
189
195
|
svc_infra/db/sql/templates/setup/env_sync.py.tmpl,sha256=SP6OocHQIfkZl9g9iV0qVgkKOgQyViURQmTVqIIicPw,14195
|
|
190
196
|
svc_infra/db/sql/templates/setup/script.py.mako.tmpl,sha256=RiEMqF6dTN0S_lSRr90w5K2VtyK0_C81kBPZ02qQDZU,588
|
|
197
|
+
svc_infra/db/sql/tenant.py,sha256=erCB_TjZ62NXXIiOjsTOV_u3mM8kj0-R7gy4RFc-NrE,2779
|
|
191
198
|
svc_infra/db/sql/types.py,sha256=aDcYS-lEb3Aw9nlc8D67wyS5rmE9ZOkblxPjPFbMM_0,863
|
|
192
199
|
svc_infra/db/sql/uniq.py,sha256=IxW7SpgOTcHEAMe2oaDnuYFvj7YOfyjCpZRXdR45yP8,2530
|
|
193
200
|
svc_infra/db/sql/uniq_hooks.py,sha256=6gCnO0_Y-rhB0p-VuY0mZ9m1u3haiLWI3Ns_iUTqF_8,4294
|
|
194
201
|
svc_infra/db/sql/utils.py,sha256=nzuDcDhnVNehx5Y9BZLgxw8fvpfYbxTfXQsgnznVf4w,32862
|
|
195
202
|
svc_infra/db/sql/versioning.py,sha256=okZu2ad5RAFXNLXJgGpcQvZ5bc6gPjRWzwiBT0rEJJw,400
|
|
196
203
|
svc_infra/db/utils.py,sha256=aTD49VJSEu319kIWJ1uijUoP51co4lNJ3S0_tvuyGio,802
|
|
204
|
+
svc_infra/dx/add.py,sha256=FAnLGP0BPm_q_VCEcpUwfj-b0mEse988chh9DHeS7GU,1474
|
|
197
205
|
svc_infra/jobs/builtins/outbox_processor.py,sha256=VZoehNyjdaV_MmV74WMcbZR6z9E3VFMtZC-pxEwK0x0,1247
|
|
198
206
|
svc_infra/jobs/builtins/webhook_delivery.py,sha256=z_cl6YKwnduGjGaB8ZoUpKhFcEAhUZqqBma8v2FO1so,2982
|
|
199
207
|
svc_infra/jobs/easy.py,sha256=eix-OxWeE3vdkY3GGNoYM0GAyOxc928SpiSzMkr9k0A,977
|
|
@@ -270,7 +278,7 @@ svc_infra/webhooks/fastapi.py,sha256=BCNvGNxukf6dC2a4i-6en-PrjBGV19YvCWOot5lXWsA
|
|
|
270
278
|
svc_infra/webhooks/router.py,sha256=6JvAVPMEth_xxHX-IsIOcyMgHX7g1H0OVxVXKLuMp9w,1596
|
|
271
279
|
svc_infra/webhooks/service.py,sha256=hWgiJRXKBwKunJOx91C7EcLUkotDtD3Xp0RT6vj2IC0,1797
|
|
272
280
|
svc_infra/webhooks/signing.py,sha256=NCwdZzmravUe7HVIK_uXK0qqf12FG-_MVsgPvOw6lsM,784
|
|
273
|
-
svc_infra-0.1.
|
|
274
|
-
svc_infra-0.1.
|
|
275
|
-
svc_infra-0.1.
|
|
276
|
-
svc_infra-0.1.
|
|
281
|
+
svc_infra-0.1.602.dist-info/METADATA,sha256=GarD5h35kMI5JWTb1QpqupgtKdpT8GIsv71yTKOEdgA,7839
|
|
282
|
+
svc_infra-0.1.602.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
|
283
|
+
svc_infra-0.1.602.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
|
|
284
|
+
svc_infra-0.1.602.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|