svc-infra 0.1.599__py3-none-any.whl → 0.1.601__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.

@@ -12,6 +12,7 @@ from fastapi_users.password import PasswordHelper
12
12
  from svc_infra.api.fastapi.auth._cookies import compute_cookie_params
13
13
  from svc_infra.api.fastapi.auth.policy import AuthPolicy, DefaultAuthPolicy
14
14
  from svc_infra.api.fastapi.auth.settings import get_auth_settings
15
+ from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
15
16
  from svc_infra.api.fastapi.dual.public import public_router
16
17
 
17
18
  _pwd = PasswordHelper()
@@ -66,19 +67,18 @@ def auth_session_router(
66
67
  router = public_router()
67
68
  policy = auth_policy or DefaultAuthPolicy(get_auth_settings())
68
69
 
69
- from svc_infra.api.fastapi.db.sql import SqlSessionDep
70
70
  from svc_infra.security.lockout import get_lockout_status, record_attempt
71
71
 
72
72
  @router.post("/login", name="auth:jwt.login")
73
73
  async def login(
74
74
  request: Request,
75
+ session: SqlSessionDep,
75
76
  username: str = Form(...),
76
77
  password: str = Form(...),
77
78
  scope: str = Form(""),
78
79
  client_id: str | None = Form(None),
79
80
  client_secret: str | None = Form(None),
80
81
  user_manager=Depends(fapi.get_user_manager),
81
- session: SqlSessionDep = Depends(),
82
82
  ):
83
83
  strategy = auth_backend.get_strategy()
84
84
  email = username.strip().lower()
@@ -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
- router = make_crud_router_plus_sql(
41
- model=r.model,
42
- service=svc,
43
- read_schema=Read,
44
- create_schema=Create,
45
- update_schema=Update,
46
- prefix=r.prefix,
47
- tags=r.tags,
48
- search_fields=r.search_fields,
49
- default_ordering=r.ordering_default,
50
- allowed_order_fields=r.allowed_order_fields,
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[read_schema]),
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, read_schema),
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, read_schema),
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: create_schema = Body(...),
115
+ payload: Any = Body(...),
114
116
  ):
115
- data = cast(BaseModel, payload).model_dump(exclude_unset=True)
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, read_schema),
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: update_schema = Body(...),
134
+ payload: Any = Body(...),
128
135
  ):
129
- data = cast(BaseModel, payload).model_dump(exclude_unset=True)
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
- __all__ = ["make_crud_router_plus_sql"]
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
- count, limit, reset = self.store.incr(str(key), self.window)
30
- if count > limit:
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), limit, retry)
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
- count, lim, reset = store_.incr(str(key), window)
56
- if count > lim:
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), lim, retry)
109
+ emit_rate_limited(str(key), eff_limit, retry)
60
110
  except Exception:
61
111
  pass
62
112
  raise HTTPException(
@@ -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
- count, limit, reset = self.store.incr(str(key), self.window)
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,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)
@@ -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")
@@ -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().limit(limit).offset(offset)
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
- stmt = select(func.count()).select_from(self._base_select().subquery())
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(self, session: AsyncSession, id_value: Any) -> Any | None:
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, session: AsyncSession, id_value: Any, data: dict[str, Any]
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(self, session: AsyncSession, id_value: Any) -> bool:
97
- obj = await session.get(self.model, id_value)
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(self, session: AsyncSession, *, q: str, fields: Sequence[str]) -> int:
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
@@ -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"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: svc-infra
3
- Version: 0.1.599
3
+ Version: 0.1.601
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
@@ -19,7 +19,7 @@ svc_infra/api/fastapi/apf_payments/setup.py,sha256=bk_LLLXyqTA-lqf0v-mdpqLEMbXB1
19
19
  svc_infra/api/fastapi/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
20
  svc_infra/api/fastapi/auth/_cookies.py,sha256=U4heUmMnLezHx8U6ksuUEpSZ6sNMJcIO0gdLpmZ5FXw,1367
21
21
  svc_infra/api/fastapi/auth/add.py,sha256=-GOLZv-O53wfegmMQsi1wgex_IKJWBJJ4vEQNONQpnU,10014
22
- svc_infra/api/fastapi/auth/gaurd.py,sha256=j9k5V67MGr1s7m9yH4CPGAPkzAnnsqA5-xvrluYN3Ts,8751
22
+ svc_infra/api/fastapi/auth/gaurd.py,sha256=CXT5oMjxy_uQNyDRxQCxY2C4JFpMJqTTAz2j8pY5jOQ,8743
23
23
  svc_infra/api/fastapi/auth/mfa/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
24
24
  svc_infra/api/fastapi/auth/mfa/models.py,sha256=Te1cpGEBguUgul2NS50W_tHgybuu-TOIMPEBy53y9xc,1232
25
25
  svc_infra/api/fastapi/auth/mfa/pre_auth.py,sha256=ZsXIUNnObF8wx9-sz7PXGHrSSpaY2G0Ed5wwvu8TBzA,1379
@@ -49,12 +49,12 @@ 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=zrt6z4iVPEdq_f8dtBOJW_TRl-WlLBpW35KdTXpUWHQ,4003
53
- svc_infra/api/fastapi/db/sql/crud_router.py,sha256=GCNhGLZ2jfksBfgzqN20eStwpN9oUc9KPd0F0ztk4Bw,5030
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=us4iKKYZUqsoQ0xXuIJiLAsR8x-TlLMt6_ok8oiJSuY,1894
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
59
  svc_infra/api/fastapi/docs/landing.py,sha256=5JqJYCxQDCWy-BeDLfkv7OBlzWQKSGWUCYXQ51hojG8,4627
60
60
  svc_infra/api/fastapi/docs/scoped.py,sha256=AuN35Op-9fUvHQCLOBtRjd5eWSpB5C9EAW_7-Boxmfo,7540
@@ -79,7 +79,7 @@ svc_infra/api/fastapi/middleware/errors/handlers.py,sha256=9Em_Z6PXTm9gM9ulEYwY0
79
79
  svc_infra/api/fastapi/middleware/idempotency.py,sha256=vnBQgMWzJVaF8oWgfw2ATjEKCyQifDeGPUc9z1N7ebE,5051
80
80
  svc_infra/api/fastapi/middleware/idempotency_store.py,sha256=BQN_Cq_jf_cuZRhze4EF5v0lOMQXpUWoRo7CsSTprug,5528
81
81
  svc_infra/api/fastapi/middleware/optimistic_lock.py,sha256=9lOMBI4VNIVndXnrMmgSq4qeR7xPjNR1H9d1F71M5S8,1271
82
- svc_infra/api/fastapi/middleware/ratelimit.py,sha256=a1KZ_TCSkSn5WlPOIEZ0lw5sUsrAMMBXdiDNFVWFi5k,2044
82
+ svc_infra/api/fastapi/middleware/ratelimit.py,sha256=8A_J6JdU-cs9QYqP6Ufbp4vDkiH-H6CsZwege1nqf24,3855
83
83
  svc_infra/api/fastapi/middleware/ratelimit_store.py,sha256=LmJR8-kkW42rzOjls9lG1SBtCKjVY7L2Y_bNKHNY3-A,2553
84
84
  svc_infra/api/fastapi/middleware/request_id.py,sha256=Iru7ypTdK_n76lwziEGDWoVF4FKS0Ps1PMASYmzK8ek,768
85
85
  svc_infra/api/fastapi/middleware/request_size_limit.py,sha256=AcGqaB-F7Tbhg-at7ViT4Bpifst34jFneDBlUBjgo5I,1248
@@ -100,6 +100,8 @@ svc_infra/api/fastapi/paths/user.py,sha256=z8xv_A3dPhG366ezJU1c839oHbU3tEg06rbx3
100
100
  svc_infra/api/fastapi/routers/__init__.py,sha256=pbyrfVZzrMFgX11K47TvTS94yN0q-t-BdrVrGG9QyDk,9163
101
101
  svc_infra/api/fastapi/routers/ping.py,sha256=4M1xJ5nPu_CSvHHSD2DQJKzS7Mf5m8J-Ok9bXSEwudA,540
102
102
  svc_infra/api/fastapi/setup.py,sha256=zm7mgyhTZVFNbrRaCerBN8fDfglpWStAYLMZHfVmEjU,9574
103
+ svc_infra/api/fastapi/tenancy/add.py,sha256=47lhWoHjuqbgSckJBTNGz-Mo5R6W7SQ0hQDiOGC0HU0,509
104
+ svc_infra/api/fastapi/tenancy/context.py,sha256=nYnUKKP88Xy5mZCY7B7ob9PTDAnaKIhrj7pN6s-lBqg,3308
103
105
  svc_infra/app/README.md,sha256=jWbgyvP90NobTgxlkwyzCZpFOS-zDSkKw0JoRNauCrs,5074
104
106
  svc_infra/app/__init__.py,sha256=za20ALo_kvJMgf7R_kF98DWk3h9acmvhYrCMhThcSmU,209
105
107
  svc_infra/app/env.py,sha256=AOs4ksLZDXoAWrXeSnkYZ2yyS4UP68QUYfaWDcQ1QfA,4426
@@ -119,9 +121,9 @@ svc_infra/cache/resources.py,sha256=BhvPAZvCQ-fitUdniGEOOE4g1ZvljdCA_R5pR8WfJz4,
119
121
  svc_infra/cache/tags.py,sha256=9URw4BRlnb4QFAYpDI36fMms6642xq4TeV9jqsEjzE8,2625
120
122
  svc_infra/cache/ttl.py,sha256=_lWvNx1CTE4RcFEOUYkADd7_k4I13SLmtK0AMRUq2OM,1945
121
123
  svc_infra/cache/utils.py,sha256=-LWr5IiJCNm3pwaoeCVlxNknnO2ChNKFcAGlFU98kjg,4856
122
- svc_infra/cli/__init__.py,sha256=wsmFGr8wiKeoIW7pImcHt6piEV5KZQR2IDfgh3yHpyY,699
124
+ svc_infra/cli/__init__.py,sha256=rWsQDwgYVZifCP0tgB8DKm0uPSX56qd9ipiYsVwD-eM,749
123
125
  svc_infra/cli/__main__.py,sha256=5BjNuyet8AY-POwoF5rGt722rHQ7tJ0Vf0UFUfzzi-I,58
124
- svc_infra/cli/cmds/__init__.py,sha256=HyUBE2pvhlTF5Uk03x_fqj4cbdX1Ri2CyHLUFBNK2UE,691
126
+ svc_infra/cli/cmds/__init__.py,sha256=wlWXa2PPxfmARkqEDQODhjCBrGJoti9eX8eE1IruX7w,804
125
127
  svc_infra/cli/cmds/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
126
128
  svc_infra/cli/cmds/db/nosql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
127
129
  svc_infra/cli/cmds/db/nosql/mongo/README.md,sha256=0u3XLeoBd0XQzXwwfEiFISMIij11TJ9iOGzrysBvsFk,1788
@@ -130,6 +132,7 @@ svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py,sha256=83_I0-63aRyR2uRLhpG1DKavH
130
132
  svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py,sha256=gsv7V3eGxyhQQm4J8IPYV9xQkdv0DoX7txcPLgbiejk,4277
131
133
  svc_infra/cli/cmds/db/sql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
132
134
  svc_infra/cli/cmds/db/sql/alembic_cmds.py,sha256=KjumtKSOZR1UxbpZUuqllpknerDLNcY-0kqqqxiOnL4,7664
135
+ svc_infra/cli/cmds/db/sql/sql_export_cmds.py,sha256=6MxoQO-9upoXg0cl1RHIqz96yXFVGidiBYp_ewhB0E0,2700
133
136
  svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py,sha256=eNTCqHXOxgl9H3WTbGVn9BHXYwCpjIEJsDqhEFdrYMM,4613
134
137
  svc_infra/cli/cmds/help.py,sha256=wGfZFMYaR2ZPwW2JwKDU7M3m4AtdCd8GRQ412AmEBUM,758
135
138
  svc_infra/cli/cmds/jobs/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
@@ -172,8 +175,8 @@ svc_infra/db/sql/base.py,sha256=yeOM4Xo2CtTzsfx0hN8KLFTgOPZp6BrbV7Omz_bZUMg,317
172
175
  svc_infra/db/sql/constants.py,sha256=FqtoBWqP13gW8avIWpr8E41PDW5aQ88tjGmnokCV9Lk,1656
173
176
  svc_infra/db/sql/core.py,sha256=2W7o1uyEZBDbqX1ptArP5Fa5obaVpZqHpczJCdf1NZk,10855
174
177
  svc_infra/db/sql/management.py,sha256=bVpzj8BapwcghxAwmBsTnntWf3kaNf7CYETndppaiUM,3620
175
- svc_infra/db/sql/repository.py,sha256=V-HA35rzRLebggfn1jqYGz-zy1JTc2keQicsb_mgQEA,5832
176
- svc_infra/db/sql/resource.py,sha256=RfbFGo1u4NOZ8AZpBwd7PfK42871eItvFDoCv5COuTo,1058
178
+ svc_infra/db/sql/repository.py,sha256=SG3cBf0BUnfds47BuOWjZJiFtrd2SmRkMINGioUhVn0,7047
179
+ svc_infra/db/sql/resource.py,sha256=CfBmRvMq_1-cgYBS9uc_G4i1W5e6wk6zo4MGdKTPT9I,1194
177
180
  svc_infra/db/sql/scaffold.py,sha256=aRC572W9Jqs1nLprR_zmmgERthMlJwoUjQ_xbRh53RU,9107
178
181
  svc_infra/db/sql/service.py,sha256=Jtb89FQgXWNRrh_bPLCvNyM7Up0_rn_wx1_CGWfiqNs,2790
179
182
  svc_infra/db/sql/service_with_hooks.py,sha256=E2VHC-nqDp3AAop37WIM9nWyfTOsxzsa7W6jCFWxl9Q,721
@@ -188,6 +191,7 @@ svc_infra/db/sql/templates/setup/alembic.ini.tmpl,sha256=7SHSOyUq9S7XqVdKpWLJW7g
188
191
  svc_infra/db/sql/templates/setup/env_async.py.tmpl,sha256=inKnexIhdnvwQ9gy-ODBgK1mdSKpzw2Q3pdTS_9AN0A,12781
189
192
  svc_infra/db/sql/templates/setup/env_sync.py.tmpl,sha256=SP6OocHQIfkZl9g9iV0qVgkKOgQyViURQmTVqIIicPw,14195
190
193
  svc_infra/db/sql/templates/setup/script.py.mako.tmpl,sha256=RiEMqF6dTN0S_lSRr90w5K2VtyK0_C81kBPZ02qQDZU,588
194
+ svc_infra/db/sql/tenant.py,sha256=erCB_TjZ62NXXIiOjsTOV_u3mM8kj0-R7gy4RFc-NrE,2779
191
195
  svc_infra/db/sql/types.py,sha256=aDcYS-lEb3Aw9nlc8D67wyS5rmE9ZOkblxPjPFbMM_0,863
192
196
  svc_infra/db/sql/uniq.py,sha256=IxW7SpgOTcHEAMe2oaDnuYFvj7YOfyjCpZRXdR45yP8,2530
193
197
  svc_infra/db/sql/uniq_hooks.py,sha256=6gCnO0_Y-rhB0p-VuY0mZ9m1u3haiLWI3Ns_iUTqF_8,4294
@@ -270,7 +274,7 @@ svc_infra/webhooks/fastapi.py,sha256=BCNvGNxukf6dC2a4i-6en-PrjBGV19YvCWOot5lXWsA
270
274
  svc_infra/webhooks/router.py,sha256=6JvAVPMEth_xxHX-IsIOcyMgHX7g1H0OVxVXKLuMp9w,1596
271
275
  svc_infra/webhooks/service.py,sha256=hWgiJRXKBwKunJOx91C7EcLUkotDtD3Xp0RT6vj2IC0,1797
272
276
  svc_infra/webhooks/signing.py,sha256=NCwdZzmravUe7HVIK_uXK0qqf12FG-_MVsgPvOw6lsM,784
273
- svc_infra-0.1.599.dist-info/METADATA,sha256=dIULp2nBSVBybFpHOi0m0LEZRZe0dYUTYZPJYve8i1w,7839
274
- svc_infra-0.1.599.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
275
- svc_infra-0.1.599.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
276
- svc_infra-0.1.599.dist-info/RECORD,,
277
+ svc_infra-0.1.601.dist-info/METADATA,sha256=--fXg5XHvGu56tmJ-UArjNr8FjrvQ9d-75Bwww4i3bI,7839
278
+ svc_infra-0.1.601.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
279
+ svc_infra-0.1.601.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
280
+ svc_infra-0.1.601.dist-info/RECORD,,