interloper-api 0.57.0__tar.gz → 0.58.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.57.0 → interloper_api-0.58.0}/PKG-INFO +1 -1
  2. {interloper_api-0.57.0 → interloper_api-0.58.0}/pyproject.toml +1 -1
  3. {interloper_api-0.57.0 → interloper_api-0.58.0}/pyproject.toml.orig +1 -1
  4. {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/app.py +11 -1
  5. {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/dependencies.py +20 -0
  6. {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/admin.py +105 -1
  7. {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/backfills.py +33 -9
  8. {interloper_api-0.57.0 → interloper_api-0.58.0}/README.md +0 -0
  9. {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/__init__.py +0 -0
  10. {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/components.py +0 -0
  11. {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/email.py +0 -0
  12. {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/__init__.py +0 -0
  13. {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/agent.py +0 -0
  14. {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/auth.py +0 -0
  15. {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/catalog.py +0 -0
  16. {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/components.py +0 -0
  17. {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/oauth.py +0 -0
  18. {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/organisations.py +0 -0
  19. {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/runs.py +0 -0
  20. {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/tokens.py +0 -0
  21. {interloper_api-0.57.0 → interloper_api-0.58.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.57.0
3
+ Version: 0.58.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.57.0"
3
+ version = "0.58.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.57.0"
6
+ version = "0.58.0"
7
7
  description = "Interloper FastAPI routes"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
@@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI, Request
9
9
  from fastapi.middleware.cors import CORSMiddleware
10
10
  from fastapi.responses import JSONResponse
11
11
  from interloper.catalog.base import Catalog
12
- from interloper.errors import ComponentDriftError, NotFoundError
12
+ from interloper.errors import ComponentDriftError, NotFoundError, QuotaExceededError
13
13
  from interloper_db import Store
14
14
 
15
15
  from interloper_api.dependencies import (
@@ -17,6 +17,7 @@ from interloper_api.dependencies import (
17
17
  set_auth_config,
18
18
  set_catalog,
19
19
  set_features,
20
+ set_quota_defaults,
20
21
  set_smtp_config,
21
22
  set_store,
22
23
  )
@@ -76,6 +77,14 @@ def create_app(
76
77
  """
77
78
  return JSONResponse(status_code=409, content={"detail": str(exc)})
78
79
 
80
+ @app.exception_handler(QuotaExceededError)
81
+ async def _quota_exceeded_handler(_request: Request, exc: QuotaExceededError) -> JSONResponse:
82
+ """Store-level quota enforcement surfaces as 429 with structured context."""
83
+ return JSONResponse(
84
+ status_code=429,
85
+ content={"detail": {"message": str(exc), "quota": exc.quota, "limit": exc.limit, "used": exc.used}},
86
+ )
87
+
79
88
  if cors_origins:
80
89
  app.add_middleware(
81
90
  CORSMiddleware,
@@ -92,6 +101,7 @@ def create_app(
92
101
  if settings:
93
102
  set_auth_config(settings.auth)
94
103
  set_smtp_config(settings.smtp)
104
+ set_quota_defaults(settings.quota)
95
105
 
96
106
  api = APIRouter(prefix="/api")
97
107
  api.include_router(auth.router)
@@ -18,6 +18,7 @@ _auth_config: Any | None = None
18
18
  _smtp_config: Any | None = None
19
19
  _features: dict[str, bool] = {}
20
20
  _admin_config: Any | None = None
21
+ _quota_defaults: Any | None = None
21
22
 
22
23
  # Role hierarchy: admin > editor > viewer
23
24
  _ROLE_RANK = {"viewer": 0, "editor": 1, "admin": 2}
@@ -154,6 +155,25 @@ def get_admin_config() -> Any:
154
155
  return _admin_config
155
156
 
156
157
 
158
+ def set_quota_defaults(defaults: Any) -> None:
159
+ """Set the global default quota limits (from settings, at app creation).
160
+
161
+ Args:
162
+ defaults: The QuotaSettings instance.
163
+ """
164
+ global _quota_defaults # noqa: PLW0603
165
+ _quota_defaults = defaults
166
+
167
+
168
+ def get_quota_defaults() -> Any:
169
+ """Return the global default quota limits.
170
+
171
+ Returns:
172
+ The QuotaSettings instance, or None if not configured.
173
+ """
174
+ return _quota_defaults
175
+
176
+
157
177
  # -- Auth dependencies -------------------------------------------------------
158
178
 
159
179
 
@@ -8,6 +8,7 @@ no access to org-scoped *data* (sources, jobs, runs, …).
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import datetime as dt
11
12
  import inspect
12
13
  import logging
13
14
  from datetime import datetime
@@ -17,9 +18,10 @@ from uuid import UUID
17
18
 
18
19
  from fastapi import APIRouter, Depends, HTTPException, Request
19
20
  from interloper_db import Organisation, Profile, Store
21
+ from interloper_db.store.quotas import METRIC_SUCCESSFUL_RUNS
20
22
  from pydantic import BaseModel
21
23
 
22
- from interloper_api.dependencies import get_admin_config, get_store, require_super_admin
24
+ from interloper_api.dependencies import get_admin_config, get_quota_defaults, get_store, require_super_admin
23
25
  from interloper_api.email import send_invite_email
24
26
 
25
27
  logger = logging.getLogger(__name__)
@@ -223,6 +225,38 @@ class AdminDataConfig(BaseModel):
223
225
  catalog: dict[str, list[str]]
224
226
 
225
227
 
228
+ class AdminQuotaLimits(BaseModel):
229
+ """One set of quota limits; null means unlimited (or unset, for overrides)."""
230
+
231
+ max_sources: int | None = None
232
+ max_assets_per_source: int | None = None
233
+ max_successful_runs_per_month: int | None = None
234
+
235
+
236
+ class AdminOrgQuotaStatus(BaseModel):
237
+ """One organisation's quota limits and current usage."""
238
+
239
+ id: UUID
240
+ name: str
241
+ limits: AdminQuotaLimits
242
+ effective: AdminQuotaLimits
243
+ sources: int
244
+ max_assets_per_source: int
245
+ successful_runs: int
246
+ reserved_runs: int
247
+ #: Recomputed from the runs table; a mismatch with ``successful_runs``
248
+ #: (the ledger) signals counter drift.
249
+ recomputed_successful_runs: int
250
+
251
+
252
+ class AdminQuotasResponse(BaseModel):
253
+ """Quota overview: global defaults plus per-organisation status."""
254
+
255
+ period_start: dt.date
256
+ defaults: AdminQuotaLimits
257
+ organisations: list[AdminOrgQuotaStatus]
258
+
259
+
226
260
  class AdminConfigResponse(BaseModel):
227
261
  """Read-only, secrets-redacted snapshot of the instance configuration."""
228
262
 
@@ -230,6 +264,7 @@ class AdminConfigResponse(BaseModel):
230
264
  auth: AdminAuthConfig
231
265
  services: AdminServicesConfig
232
266
  data: AdminDataConfig
267
+ quotas: AdminQuotaLimits
233
268
 
234
269
 
235
270
  # Non-secret launcher/runner config keys exposed on /admin/config. Anything not
@@ -400,12 +435,39 @@ def build_config_snapshot(settings: Any, features: dict[str, bool], catalog: Any
400
435
  encryption_configured=bool(settings.secrets.encryption_key),
401
436
  catalog=_catalog_by_kind(catalog) if catalog is not None else {},
402
437
  ),
438
+ quotas=_quota_limits(settings.quota),
403
439
  )
404
440
 
405
441
 
406
442
  # -- Helpers ------------------------------------------------------------------
407
443
 
408
444
 
445
+ def _quota_limits(limits: Any) -> AdminQuotaLimits:
446
+ """Map any limits-shaped object (settings, Quota row, None) to the model."""
447
+ return AdminQuotaLimits(
448
+ max_sources=getattr(limits, "max_sources", None),
449
+ max_assets_per_source=getattr(limits, "max_assets_per_source", None),
450
+ max_successful_runs_per_month=getattr(limits, "max_successful_runs_per_month", None),
451
+ )
452
+
453
+
454
+ def _effective_limits(overrides: AdminQuotaLimits, defaults: AdminQuotaLimits) -> AdminQuotaLimits:
455
+ """Per-org overrides win field-by-field over the global defaults."""
456
+ return AdminQuotaLimits(
457
+ max_sources=overrides.max_sources if overrides.max_sources is not None else defaults.max_sources,
458
+ max_assets_per_source=(
459
+ overrides.max_assets_per_source
460
+ if overrides.max_assets_per_source is not None
461
+ else defaults.max_assets_per_source
462
+ ),
463
+ max_successful_runs_per_month=(
464
+ overrides.max_successful_runs_per_month
465
+ if overrides.max_successful_runs_per_month is not None
466
+ else defaults.max_successful_runs_per_month
467
+ ),
468
+ )
469
+
470
+
409
471
  def _require_org(store: Store, org_id: UUID) -> Organisation:
410
472
  """Fetch an organisation or raise 404."""
411
473
  org = store.get_organisation(org_id)
@@ -466,6 +528,48 @@ def get_instance_config(
466
528
  return config
467
529
 
468
530
 
531
+ # -- Quotas ---------------------------------------------------------------------
532
+
533
+
534
+ @router.get("/quotas")
535
+ def get_quotas(
536
+ user: Profile = Depends(require_super_admin),
537
+ store: Store = Depends(get_store),
538
+ quota_defaults: Any = Depends(get_quota_defaults),
539
+ ) -> AdminQuotasResponse:
540
+ """Quota limits and current-period usage for every organisation."""
541
+ defaults = _quota_limits(quota_defaults)
542
+ period_start = store.current_period_start()
543
+ overrides = {quota.org_id: quota for quota in store.list_quotas()}
544
+ usage = {
545
+ row.org_id: row
546
+ for row in store.list_usage(period_start=period_start)
547
+ if row.metric == METRIC_SUCCESSFUL_RUNS
548
+ }
549
+ sources = store.count_sources_by_org()
550
+ max_assets = store.max_assets_per_source_by_org()
551
+ recomputed = store.count_successful_runs_by_org(period_start)
552
+
553
+ organisations = []
554
+ for org, _member_count in store.list_all_organisations():
555
+ limits = _quota_limits(overrides.get(org.id))
556
+ org_usage = usage.get(org.id)
557
+ organisations.append(
558
+ AdminOrgQuotaStatus(
559
+ id=org.id,
560
+ name=org.name,
561
+ limits=limits,
562
+ effective=_effective_limits(limits, defaults),
563
+ sources=sources.get(org.id, 0),
564
+ max_assets_per_source=max_assets.get(org.id, 0),
565
+ successful_runs=org_usage.used if org_usage else 0,
566
+ reserved_runs=org_usage.reserved if org_usage else 0,
567
+ recomputed_successful_runs=recomputed.get(org.id, 0),
568
+ )
569
+ )
570
+ return AdminQuotasResponse(period_start=period_start, defaults=defaults, organisations=organisations)
571
+
572
+
469
573
  # -- Users --------------------------------------------------------------------
470
574
 
471
575
 
@@ -1,4 +1,4 @@
1
- """Backfills API: read endpoints for backfill state."""
1
+ """Backfills API: queue, inspect, and cancel backfills."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -100,14 +100,17 @@ def create_backfill(
100
100
  job = load_authorized(
101
101
  lambda i: store.get_component(i, kind="job"), body.component_id, user, store, label="Job", minimum="editor"
102
102
  )
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
- )
103
+ try:
104
+ backfill = store.create_backfill(
105
+ job.org_id,
106
+ component_id=body.component_id,
107
+ start_date=body.start_date,
108
+ end_date=body.end_date,
109
+ concurrency=body.concurrency,
110
+ fail_fast=body.fail_fast,
111
+ )
112
+ except ValueError as e: # span cap
113
+ raise HTTPException(status_code=400, detail=str(e))
111
114
  return _backfill_to_response(backfill)
112
115
 
113
116
 
@@ -124,3 +127,24 @@ def get_backfill(
124
127
  raise HTTPException(status_code=404, detail=f"Backfill {backfill_id} not found")
125
128
  authorize_org_member(user, backfill.org_id, store, detail=f"Backfill {backfill_id} not found")
126
129
  return _backfill_to_response(backfill)
130
+
131
+
132
+ @router.post("/{backfill_id}/cancel")
133
+ def cancel_backfill(
134
+ backfill_id: UUID,
135
+ user: Profile = Depends(get_current_user),
136
+ store: Store = Depends(get_store),
137
+ ) -> BackfillResponse:
138
+ """Cancel a backfill: pending and queued runs are canceled, in-flight runs drain."""
139
+ try:
140
+ backfill = store.get_backfill(backfill_id)
141
+ except NotFoundError:
142
+ raise HTTPException(status_code=404, detail=f"Backfill {backfill_id} not found")
143
+ authorize_org_member(
144
+ user, backfill.org_id, store, minimum="editor", detail=f"Backfill {backfill_id} not found"
145
+ )
146
+ try:
147
+ backfill = store.cancel_backfill(backfill_id)
148
+ except ValueError as e:
149
+ raise HTTPException(status_code=409, detail=str(e))
150
+ return _backfill_to_response(backfill)