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.
Files changed (21) hide show
  1. {interloper_api-0.60.0 → interloper_api-0.62.0}/PKG-INFO +1 -1
  2. {interloper_api-0.60.0 → interloper_api-0.62.0}/pyproject.toml +1 -1
  3. {interloper_api-0.60.0 → interloper_api-0.62.0}/pyproject.toml.orig +1 -1
  4. {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/admin.py +56 -33
  5. {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/backfills.py +18 -16
  6. {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/runs.py +19 -5
  7. {interloper_api-0.60.0 → interloper_api-0.62.0}/README.md +0 -0
  8. {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/__init__.py +0 -0
  9. {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/app.py +0 -0
  10. {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/components.py +0 -0
  11. {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/dependencies.py +0 -0
  12. {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/email.py +0 -0
  13. {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/__init__.py +0 -0
  14. {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/agent.py +0 -0
  15. {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/auth.py +0 -0
  16. {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/catalog.py +0 -0
  17. {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/components.py +0 -0
  18. {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/oauth.py +0 -0
  19. {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/organisations.py +0 -0
  20. {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/tokens.py +0 -0
  21. {interloper_api-0.60.0 → interloper_api-0.62.0}/src/interloper_api/routes/ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: interloper-api
3
- Version: 0.60.0
3
+ Version: 0.62.0
4
4
  Summary: Interloper FastAPI routes
5
5
  Author: Guillaume Onfroy
6
6
  Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "interloper-api"
3
- version = "0.60.0"
3
+ version = "0.62.0"
4
4
  description = "Interloper FastAPI routes"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -3,7 +3,7 @@
3
3
  # ###############
4
4
  [project]
5
5
  name = "interloper-api"
6
- version = "0.60.0"
6
+ version = "0.62.0"
7
7
  description = "Interloper FastAPI routes"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
@@ -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, Field
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
- class AdminQuotaLimits(BaseModel):
232
- """One set of quota limits; null means unlimited (or unset, for overrides)."""
233
-
234
- max_sources: int | None = None
235
- max_assets_per_source: int | None = None
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(BaseModel):
276
- """Per-org quota overrides. Omitted fields keep their value; null clears one."""
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
- max_sources: int | None = Field(default=None, ge=0)
279
- max_assets_per_source: int | None = Field(default=None, ge=0)
280
- max_successful_runs_per_month: int | None = Field(default=None, ge=0)
281
- max_backfill_days: int | None = Field(default=None, ge=0)
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 AdminQuotaLimits(**{key: limits.get(key) for key in AdminQuotaLimits.model_fields})
482
- return AdminQuotaLimits(**{key: getattr(limits, key, None) for key in AdminQuotaLimits.model_fields})
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 field-by-field over the global defaults."""
487
- return AdminQuotaLimits(
488
- **{
489
- key: override if (override := getattr(overrides, key)) is not None else getattr(defaults, key)
490
- for key in AdminQuotaLimits.model_fields
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 the wire model's order.
517
+ """Field descriptors for admin quota surfaces, in registry order.
497
518
 
498
- Labels come from the registry definitions, so the frontend renders new
499
- quotas without a matching code change.
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 fields keep their value, null clears one."""
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.model_dump(exclude_unset=True))
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
- start_date: dt.date
31
- end_date: dt.date
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
- start_date: dt.date
44
- end_date: dt.date
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
- start_date=backfill.start_date,
68
- end_date=backfill.end_date,
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 date range."""
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
- backfill = store.create_backfill(
104
- job.org_id,
105
- component_id=body.component_id,
106
- start_date=body.start_date,
107
- end_date=body.end_date,
108
- concurrency=body.concurrency,
109
- fail_fast=body.fail_fast,
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
- partition_date: dt.date | None
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
- partition_date: dt.date | None = None
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
- partition_date=run.partition_date,
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(org_id, component_id=component_id, backfill_id=backfill_id, status=status)
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
- run = store.create_run(target.org_id, component_id=body.component_id, partition_date=body.partition_date)
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