interloper-api 0.95.0__tar.gz → 0.97.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 (28) hide show
  1. {interloper_api-0.95.0 → interloper_api-0.97.0}/PKG-INFO +1 -1
  2. {interloper_api-0.95.0 → interloper_api-0.97.0}/pyproject.toml +1 -1
  3. {interloper_api-0.95.0 → interloper_api-0.97.0}/pyproject.toml.orig +1 -1
  4. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/app.py +2 -0
  5. interloper_api-0.97.0/src/interloper_api/routes/overview.py +952 -0
  6. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/routes/runs.py +25 -14
  7. interloper_api-0.97.0/src/interloper_api/utils/__init__.py +5 -0
  8. interloper_api-0.97.0/src/interloper_api/utils/time.py +41 -0
  9. {interloper_api-0.95.0 → interloper_api-0.97.0}/README.md +0 -0
  10. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/__init__.py +0 -0
  11. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/dependencies/__init__.py +0 -0
  12. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/dependencies/auth.py +0 -0
  13. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/dependencies/rbac.py +0 -0
  14. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/dependencies/state.py +0 -0
  15. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/notifications/__init__.py +0 -0
  16. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/notifications/invitations.py +0 -0
  17. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/routes/__init__.py +0 -0
  18. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/routes/admin.py +0 -0
  19. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/routes/agent.py +0 -0
  20. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/routes/auth.py +0 -0
  21. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/routes/backfills.py +0 -0
  22. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/routes/catalog.py +0 -0
  23. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/routes/components.py +0 -0
  24. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/routes/health.py +0 -0
  25. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/routes/oauth.py +0 -0
  26. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/routes/organisations.py +0 -0
  27. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/routes/tokens.py +0 -0
  28. {interloper_api-0.95.0 → interloper_api-0.97.0}/src/interloper_api/routes/websocket.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: interloper-api
3
- Version: 0.95.0
3
+ Version: 0.97.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.95.0"
3
+ version = "0.97.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.95.0"
6
+ version = "0.97.0"
7
7
  description = "Interloper FastAPI routes"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
@@ -31,6 +31,7 @@ from interloper_api.routes import (
31
31
  health,
32
32
  oauth,
33
33
  organisations,
34
+ overview,
34
35
  runs,
35
36
  tokens,
36
37
  websocket,
@@ -49,6 +50,7 @@ _ROUTE_MODULES: tuple[ModuleType, ...] = (
49
50
  components,
50
51
  runs,
51
52
  backfills,
53
+ overview,
52
54
  oauth,
53
55
  tokens,
54
56
  websocket,
@@ -0,0 +1,952 @@
1
+ """Overview API: the landing page's health, attention, schedule and inventory in one read.
2
+
3
+ The store answers what the data says; this module answers what it means
4
+ for the reader. Each section of the response is built from store rows by its
5
+ own model's ``from_*`` constructor, so every tile and list on the page shares
6
+ one vocabulary (see ``docs/superpowers/specs/2026-09-30-overview-page-design.md``).
7
+
8
+ Run and event timestamps pass through ``assume_utc`` before any arithmetic:
9
+ Postgres hands ``TIMESTAMPTZ`` columns back aware, but SQLite has no timezone
10
+ type and hands them back naive, and comparing those with an aware ``now``
11
+ would raise.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import datetime as dt
17
+ from collections import Counter, defaultdict
18
+ from dataclasses import dataclass
19
+ from typing import Annotated, Any, Literal
20
+ from uuid import UUID
21
+
22
+ from fastapi import APIRouter, HTTPException, Query
23
+ from interloper.partitioning.time import TimeGranularity, TimePartitionConfig, TimePartitionWindow
24
+ from interloper.utils.time import assume_utc
25
+ from interloper_db import Backfill, Component, ComponentStatus
26
+ from interloper_db.models import Run
27
+ from interloper_db.store.events import CoverageRow, ErrorGroup, PartitionBounds
28
+ from pydantic import BaseModel
29
+
30
+ from interloper_api.dependencies import OrgIdDep, StoreDep, ViewerDep
31
+ from interloper_api.routes.runs import RunResponse
32
+ from interloper_api.utils import format_duration, job_zone
33
+
34
+ router = APIRouter(prefix="/overview", tags=["overview"])
35
+
36
+ OVERDUE_AFTER = dt.timedelta(minutes=15)
37
+ # Verdicts only: a step-level failure (dest_write_failed and the like) repeats its operation's own verdict.
38
+ FAILURE_EVENT_TYPES = ("operation_failed", "run_failed")
39
+ INVENTORY_KINDS = ("source", "asset", "destination", "connection", "job", "hook")
40
+ HOOK_EVENTS_LOOKBACK = dt.timedelta(days=30)
41
+ RECENT_LIMIT = 5
42
+ MAX_COVERAGE_SPAN_DAYS = 400
43
+ TERMINAL_STATUSES = frozenset({"success", "failed", "canceled"})
44
+ DRIFT_TITLES = {
45
+ ComponentStatus.MISSING: "{name} is missing from the catalog",
46
+ ComponentStatus.DISABLED: "{name} is disabled in this deployment",
47
+ ComponentStatus.UNREADABLE: "{name} has an unreadable config",
48
+ }
49
+
50
+
51
+ # -- Response models -----------------------------------------------------------
52
+
53
+
54
+ class HourBucket(BaseModel):
55
+ """Attempts that finished inside one hour, by verdict."""
56
+
57
+ hour: dt.datetime
58
+ succeeded: int
59
+ failed: int
60
+
61
+
62
+ class RunsSummary(BaseModel):
63
+ """Attempts finished over the last 24 clock hours, with their hourly profile.
64
+
65
+ The window starts at the top of the hour 23 hours before *now*'s and ends
66
+ at *now*, so the totals always equal the sum of the bars.
67
+ """
68
+
69
+ total: int
70
+ succeeded: int
71
+ failed: int
72
+ hourly: list[HourBucket]
73
+
74
+ @classmethod
75
+ def from_runs(cls, runs: list[Run], now: dt.datetime) -> RunsSummary:
76
+ """Bucket the attempts that finished in the window by the clock hour they completed in.
77
+
78
+ Args:
79
+ runs: Attempts completed around the window, whether or not they
80
+ started; those that did not complete inside it, or ended other
81
+ than in success or failure, are left out.
82
+ now: The window's end, aware UTC.
83
+
84
+ Returns:
85
+ The verdict totals and 24 hourly buckets, oldest first.
86
+ """
87
+ start = now.replace(minute=0, second=0, microsecond=0) - dt.timedelta(hours=23)
88
+ counts: dict[dt.datetime, Counter[str]] = defaultdict(Counter)
89
+ for run in runs:
90
+ if run.completed_at is None or run.status not in ("success", "failed"):
91
+ continue
92
+ completed = assume_utc(run.completed_at).astimezone(dt.timezone.utc)
93
+ if start <= completed <= now:
94
+ counts[completed.replace(minute=0, second=0, microsecond=0)][run.status] += 1
95
+ hourly = [
96
+ HourBucket(hour=hour, succeeded=counts[hour]["success"], failed=counts[hour]["failed"])
97
+ for hour in (start + dt.timedelta(hours=i) for i in range(24))
98
+ ]
99
+ succeeded = sum(bucket.succeeded for bucket in hourly)
100
+ failed = sum(bucket.failed for bucket in hourly)
101
+ return cls(total=succeeded + failed, succeeded=succeeded, failed=failed, hourly=hourly)
102
+
103
+
104
+ class ActivitySummary(BaseModel):
105
+ """What is executing right now."""
106
+
107
+ running: int
108
+ queued: int
109
+ longest_running_seconds: float | None = None
110
+
111
+ @classmethod
112
+ def from_runs(cls, running: list[Run], queued: int, now: dt.datetime) -> ActivitySummary:
113
+ """Summarise the attempts in flight.
114
+
115
+ Args:
116
+ running: The attempts currently running.
117
+ queued: How many attempts wait in the queue.
118
+ now: The reference instant the running time is measured to.
119
+
120
+ Returns:
121
+ The counts, and the longest running time in seconds (``None``
122
+ when nothing that started is running).
123
+ """
124
+ longest = max(
125
+ ((now - assume_utc(run.started_at)).total_seconds() for run in running if run.started_at), default=None
126
+ )
127
+ return cls(running=len(running), queued=queued, longest_running_seconds=longest)
128
+
129
+
130
+ class BackfillsSummary(BaseModel):
131
+ """Backfills still queued or running, their partitions rolled up."""
132
+
133
+ active: int
134
+ partitions_done: int
135
+ partitions_total: int
136
+
137
+ @classmethod
138
+ def from_backfills(cls, backfills: list[Backfill], counts: dict[UUID, dict[str, int]]) -> BackfillsSummary:
139
+ """Roll the active backfills' partitions up into one progress figure.
140
+
141
+ Args:
142
+ backfills: The active backfills.
143
+ counts: Each backfill's runs per status, by backfill id; a
144
+ backfill absent from it has no runs counted as done.
145
+
146
+ Returns:
147
+ The active count, the partitions in a terminal status and the
148
+ partitions in total.
149
+ """
150
+ done = sum(
151
+ n
152
+ for backfill in backfills
153
+ for status, n in counts.get(backfill.id, {}).items()
154
+ if status in TERMINAL_STATUSES
155
+ )
156
+ return cls(
157
+ active=len(backfills),
158
+ partitions_done=done,
159
+ partitions_total=sum(backfill.partitions for backfill in backfills),
160
+ )
161
+
162
+
163
+ class JobsSummary(BaseModel):
164
+ """Enabled jobs, and how many of them last ended in failure."""
165
+
166
+ enabled: int
167
+ failing: int
168
+
169
+ @classmethod
170
+ def from_jobs(cls, enabled_jobs: list[Component], failing_ids: set[UUID]) -> JobsSummary:
171
+ """Count the enabled jobs and those whose latest attempt failed.
172
+
173
+ Args:
174
+ enabled_jobs: The organisation's enabled job rows.
175
+ failing_ids: Ids of the jobs whose latest attempt failed.
176
+
177
+ Returns:
178
+ The two counts.
179
+ """
180
+ return cls(enabled=len(enabled_jobs), failing=sum(1 for job in enabled_jobs if job.id in failing_ids))
181
+
182
+
183
+ class AttentionItem(BaseModel):
184
+ """One thing that needs a person, with what to open to act on it."""
185
+
186
+ kind: Literal["error_group", "connection", "run_stack", "drift", "overdue"]
187
+ severity: Literal["error", "warning"]
188
+ title: str
189
+ target: str | None = None
190
+ since: dt.datetime | None = None
191
+ component_id: UUID | None = None
192
+ component_kind: str | None = None
193
+ run_id: UUID | None = None
194
+
195
+ @classmethod
196
+ def from_error_groups(cls, groups: list[ErrorGroup], names: dict[UUID, str]) -> list[AttentionItem]:
197
+ """Merge error groups by job and the error's first line.
198
+
199
+ Args:
200
+ groups: The store's error groups (one per job, run, component, type and text).
201
+ names: Job names by id; a group whose run targets anything else
202
+ (an ad-hoc asset run, a deleted target) names no job.
203
+
204
+ Returns:
205
+ One error per job and first line, the most runs first, then by title.
206
+ """
207
+ merged: dict[tuple[UUID | None, str], dict[str, Any]] = {}
208
+ for group in groups:
209
+ line = group.error.splitlines()[0].strip() if group.error else ""
210
+ entry = merged.setdefault(
211
+ (group.job_id, line), {"runs": set(), "last_seen": group.last_seen, "sample": group.run_id}
212
+ )
213
+ entry["runs"].add(group.run_id)
214
+ if group.last_seen > entry["last_seen"]:
215
+ entry["last_seen"] = group.last_seen
216
+ entry["sample"] = group.run_id
217
+ pairs = []
218
+ for (job_id, line), entry in merged.items():
219
+ count = len(entry["runs"])
220
+ name = names.get(job_id) if job_id is not None else None
221
+ item = cls(
222
+ kind="error_group",
223
+ severity="error",
224
+ title=f"{count} run{'s' if count != 1 else ''} failed: {line}",
225
+ target=name,
226
+ since=assume_utc(entry["last_seen"]),
227
+ component_id=job_id,
228
+ component_kind="job" if name is not None else None,
229
+ run_id=entry["sample"],
230
+ )
231
+ pairs.append((count, item))
232
+ return [item for _, item in sorted(pairs, key=lambda pair: (-pair[0], pair[1].title))]
233
+
234
+ @classmethod
235
+ def from_connections(cls, connections: list[Component]) -> list[AttentionItem]:
236
+ """Flag the connections whose last renewal failed.
237
+
238
+ Args:
239
+ connections: The organisation's connection rows.
240
+
241
+ Returns:
242
+ One error per connection carrying ``state.last_renewal_error``.
243
+ """
244
+ items = []
245
+ for connection in connections:
246
+ error = (connection.state or {}).get("last_renewal_error")
247
+ if not error:
248
+ continue
249
+ items.append(
250
+ cls(
251
+ kind="connection",
252
+ severity="error",
253
+ title=f"{connection.name or connection.key} connection needs re-authorisation",
254
+ target=str(error).splitlines()[0],
255
+ since=connection.state_datetime("last_renewed_at"),
256
+ component_id=connection.id,
257
+ component_kind="connection",
258
+ )
259
+ )
260
+ return items
261
+
262
+ @classmethod
263
+ def from_run_stacks(
264
+ cls, failed: list[Run], names: dict[UUID, str], since: dt.datetime, now: dt.datetime
265
+ ) -> list[AttentionItem]:
266
+ """Flag the stacks whose latest attempt is a retry that still failed.
267
+
268
+ Every stack counts, not only each job's newest one, so a partition
269
+ still failing after retries stays listed once a later run starts.
270
+
271
+ Args:
272
+ failed: The latest attempt of each job stack whose latest attempt
273
+ failed, whether or not that attempt ever started.
274
+ names: Job names by id.
275
+ since: Only stacks that finished at or after this instant count.
276
+ now: Only stacks that finished by this instant count.
277
+
278
+ Returns:
279
+ One error per stack.
280
+ """
281
+ items = []
282
+ for run in failed:
283
+ if run.status != "failed" or run.attempt <= 1 or run.completed_at is None:
284
+ continue
285
+ completed = assume_utc(run.completed_at)
286
+ if not since <= completed <= now:
287
+ continue
288
+ name = names.get(run.component_id) if run.component_id else None
289
+ items.append(
290
+ cls(
291
+ kind="run_stack",
292
+ severity="error",
293
+ title=f"Still failing after {run.attempt} attempts",
294
+ target=f"{name} · {run.partition_key}" if run.partition_key else name,
295
+ since=completed,
296
+ component_id=run.component_id,
297
+ component_kind="job",
298
+ run_id=run.id,
299
+ )
300
+ )
301
+ return items
302
+
303
+ @classmethod
304
+ def from_drift(cls, statuses: list[tuple[Component, ComponentStatus]]) -> list[AttentionItem]:
305
+ """Flag the components whose catalog status is not ``ok``.
306
+
307
+ An owned asset whose source is itself drifted is left out: it drifts
308
+ because its source does, and the source's one warning covers it.
309
+
310
+ Args:
311
+ statuses: Every component row with its read status.
312
+
313
+ Returns:
314
+ One warning per drifted component whose parent, if any, is not drifted.
315
+ """
316
+ drifted = {component.id for component, status in statuses if status is not ComponentStatus.OK}
317
+ return [
318
+ cls(
319
+ kind="drift",
320
+ severity="warning",
321
+ title=DRIFT_TITLES[status].format(name=component.name or component.key),
322
+ target=component.key,
323
+ component_id=component.id,
324
+ component_kind=component.kind,
325
+ )
326
+ for component, status in statuses
327
+ if status is not ComponentStatus.OK and component.parent_id not in drifted
328
+ ]
329
+
330
+ @classmethod
331
+ def from_overdue(cls, jobs: list[Component], now: dt.datetime) -> list[AttentionItem]:
332
+ """Flag the enabled jobs whose next slot passed more than :data:`OVERDUE_AFTER` ago.
333
+
334
+ Args:
335
+ jobs: The organisation's job rows.
336
+ now: The reference instant.
337
+
338
+ Returns:
339
+ One warning per overdue job.
340
+ """
341
+ items = []
342
+ for job in jobs:
343
+ due = job.state_datetime("next_run_at")
344
+ if not job.enabled or due is None or now - due <= OVERDUE_AFTER:
345
+ continue
346
+ items.append(
347
+ cls(
348
+ kind="overdue",
349
+ severity="warning",
350
+ title=f"Scheduled run is {format_duration(now - due)} overdue",
351
+ target=job.name or job.key,
352
+ since=due,
353
+ component_id=job.id,
354
+ component_kind="job",
355
+ )
356
+ )
357
+ return items
358
+
359
+
360
+ class UpcomingRun(BaseModel):
361
+ """A job's next firing and the partition window it will cover."""
362
+
363
+ job_id: UUID
364
+ job_name: str
365
+ next_run_at: dt.datetime
366
+ start_key: str | None = None
367
+ end_key: str | None = None
368
+
369
+ @classmethod
370
+ def from_jobs(cls, jobs: list[Component], granularities: dict[UUID, TimeGranularity | None]) -> list[UpcomingRun]:
371
+ """List the enabled jobs by their next firing, each with the window that firing covers.
372
+
373
+ The window is resolved the way the scheduler resolves it: the job's
374
+ ``lookback`` and ``offset`` over its targets' granularity, on the job
375
+ timezone's clock at the firing instant.
376
+
377
+ Args:
378
+ jobs: The organisation's job rows.
379
+ granularities: Each job's target granularity by id; a job absent
380
+ from it, or mapped to ``None``, is unpartitioned.
381
+
382
+ Returns:
383
+ Upcoming firings, soonest first; jobs without a stored next slot are absent.
384
+ """
385
+ upcoming = []
386
+ for job in jobs:
387
+ next_run = job.state_datetime("next_run_at")
388
+ if not job.enabled or next_run is None:
389
+ continue
390
+ window = cls._window(job, granularities.get(job.id), next_run)
391
+ upcoming.append(
392
+ cls(
393
+ job_id=job.id,
394
+ job_name=job.name or job.key,
395
+ next_run_at=next_run,
396
+ start_key=window.granularity.format(window.start) if window else None,
397
+ end_key=window.granularity.format(window.end) if window else None,
398
+ )
399
+ )
400
+ return sorted(upcoming, key=lambda run: run.next_run_at)
401
+
402
+ @classmethod
403
+ def _window(
404
+ cls, job: Component, granularity: TimeGranularity | None, fires_at: dt.datetime
405
+ ) -> TimePartitionWindow | None:
406
+ """Resolve the partition window a job's firing at *fires_at* will cover.
407
+
408
+ Args:
409
+ job: The job row, whose config carries ``lookback``, ``offset`` and ``timezone``.
410
+ granularity: The granularity its targets share, ``None`` when unpartitioned.
411
+ fires_at: The firing instant, aware.
412
+
413
+ Returns:
414
+ The window, or ``None`` for an unpartitioned job, one whose lookback
415
+ is explicitly null, or one whose lookback or offset is out of range.
416
+ """
417
+ config = job.config or {}
418
+ lookback = config.get("lookback", 1)
419
+ if not lookback or granularity is None:
420
+ return None
421
+ try:
422
+ return TimePartitionWindow.lookback(
423
+ fires_at.astimezone(job_zone(config.get("timezone"))),
424
+ lookback=lookback,
425
+ offset=config.get("offset", 1),
426
+ granularity=granularity,
427
+ )
428
+ except ValueError:
429
+ return None
430
+
431
+
432
+ class KindInventory(BaseModel):
433
+ """How many components of one kind sit in each state."""
434
+
435
+ kind: str
436
+ total: int
437
+ healthy: int
438
+ failing: int
439
+ attention: int
440
+ disabled: int
441
+
442
+ @classmethod
443
+ def from_statuses(
444
+ cls,
445
+ statuses: list[tuple[Component, ComponentStatus]],
446
+ failing_ids: set[UUID],
447
+ attention_ids: set[UUID],
448
+ ) -> list[KindInventory]:
449
+ """Count each kind's components by state, first match winning: disabled, failing, attention, healthy.
450
+
451
+ Args:
452
+ statuses: Every component row with its read status.
453
+ failing_ids: Components whose latest execution, attempt or hook event failed.
454
+ attention_ids: Components needing attention for a reason other than drift.
455
+
456
+ Returns:
457
+ One row per kind in :data:`INVENTORY_KINDS`, in that order.
458
+ """
459
+ counts: dict[str, Counter[str]] = {kind: Counter() for kind in INVENTORY_KINDS}
460
+ for component, status in statuses:
461
+ if component.kind not in counts:
462
+ continue
463
+ if not component.enabled:
464
+ state = "disabled"
465
+ elif component.id in failing_ids:
466
+ state = "failing"
467
+ elif status is not ComponentStatus.OK or component.id in attention_ids:
468
+ state = "attention"
469
+ else:
470
+ state = "healthy"
471
+ counts[component.kind][state] += 1
472
+ return [
473
+ cls(
474
+ kind=kind,
475
+ total=sum(tally.values()),
476
+ healthy=tally["healthy"],
477
+ failing=tally["failing"],
478
+ attention=tally["attention"],
479
+ disabled=tally["disabled"],
480
+ )
481
+ for kind, tally in counts.items()
482
+ ]
483
+
484
+
485
+ class OverviewResponse(BaseModel):
486
+ """Everything the overview page draws, except the coverage calendar."""
487
+
488
+ generated_at: dt.datetime
489
+ runs: RunsSummary
490
+ activity: ActivitySummary
491
+ backfills: BackfillsSummary
492
+ jobs: JobsSummary
493
+ attention: list[AttentionItem]
494
+ upcoming: list[UpcomingRun]
495
+ recent: list[RunResponse]
496
+ components: list[KindInventory]
497
+
498
+
499
+ @dataclass(frozen=True)
500
+ class AssetCoverage:
501
+ """One partitioned asset's evidence, and what the calendar expects of it.
502
+
503
+ Attributes:
504
+ asset: The asset row.
505
+ group: The row the asset counts toward on the calendar: its parent
506
+ source, or the asset itself when standalone.
507
+ partitioning: The asset's partitioning, from its catalog definition.
508
+ bounds: The days its attempted partitions span, all-time, or ``None``
509
+ when it was never attempted.
510
+ scheduled: Whether an enabled job targets the asset or its parent source.
511
+ rows: Its coverage rows for the window.
512
+ """
513
+
514
+ asset: Component
515
+ group: Component
516
+ partitioning: TimePartitionConfig
517
+ bounds: PartitionBounds | None
518
+ scheduled: bool
519
+ rows: list[CoverageRow]
520
+
521
+ @classmethod
522
+ def from_components(
523
+ cls,
524
+ components: list[Component],
525
+ partitionings: dict[UUID, TimePartitionConfig],
526
+ bounds: dict[UUID, PartitionBounds],
527
+ rows: list[CoverageRow],
528
+ ) -> list[AssetCoverage]:
529
+ """Pair each partitioned asset row with its group, partitioning, bounds and evidence.
530
+
531
+ Args:
532
+ components: The organisation's source, asset and job rows, with
533
+ their outgoing relations loaded.
534
+ partitionings: Each partitioned asset's partitioning by id; an
535
+ asset absent from it (unpartitioned, drifted) is left out.
536
+ bounds: Each attempted asset's all-time bounds by id.
537
+ rows: The store's coverage rows for the window, from runs of any target.
538
+
539
+ Returns:
540
+ One entry per partitioned asset, in the order of *components*.
541
+ """
542
+ by_id = {component.id: component for component in components}
543
+ targeted = {
544
+ relation.dst_id
545
+ for job in components
546
+ if job.kind == "job" and job.enabled
547
+ for relation in job.out_relations
548
+ if relation.name == "targets"
549
+ }
550
+ rows_by_asset: dict[UUID, list[CoverageRow]] = defaultdict(list)
551
+ for row in rows:
552
+ rows_by_asset[row.asset_id].append(row)
553
+ coverages = []
554
+ for asset in components:
555
+ if asset.kind != "asset" or asset.id not in partitionings:
556
+ continue
557
+ group = by_id.get(asset.parent_id) if asset.parent_id else asset
558
+ if group is None:
559
+ continue
560
+ coverages.append(
561
+ cls(
562
+ asset=asset,
563
+ group=group,
564
+ partitioning=partitionings[asset.id],
565
+ bounds=bounds.get(asset.id),
566
+ scheduled=asset.id in targeted or asset.parent_id in targeted,
567
+ rows=rows_by_asset[asset.id],
568
+ )
569
+ )
570
+ return coverages
571
+
572
+ def expected_span(self, now: dt.datetime) -> tuple[dt.date, dt.date] | None:
573
+ """The days the asset owes partitions for, before clipping to a window.
574
+
575
+ The span starts at the declared ``start`` when there is one, else on
576
+ the first day of the earliest attempted partition. It ends on the last
577
+ day of the latest attempted partition; a scheduled asset that is
578
+ enabled, under an enabled group, also owes every period closed before
579
+ *now*: up to yesterday for a daily asset, the end of last month or
580
+ year for a monthly or yearly one, and the hours elapsed today for an
581
+ hourly one.
582
+
583
+ Args:
584
+ now: The reference instant, aware UTC.
585
+
586
+ Returns:
587
+ The first and last day owed, inclusive, or ``None`` when nothing
588
+ is owed: no evidence and no declared start, or a declared start
589
+ with no evidence and no schedule.
590
+ """
591
+ granularity = self.partitioning.granularity
592
+ start = self.partitioning.start
593
+ if start is not None:
594
+ first = start.date() if isinstance(start, dt.datetime) else start
595
+ else:
596
+ first = self.bounds.first if self.bounds else None
597
+ last = self.bounds.last if self.bounds else None
598
+ if self.scheduled and self.asset.enabled and self.group.enabled:
599
+ if granularity is TimeGranularity.HOUR:
600
+ closed = (now - dt.timedelta(hours=1)).date()
601
+ else:
602
+ closed = granularity.truncate(now.date()) - dt.timedelta(days=1)
603
+ last = closed if last is None else max(last, closed)
604
+ if first is None or last is None:
605
+ return None
606
+ return first, last
607
+
608
+ def slots(self, day: dt.date, now: dt.datetime) -> int:
609
+ """How many partitions the asset owes on one day of its span.
610
+
611
+ An hourly asset owes the hours of the day from its declared start, when
612
+ the start falls on that day, to *now*'s hour, when the day is today
613
+ (the hours elapsed since midnight UTC), else to midnight: 24 on a
614
+ full day, at least one. Any other asset owes one slot a day.
615
+
616
+ Args:
617
+ day: A day of the asset's expected span.
618
+ now: The reference instant, aware UTC.
619
+
620
+ Returns:
621
+ The slots owed, before raising to the partitions attempted that day.
622
+ """
623
+ if self.partitioning.granularity is not TimeGranularity.HOUR:
624
+ return 1
625
+ start = self.partitioning.start
626
+ first_hour = start.hour if isinstance(start, dt.datetime) and start.date() == day else 0
627
+ end_hour = now.hour if day == now.date() else 24
628
+ return max(1, end_hour - first_hour)
629
+
630
+
631
+ class CoverageDay(BaseModel):
632
+ """One group's asset-partitions on one day: expected, covered, failed."""
633
+
634
+ date: dt.date
635
+ source_id: UUID
636
+ expected: int
637
+ covered: int
638
+ failed: int
639
+ failed_run_id: UUID | None = None
640
+
641
+ @classmethod
642
+ def from_assets(
643
+ cls, assets: list[AssetCoverage], since: dt.date, until: dt.date, now: dt.datetime
644
+ ) -> list[CoverageDay]:
645
+ """Roll every asset's days onto its group, summing per group and day.
646
+
647
+ Args:
648
+ assets: The partitioned assets with their evidence.
649
+ since: First day of the window.
650
+ until: Last day of the window, inclusive.
651
+ now: The reference instant, aware UTC.
652
+
653
+ Returns:
654
+ One entry per group and day with anything expected, by group id then day.
655
+ """
656
+ merged: dict[tuple[UUID, dt.date], CoverageDay] = {}
657
+ for asset in assets:
658
+ for day in cls.from_asset(asset, since, until, now):
659
+ slot = (day.source_id, day.date)
660
+ merged[slot] = merged[slot]._plus(day) if slot in merged else day
661
+ return [merged[slot] for slot in sorted(merged)]
662
+
663
+ @classmethod
664
+ def from_asset(cls, asset: AssetCoverage, since: dt.date, until: dt.date, now: dt.datetime) -> list[CoverageDay]:
665
+ """Roll one asset's partitions onto the days of its expected span, clipped to the window and today.
666
+
667
+ Each day owes the asset's :meth:`AssetCoverage.slots`, raised to the
668
+ partitions attempted on it when more. A
669
+ monthly or yearly key counts toward every day it spans. A partition
670
+ is covered once any execution succeeded, failed when one failed and
671
+ none succeeded; one attempted but still in flight, or canceled, is
672
+ neither, so it reads as missing, as does a day with nothing attempted.
673
+ The failed run kept for a day is the greatest id among its failed
674
+ runs, so the pick does not depend on row order.
675
+
676
+ Args:
677
+ asset: The asset with its evidence.
678
+ since: First day of the window.
679
+ until: Last day of the window, inclusive.
680
+ now: The reference instant, aware UTC.
681
+
682
+ Returns:
683
+ One entry per day owed, oldest first, each carrying the asset's group id.
684
+ """
685
+ today = now.date()
686
+ span = asset.expected_span(now)
687
+ if span is None:
688
+ return []
689
+ first, last = max(span[0], since), min(span[1], until, today)
690
+ if first > last:
691
+ return []
692
+
693
+ attempted: dict[dt.date, set[str]] = defaultdict(set)
694
+ covered: dict[dt.date, set[str]] = defaultdict(set)
695
+ failed: dict[dt.date, set[str]] = defaultdict(set)
696
+ failed_run: dict[dt.date, UUID] = {}
697
+ for row in asset.rows:
698
+ for day in cls._days_of(row.partition_key, first, last):
699
+ attempted[day].add(row.partition_key)
700
+ if row.succeeded:
701
+ covered[day].add(row.partition_key)
702
+ elif row.failed:
703
+ failed[day].add(row.partition_key)
704
+ if row.failed_run_id is not None:
705
+ failed_run[day] = max(failed_run.get(day, row.failed_run_id), row.failed_run_id)
706
+
707
+ return [
708
+ cls(
709
+ date=day,
710
+ source_id=asset.group.id,
711
+ expected=max(asset.slots(day, now), len(attempted[day])),
712
+ covered=len(covered[day]),
713
+ failed=len(failed[day]),
714
+ failed_run_id=failed_run.get(day),
715
+ )
716
+ for day in (first + dt.timedelta(days=i) for i in range((last - first).days + 1))
717
+ ]
718
+
719
+ @classmethod
720
+ def _days_of(cls, key: str, since: dt.date, until: dt.date) -> list[dt.date]:
721
+ """List the days a partition key spans, clipped to a range of days.
722
+
723
+ Args:
724
+ key: A partition key of any granularity.
725
+ since: First day of the range.
726
+ until: Last day of the range, inclusive.
727
+
728
+ Returns:
729
+ The spanned days inside the range, oldest first; empty when the
730
+ partition lies outside it.
731
+ """
732
+ span = PartitionBounds.from_keys([key])
733
+ first, last = max(span.first, since), min(span.last, until)
734
+ return [first + dt.timedelta(days=i) for i in range((last - first).days + 1)]
735
+
736
+ def _plus(self, other: CoverageDay) -> CoverageDay:
737
+ """Sum this day with another of the same group and date.
738
+
739
+ Args:
740
+ other: The other entry.
741
+
742
+ Returns:
743
+ The summed entry, keeping the greater failed run id.
744
+ """
745
+ failed_runs = [run_id for run_id in (self.failed_run_id, other.failed_run_id) if run_id is not None]
746
+ return self.model_copy(
747
+ update={
748
+ "expected": self.expected + other.expected,
749
+ "covered": self.covered + other.covered,
750
+ "failed": self.failed + other.failed,
751
+ "failed_run_id": max(failed_runs, default=None),
752
+ }
753
+ )
754
+
755
+
756
+ class CoverageSource(BaseModel):
757
+ """A calendar group: a source with its assets, or a standalone asset."""
758
+
759
+ id: UUID
760
+ name: str
761
+ kind: Literal["source", "asset"]
762
+
763
+ @classmethod
764
+ def from_component(cls, component: Component) -> CoverageSource:
765
+ """Name a group after its row.
766
+
767
+ Args:
768
+ component: The source row, or the standalone asset row.
769
+
770
+ Returns:
771
+ The group, named by the row's name or else its key.
772
+ """
773
+ return cls(
774
+ id=component.id,
775
+ name=component.name or component.key,
776
+ kind="source" if component.kind == "source" else "asset",
777
+ )
778
+
779
+
780
+ class CoverageResponse(BaseModel):
781
+ """The coverage calendar's data over a window of days."""
782
+
783
+ since: dt.date
784
+ until: dt.date
785
+ sources: list[CoverageSource]
786
+ days: list[CoverageDay]
787
+
788
+ @classmethod
789
+ def from_assets(
790
+ cls, assets: list[AssetCoverage], since: dt.date, until: dt.date, now: dt.datetime
791
+ ) -> CoverageResponse:
792
+ """Build the calendar's data, listing only the groups with a day in the window.
793
+
794
+ Args:
795
+ assets: The partitioned assets with their evidence.
796
+ since: First day of the window.
797
+ until: Last day of the window, inclusive.
798
+ now: The reference instant, aware UTC.
799
+
800
+ Returns:
801
+ The window, its groups by name, and their days by group (in that
802
+ order) then date.
803
+ """
804
+ days = CoverageDay.from_assets(assets, since, until, now)
805
+ groups = {asset.group.id: asset.group for asset in assets}
806
+ sources = sorted(
807
+ (CoverageSource.from_component(groups[group_id]) for group_id in {day.source_id for day in days}),
808
+ key=lambda source: (source.name, str(source.id)),
809
+ )
810
+ rank = {source.id: i for i, source in enumerate(sources)}
811
+ days.sort(key=lambda day: (rank[day.source_id], day.date))
812
+ return cls(since=since, until=until, sources=sources, days=days)
813
+
814
+
815
+ # -- Endpoints -----------------------------------------------------------------
816
+
817
+
818
+ @router.get("")
819
+ def get_overview(
820
+ user: ViewerDep,
821
+ org_id: OrgIdDep,
822
+ store: StoreDep,
823
+ now: Annotated[
824
+ dt.datetime | None,
825
+ Query(description="Reference instant, a test seam for reproducible reads; defaults to the current time"),
826
+ ] = None,
827
+ ) -> OverviewResponse:
828
+ """Compose the overview page's data for the current organisation.
829
+
830
+ Args:
831
+ user: The authenticated user, required to hold at least the ``viewer`` role.
832
+ org_id: The active organisation's UUID.
833
+ store: The Store instance.
834
+ now: Reference instant. A test seam: tests pin it so every window and
835
+ "overdue" reading is deterministic; clients omit it.
836
+
837
+ Returns:
838
+ The overview.
839
+ """
840
+ now = assume_utc(now or dt.datetime.now(dt.timezone.utc)).astimezone(dt.timezone.utc)
841
+ day_ago = now - dt.timedelta(hours=24)
842
+
843
+ # gather rows
844
+ components = store.components.list_all(org_id)
845
+ keys = {component.id: component.key for component in components}
846
+ statuses = [
847
+ (component, store.components.read(component, parent_key=keys.get(component.parent_id)).status)
848
+ for component in components
849
+ ]
850
+ jobs = [component for component in components if component.kind == "job"]
851
+ enabled_jobs = [job for job in jobs if job.enabled]
852
+ names = {job.id: job.name or job.key for job in jobs}
853
+ running = store.runs.list_all(org_id, status="running", limit=10_000)
854
+ backfills = store.runs.list_backfills(org_id, active_only=True)
855
+ latest_by_job = {run.component_id: run for run in store.runs.latest_by_target(org_id, component_kind="job")}
856
+ failed_stacks = store.runs.list_all(
857
+ org_id, status="failed", component_kind="job", completed_after=day_ago, completed_before=now, limit=10_000
858
+ )
859
+ granularities = store.components.job_partition_granularities([job.id for job in enabled_jobs])
860
+ groups, _ = store.events.error_groups(org_id, event_types=FAILURE_EVENT_TYPES, since=day_ago, until=now)
861
+ hook_events = store.events.latest_by_component(
862
+ org_id, event_types=("hook_fired", "hook_failed"), since=now - HOOK_EVENTS_LOOKBACK
863
+ )
864
+
865
+ # derive failing ids
866
+ failing_jobs = {job.id for job in enabled_jobs if (run := latest_by_job.get(job.id)) and run.status == "failed"}
867
+ failing_assets = {
868
+ execution.component_id
869
+ for execution in store.events.latest_executions(org_id)
870
+ if execution.status == "failed" and execution.component_id
871
+ }
872
+ failing_sources = {
873
+ component.parent_id
874
+ for component in components
875
+ if component.kind == "asset" and component.id in failing_assets and component.parent_id
876
+ }
877
+ failing_hooks = {event.component_id for event in hook_events if event.event_type == "hook_failed"}
878
+ failing_ids = {i for i in (*failing_jobs, *failing_assets, *failing_sources, *failing_hooks) if i is not None}
879
+
880
+ # build sections
881
+ attention = [
882
+ *AttentionItem.from_error_groups(groups, names),
883
+ *AttentionItem.from_connections([component for component in components if component.kind == "connection"]),
884
+ *AttentionItem.from_run_stacks(failed_stacks, names, day_ago, now),
885
+ *AttentionItem.from_drift(statuses),
886
+ *AttentionItem.from_overdue(jobs, now),
887
+ ]
888
+ attention.sort(key=lambda item: (item.severity != "error", -(item.since.timestamp() if item.since else 0)))
889
+ attention_ids = {item.component_id for item in attention if item.kind == "connection" and item.component_id}
890
+
891
+ return OverviewResponse(
892
+ generated_at=now,
893
+ runs=RunsSummary.from_runs(
894
+ store.runs.list_all(
895
+ org_id, completed_after=day_ago, completed_before=now, all_attempts=True, limit=100_000
896
+ ),
897
+ now,
898
+ ),
899
+ activity=ActivitySummary.from_runs(running, store.runs.count(org_id, status="queued"), now),
900
+ backfills=BackfillsSummary.from_backfills(
901
+ backfills, store.runs.count_backfill_runs([backfill.id for backfill in backfills])
902
+ ),
903
+ jobs=JobsSummary.from_jobs(enabled_jobs, failing_jobs),
904
+ attention=attention,
905
+ upcoming=UpcomingRun.from_jobs(jobs, granularities),
906
+ recent=[
907
+ RunResponse.from_run(run)
908
+ for run in store.runs.list_all(org_id, completed_before=now, sort="-completed_at", limit=RECENT_LIMIT)
909
+ ],
910
+ components=KindInventory.from_statuses(statuses, failing_ids, attention_ids),
911
+ )
912
+
913
+
914
+ @router.get("/coverage")
915
+ def get_coverage(
916
+ user: ViewerDep,
917
+ org_id: OrgIdDep,
918
+ store: StoreDep,
919
+ since: dt.date,
920
+ until: dt.date,
921
+ now: Annotated[
922
+ dt.datetime | None,
923
+ Query(description="Reference instant, a test seam for reproducible reads; defaults to the current time"),
924
+ ] = None,
925
+ ) -> CoverageResponse:
926
+ """Read per source and day coverage over a window of at most :data:`MAX_COVERAGE_SPAN_DAYS` days.
927
+
928
+ Args:
929
+ user: The authenticated user, required to hold at least the ``viewer`` role.
930
+ org_id: The active organisation's UUID.
931
+ store: The Store instance.
932
+ since: First day of the window.
933
+ until: Last day of the window, inclusive.
934
+ now: Reference instant, which decides which periods have closed and
935
+ how much of today is owed. A test seam: tests pin it; clients omit it.
936
+
937
+ Returns:
938
+ The coverage calendar's data.
939
+
940
+ Raises:
941
+ HTTPException: 422 when the window is empty or spans more than :data:`MAX_COVERAGE_SPAN_DAYS` days.
942
+ """
943
+ if until < since or (until - since).days + 1 > MAX_COVERAGE_SPAN_DAYS:
944
+ raise HTTPException(status_code=422, detail=f"The window must span 1 to {MAX_COVERAGE_SPAN_DAYS} days")
945
+ now = assume_utc(now or dt.datetime.now(dt.timezone.utc)).astimezone(dt.timezone.utc)
946
+ assets = AssetCoverage.from_components(
947
+ store.components.list_all(org_id, kinds=["source", "asset", "job"]),
948
+ store.components.asset_partitionings(org_id),
949
+ store.events.partition_bounds(org_id),
950
+ store.events.coverage_rows(org_id, since, until),
951
+ )
952
+ return CoverageResponse.from_assets(assets, since, until, now)
@@ -240,6 +240,7 @@ def list_runs(
240
240
  component_kind: str | None = None,
241
241
  component_key: str | None = None,
242
242
  root_run_id: UUID | None = None,
243
+ sort: str | None = None,
243
244
  limit: int = 50,
244
245
  offset: int = 0,
245
246
  ) -> list[RunResponse]:
@@ -271,6 +272,9 @@ def list_runs(
271
272
  component_key: Keep only runs targeting a component of this type (catalog key); None
272
273
  applies no filter.
273
274
  root_run_id: List this stack's attempts rather than one row per stack.
275
+ sort: The field to order by (``id``, ``partition_key``, ``status``,
276
+ ``created_at``, ``started_at`` or ``completed_at``), ``-``-prefixed
277
+ for descending; None lists newest first.
274
278
  limit: Maximum number of runs on the page.
275
279
  offset: Number of matching runs to skip before the page starts.
276
280
  user: The authenticated user, required to hold at least the ``viewer`` role.
@@ -279,6 +283,9 @@ def list_runs(
279
283
 
280
284
  Returns:
281
285
  The matching page of runs, as response models.
286
+
287
+ Raises:
288
+ HTTPException: 400 if ``sort`` names a field runs cannot be ordered by.
282
289
  """
283
290
  total = store.runs.count(
284
291
  org_id,
@@ -293,20 +300,24 @@ def list_runs(
293
300
  root_run_id=root_run_id,
294
301
  )
295
302
  response.headers["X-Total-Count"] = str(total)
296
- runs = store.runs.list_all(
297
- org_id,
298
- component_id=component_id,
299
- backfill_id=backfill_id,
300
- status=status,
301
- after=after,
302
- before=before,
303
- q=q,
304
- component_kind=component_kind,
305
- component_key=component_key,
306
- root_run_id=root_run_id,
307
- limit=limit,
308
- offset=offset,
309
- )
303
+ try:
304
+ runs = store.runs.list_all(
305
+ org_id,
306
+ component_id=component_id,
307
+ backfill_id=backfill_id,
308
+ status=status,
309
+ after=after,
310
+ before=before,
311
+ q=q,
312
+ component_kind=component_kind,
313
+ component_key=component_key,
314
+ root_run_id=root_run_id,
315
+ sort=sort,
316
+ limit=limit,
317
+ offset=offset,
318
+ )
319
+ except ValueError as e:
320
+ raise HTTPException(status_code=400, detail=str(e))
310
321
  counts = store.events.count_executions([run.id for run in runs])
311
322
  return [RunResponse.from_run(run, counts.get(run.id)) for run in runs]
312
323
 
@@ -0,0 +1,5 @@
1
+ """Pure, type-agnostic helpers the API's routes share."""
2
+
3
+ from interloper_api.utils.time import format_duration, job_zone
4
+
5
+ __all__ = ["format_duration", "job_zone"]
@@ -0,0 +1,41 @@
1
+ """Time zones and durations as the API presents them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
7
+
8
+
9
+ def job_zone(name: str | None) -> dt.tzinfo:
10
+ """Resolve a job's timezone name, falling back to UTC like the scheduler does.
11
+
12
+ Args:
13
+ name: The IANA name from the job's config; ``None`` or an empty name
14
+ means UTC.
15
+
16
+ Returns:
17
+ The zone, or UTC when the name is unknown or malformed.
18
+ """
19
+ try:
20
+ return ZoneInfo(name or "UTC")
21
+ except (ZoneInfoNotFoundError, ValueError, TypeError):
22
+ return dt.timezone.utc
23
+
24
+
25
+ def format_duration(delta: dt.timedelta) -> str:
26
+ """Render a duration compactly, keeping its two largest units: ``2d 3h``, ``5h 12m`` or ``12m``.
27
+
28
+ Args:
29
+ delta: The duration; seconds below a whole minute are dropped.
30
+
31
+ Returns:
32
+ The compact label.
33
+ """
34
+ minutes = int(delta.total_seconds() // 60)
35
+ days, minutes = divmod(minutes, 24 * 60)
36
+ hours, minutes = divmod(minutes, 60)
37
+ if days:
38
+ return f"{days}d {hours}h"
39
+ if hours:
40
+ return f"{hours}h {minutes}m"
41
+ return f"{minutes}m"