interloper-api 0.60.0__tar.gz → 0.62.0__tar.gz
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.
- {interloper_api-0.60.0 → interloper_api-0.62.0}/PKG-INFO +1 -1
- {interloper_api-0.60.0 → interloper_api-0.62.0}/pyproject.toml +1 -1
- {interloper_api-0.60.0 → interloper_api-0.62.0}/pyproject.toml.orig +1 -1
- {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/admin.py +56 -33
- {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/backfills.py +18 -16
- {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/runs.py +19 -5
- {interloper_api-0.60.0 → interloper_api-0.62.0}/README.md +0 -0
- {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/__init__.py +0 -0
- {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/app.py +0 -0
- {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/components.py +0 -0
- {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/dependencies.py +0 -0
- {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/email.py +0 -0
- {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/__init__.py +0 -0
- {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/agent.py +0 -0
- {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/auth.py +0 -0
- {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/catalog.py +0 -0
- {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/components.py +0 -0
- {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/oauth.py +0 -0
- {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/organisations.py +0 -0
- {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/tokens.py +0 -0
- {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/ws.py +0 -0
|
@@ -19,7 +19,7 @@ from uuid import UUID
|
|
|
19
19
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
20
20
|
from interloper_db import Organisation, Profile, Store
|
|
21
21
|
from interloper_db.store.quotas import METRIC_SUCCESSFUL_RUNS, QUOTAS
|
|
22
|
-
from pydantic import BaseModel,
|
|
22
|
+
from pydantic import BaseModel, RootModel, field_validator
|
|
23
23
|
|
|
24
24
|
from interloper_api.dependencies import get_admin_config, get_quota_defaults, get_store, require_super_admin
|
|
25
25
|
from interloper_api.email import send_invite_email
|
|
@@ -228,13 +228,11 @@ class AdminDataConfig(BaseModel):
|
|
|
228
228
|
catalog: dict[str, list[str]]
|
|
229
229
|
|
|
230
230
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
max_successful_runs_per_month: int | None = None
|
|
237
|
-
max_backfill_days: int | None = None
|
|
231
|
+
#: One set of quota limits, keyed by quota key; null means unlimited (or
|
|
232
|
+
#: unset, for overrides). Derived from the ``QUOTAS`` registry rather than
|
|
233
|
+
#: declared field-by-field, so registering a quota surfaces it here — and in
|
|
234
|
+
#: the frontend, which already indexes these by key — with no code change.
|
|
235
|
+
AdminQuotaLimits = dict[str, int | None]
|
|
238
236
|
|
|
239
237
|
|
|
240
238
|
class AdminOrgQuotaStatus(BaseModel):
|
|
@@ -272,13 +270,30 @@ class AdminQuotasResponse(BaseModel):
|
|
|
272
270
|
organisations: list[AdminOrgQuotaStatus]
|
|
273
271
|
|
|
274
272
|
|
|
275
|
-
class AdminQuotaUpdateRequest(
|
|
276
|
-
"""Per-org quota overrides. Omitted
|
|
273
|
+
class AdminQuotaUpdateRequest(RootModel[AdminQuotaLimits]):
|
|
274
|
+
"""Per-org quota overrides. Omitted keys keep their value; null clears one.
|
|
275
|
+
|
|
276
|
+
Keys are checked against the registry here so an unknown quota is a 422
|
|
277
|
+
at the boundary rather than a ``KeyError`` out of the store.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
@field_validator("root")
|
|
281
|
+
@classmethod
|
|
282
|
+
def _known_keys_and_non_negative(cls, value: AdminQuotaLimits) -> AdminQuotaLimits:
|
|
283
|
+
"""Validate the payload against the quota registry.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
The payload unchanged.
|
|
277
287
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
288
|
+
Raises:
|
|
289
|
+
ValueError: If a key names no registered quota, or a limit is
|
|
290
|
+
negative.
|
|
291
|
+
"""
|
|
292
|
+
if unknown := sorted(set(value) - set(QUOTAS.keys())):
|
|
293
|
+
raise ValueError(f"Unknown quota(s): {', '.join(unknown)}. Known: {', '.join(sorted(QUOTAS.keys()))}")
|
|
294
|
+
if negative := sorted(key for key, limit in value.items() if limit is not None and limit < 0):
|
|
295
|
+
raise ValueError(f"Quota limits must be >= 0: {', '.join(negative)}")
|
|
296
|
+
return value
|
|
282
297
|
|
|
283
298
|
|
|
284
299
|
class AdminActivityEntry(BaseModel):
|
|
@@ -476,32 +491,40 @@ def build_config_snapshot(settings: Any, features: dict[str, bool], catalog: Any
|
|
|
476
491
|
|
|
477
492
|
|
|
478
493
|
def _quota_limits(limits: Any) -> AdminQuotaLimits:
|
|
479
|
-
"""Map limits from a ``{key: limit}`` dict (store) or attributes (settings).
|
|
494
|
+
"""Map limits from a ``{key: limit}`` dict (store) or attributes (settings).
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
Every registered quota's limit, ``None`` where unset.
|
|
498
|
+
"""
|
|
480
499
|
if isinstance(limits, dict):
|
|
481
|
-
return
|
|
482
|
-
return
|
|
500
|
+
return {key: limits.get(key) for key in QUOTAS.keys()}
|
|
501
|
+
return {key: getattr(limits, key, None) for key in QUOTAS.keys()}
|
|
483
502
|
|
|
484
503
|
|
|
485
504
|
def _effective_limits(overrides: AdminQuotaLimits, defaults: AdminQuotaLimits) -> AdminQuotaLimits:
|
|
486
|
-
"""Per-org overrides win
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
505
|
+
"""Per-org overrides win key-by-key over the global defaults.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
The effective limit of every registered quota.
|
|
509
|
+
"""
|
|
510
|
+
return {
|
|
511
|
+
key: override if (override := overrides.get(key)) is not None else defaults.get(key)
|
|
512
|
+
for key in QUOTAS.keys()
|
|
513
|
+
}
|
|
493
514
|
|
|
494
515
|
|
|
495
516
|
def _quota_fields(defaults: AdminQuotaLimits) -> list[AdminQuotaField]:
|
|
496
|
-
"""Field descriptors for admin quota surfaces, in
|
|
517
|
+
"""Field descriptors for admin quota surfaces, in registry order.
|
|
497
518
|
|
|
498
|
-
|
|
499
|
-
|
|
519
|
+
Keys, labels and defaults all come from the registry, so registering a
|
|
520
|
+
quota is the whole change: no wire model, no frontend edit. ``Registry``
|
|
521
|
+
sorts its keys, so the admin surfaces list quotas alphabetically rather
|
|
522
|
+
than in the order they happen to be registered.
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
One descriptor per registered quota.
|
|
500
526
|
"""
|
|
501
|
-
return [
|
|
502
|
-
AdminQuotaField(key=key, label=QUOTAS[key].label, default=getattr(defaults, key))
|
|
503
|
-
for key in AdminQuotaLimits.model_fields
|
|
504
|
-
]
|
|
527
|
+
return [AdminQuotaField(key=key, label=QUOTAS[key].label, default=defaults.get(key)) for key in QUOTAS.keys()]
|
|
505
528
|
|
|
506
529
|
|
|
507
530
|
def _require_org(store: Store, org_id: UUID) -> Organisation:
|
|
@@ -657,9 +680,9 @@ def update_org_quota(
|
|
|
657
680
|
user: Profile = Depends(require_super_admin),
|
|
658
681
|
store: Store = Depends(get_store),
|
|
659
682
|
) -> AdminQuotaLimits:
|
|
660
|
-
"""Set an organisation's quota overrides; omitted
|
|
683
|
+
"""Set an organisation's quota overrides; omitted keys keep their value, null clears one."""
|
|
661
684
|
_require_org(store, org_id)
|
|
662
|
-
overrides = store.set_quota(org_id, body.
|
|
685
|
+
overrides = store.set_quota(org_id, body.root)
|
|
663
686
|
return _quota_limits(overrides)
|
|
664
687
|
|
|
665
688
|
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import datetime as dt
|
|
6
5
|
from uuid import UUID
|
|
7
6
|
|
|
8
7
|
from fastapi import APIRouter, Depends, HTTPException
|
|
@@ -27,8 +26,8 @@ class BackfillCreateRequest(BaseModel):
|
|
|
27
26
|
"""Request body for queuing a backfill."""
|
|
28
27
|
|
|
29
28
|
component_id: UUID
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
start_key: str
|
|
30
|
+
end_key: str
|
|
32
31
|
concurrency: int = 1
|
|
33
32
|
fail_fast: bool = False
|
|
34
33
|
|
|
@@ -40,8 +39,8 @@ class BackfillResponse(BaseModel):
|
|
|
40
39
|
org_id: UUID
|
|
41
40
|
component_id: UUID | None
|
|
42
41
|
status: str
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
start_key: str
|
|
43
|
+
end_key: str
|
|
45
44
|
concurrency: int
|
|
46
45
|
fail_fast: bool
|
|
47
46
|
partitions: int
|
|
@@ -64,8 +63,8 @@ def _backfill_to_response(backfill: Backfill) -> BackfillResponse:
|
|
|
64
63
|
org_id=backfill.org_id,
|
|
65
64
|
component_id=backfill.component_id,
|
|
66
65
|
status=backfill.status,
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
start_key=backfill.start_key,
|
|
67
|
+
end_key=backfill.end_key,
|
|
69
68
|
concurrency=backfill.concurrency,
|
|
70
69
|
fail_fast=backfill.fail_fast,
|
|
71
70
|
partitions=backfill.partitions,
|
|
@@ -96,18 +95,21 @@ def create_backfill(
|
|
|
96
95
|
user: Profile = Depends(get_current_user),
|
|
97
96
|
store: Store = Depends(get_store),
|
|
98
97
|
) -> BackfillResponse:
|
|
99
|
-
"""Queue a backfill for a job over a
|
|
98
|
+
"""Queue a backfill for a job over a partition-key range."""
|
|
100
99
|
job = load_authorized(
|
|
101
100
|
lambda i: store.get_component(i, kind="job"), body.component_id, user, store, label="Job", minimum="editor"
|
|
102
101
|
)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
102
|
+
try:
|
|
103
|
+
backfill = store.create_backfill(
|
|
104
|
+
job.org_id,
|
|
105
|
+
component_id=body.component_id,
|
|
106
|
+
start_key=body.start_key,
|
|
107
|
+
end_key=body.end_key,
|
|
108
|
+
concurrency=body.concurrency,
|
|
109
|
+
fail_fast=body.fail_fast,
|
|
110
|
+
)
|
|
111
|
+
except ValueError as e:
|
|
112
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
111
113
|
return _backfill_to_response(backfill)
|
|
112
114
|
|
|
113
115
|
|
|
@@ -37,7 +37,7 @@ class RunResponse(BaseModel):
|
|
|
37
37
|
org_id: UUID
|
|
38
38
|
component_id: UUID | None
|
|
39
39
|
backfill_id: UUID | None
|
|
40
|
-
|
|
40
|
+
partition_key: str | None
|
|
41
41
|
status: str
|
|
42
42
|
retry_of: UUID | None = None
|
|
43
43
|
attempt: int = 1
|
|
@@ -51,7 +51,7 @@ class RunCreateRequest(BaseModel):
|
|
|
51
51
|
"""Request body for queuing a run targeting a runnable component."""
|
|
52
52
|
|
|
53
53
|
component_id: UUID
|
|
54
|
-
|
|
54
|
+
partition_key: str | None = None
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
class RetryRequest(BaseModel):
|
|
@@ -105,7 +105,7 @@ def _run_to_response(run: Run) -> RunResponse:
|
|
|
105
105
|
org_id=run.org_id,
|
|
106
106
|
component_id=run.component_id,
|
|
107
107
|
backfill_id=run.backfill_id,
|
|
108
|
-
|
|
108
|
+
partition_key=run.partition_key,
|
|
109
109
|
status=run.status,
|
|
110
110
|
retry_of=run.retry_of,
|
|
111
111
|
attempt=run.attempt,
|
|
@@ -172,6 +172,8 @@ def list_runs(
|
|
|
172
172
|
component_id: UUID | None = None,
|
|
173
173
|
backfill_id: UUID | None = None,
|
|
174
174
|
status: str | None = None,
|
|
175
|
+
after: dt.datetime | None = None,
|
|
176
|
+
before: dt.datetime | None = None,
|
|
175
177
|
limit: int = 50,
|
|
176
178
|
offset: int = 0,
|
|
177
179
|
user: Profile = Depends(require_viewer),
|
|
@@ -180,16 +182,25 @@ def list_runs(
|
|
|
180
182
|
) -> list[RunResponse]:
|
|
181
183
|
"""List runs with optional filters.
|
|
182
184
|
|
|
185
|
+
``after``/``before`` bound the runs to those whose execution overlaps that
|
|
186
|
+
window — a run occupies ``started_at`` → ``completed_at`` (open-ended while
|
|
187
|
+
running), so runs that never started match no window. This is what a
|
|
188
|
+
timeline view over a time range asks for.
|
|
189
|
+
|
|
183
190
|
The total number of matching runs (ignoring ``limit``/``offset``) is
|
|
184
191
|
returned in the ``X-Total-Count`` response header so clients can paginate.
|
|
185
192
|
"""
|
|
186
|
-
total = store.count_runs(
|
|
193
|
+
total = store.count_runs(
|
|
194
|
+
org_id, component_id=component_id, backfill_id=backfill_id, status=status, after=after, before=before
|
|
195
|
+
)
|
|
187
196
|
response.headers["X-Total-Count"] = str(total)
|
|
188
197
|
runs = store.list_runs(
|
|
189
198
|
org_id,
|
|
190
199
|
component_id=component_id,
|
|
191
200
|
backfill_id=backfill_id,
|
|
192
201
|
status=status,
|
|
202
|
+
after=after,
|
|
203
|
+
before=before,
|
|
193
204
|
limit=limit,
|
|
194
205
|
offset=offset,
|
|
195
206
|
)
|
|
@@ -206,7 +217,10 @@ def create_run(
|
|
|
206
217
|
target = load_authorized(store.get_component, body.component_id, user, store, label="Component", minimum="editor")
|
|
207
218
|
if not KINDS[target.kind].runnable:
|
|
208
219
|
raise HTTPException(status_code=400, detail=f"Components of kind '{target.kind}' cannot be run")
|
|
209
|
-
|
|
220
|
+
try:
|
|
221
|
+
run = store.create_run(target.org_id, component_id=body.component_id, partition_key=body.partition_key)
|
|
222
|
+
except ValueError as e:
|
|
223
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
210
224
|
return _run_to_response(run)
|
|
211
225
|
|
|
212
226
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|