interloper-toolkit 0.43.0__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.
@@ -0,0 +1,241 @@
1
+ """Lineage tools — dependency analysis, impact assessment, and DAG traversal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from uuid import UUID
7
+
8
+ from interloper_toolkit.context import ToolkitContext
9
+ from interloper_toolkit.models import (
10
+ AssetRef,
11
+ CrossSourceDependencies,
12
+ CrossSourceEdge,
13
+ DependencyEdge,
14
+ DownstreamResult,
15
+ ImpactAnalysis,
16
+ LineageItem,
17
+ LineageResult,
18
+ ToolError,
19
+ UpstreamResult,
20
+ )
21
+
22
+
23
+ def get_upstream(ctx: ToolkitContext, asset_id: str) -> UpstreamResult | ToolError:
24
+ """Get the direct upstream dependencies of an asset.
25
+
26
+ Args:
27
+ asset_id: UUID of the asset to inspect.
28
+
29
+ Returns the list of upstream assets that this asset depends on,
30
+ including the parameter name used for each dependency.
31
+ """
32
+ try:
33
+ deps = ctx.store.list_relations(ctx.org_id, type="dependency")
34
+ target = UUID(asset_id)
35
+
36
+ upstream = []
37
+ for dep in deps:
38
+ if dep.src_id == target:
39
+ asset = ctx.store.get_component(dep.dst_id, kind="asset")
40
+ upstream.append(DependencyEdge(
41
+ asset_id=str(dep.dst_id),
42
+ param_name=dep.slot,
43
+ asset_key=asset.key,
44
+ source_id=str(asset.parent_id),
45
+ ))
46
+
47
+ return UpstreamResult(asset_id=asset_id, upstream=upstream)
48
+ except Exception as e:
49
+ return ToolError(error=str(e))
50
+
51
+
52
+ def get_downstream(ctx: ToolkitContext, asset_id: str) -> DownstreamResult | ToolError:
53
+ """Get the direct downstream dependents of an asset.
54
+
55
+ Args:
56
+ asset_id: UUID of the asset to inspect.
57
+
58
+ Returns the list of assets that directly depend on this asset.
59
+ """
60
+ try:
61
+ deps = ctx.store.list_relations(ctx.org_id, type="dependency")
62
+ target = UUID(asset_id)
63
+
64
+ downstream = []
65
+ for dep in deps:
66
+ if dep.dst_id == target:
67
+ asset = ctx.store.get_component(dep.src_id, kind="asset")
68
+ downstream.append(DependencyEdge(
69
+ asset_id=str(dep.src_id),
70
+ param_name=dep.slot,
71
+ asset_key=asset.key,
72
+ source_id=str(asset.parent_id),
73
+ ))
74
+
75
+ return DownstreamResult(asset_id=asset_id, downstream=downstream)
76
+ except Exception as e:
77
+ return ToolError(error=str(e))
78
+
79
+
80
+ def get_full_lineage(ctx: ToolkitContext, asset_id: str, direction: str = "upstream") -> LineageResult | ToolError:
81
+ """Recursively traverse the full lineage of an asset.
82
+
83
+ Args:
84
+ asset_id: UUID of the asset to start from.
85
+ direction: Either 'upstream' (ancestors) or 'downstream' (dependents).
86
+
87
+ Returns an ordered list of assets in the lineage chain with depth levels.
88
+ """
89
+ try:
90
+ adj, asset_info = _build_adjacency(ctx, direction)
91
+ target = UUID(asset_id)
92
+
93
+ visited: set[UUID] = set()
94
+ result: list[LineageItem] = []
95
+ queue: list[tuple[UUID, int]] = [(target, 0)]
96
+
97
+ while queue:
98
+ current, depth = queue.pop(0)
99
+ if current in visited:
100
+ continue
101
+ visited.add(current)
102
+ if current != target:
103
+ info = asset_info.get(current, {})
104
+ result.append(LineageItem(asset_id=str(current), depth=depth, **info))
105
+ for neighbor in adj.get(current, []):
106
+ if neighbor not in visited:
107
+ queue.append((neighbor, depth + 1))
108
+
109
+ return LineageResult(
110
+ asset_id=asset_id,
111
+ direction=direction,
112
+ lineage_count=len(result),
113
+ lineage=result,
114
+ )
115
+ except Exception as e:
116
+ return ToolError(error=str(e))
117
+
118
+
119
+ def impact_analysis(ctx: ToolkitContext, asset_id: str) -> ImpactAnalysis | ToolError:
120
+ """Analyze the downstream impact if an asset fails or is disabled.
121
+
122
+ Args:
123
+ asset_id: UUID of the asset to analyze.
124
+
125
+ Returns all downstream assets grouped by source, with total affected count.
126
+ """
127
+ try:
128
+ adj, asset_info = _build_adjacency(ctx, "downstream")
129
+ target = UUID(asset_id)
130
+
131
+ visited: set[UUID] = set()
132
+ affected: list[LineageItem] = []
133
+ queue: list[tuple[UUID, int]] = [(target, 0)]
134
+
135
+ while queue:
136
+ current, depth = queue.pop(0)
137
+ if current in visited:
138
+ continue
139
+ visited.add(current)
140
+ if current != target:
141
+ info = asset_info.get(current, {})
142
+ affected.append(LineageItem(asset_id=str(current), depth=depth, **info))
143
+ for neighbor in adj.get(current, []):
144
+ if neighbor not in visited:
145
+ queue.append((neighbor, depth + 1))
146
+
147
+ # Group by source
148
+ by_source: dict[str, list[LineageItem]] = defaultdict(list)
149
+ for item in affected:
150
+ by_source[item.source_key or "unknown"].append(item)
151
+
152
+ return ImpactAnalysis(
153
+ asset_id=asset_id,
154
+ total_affected=len(affected),
155
+ by_source=dict(by_source),
156
+ )
157
+ except Exception as e:
158
+ return ToolError(error=str(e))
159
+
160
+
161
+ def cross_source_dependencies(ctx: ToolkitContext) -> CrossSourceDependencies | ToolError:
162
+ """List all dependency edges that cross source boundaries.
163
+
164
+ Returns edges where the upstream and downstream assets belong to
165
+ different sources.
166
+ """
167
+ try:
168
+ deps = ctx.store.list_relations(ctx.org_id, type="dependency")
169
+ assets = ctx.store.list_components(ctx.org_id, kinds=["asset"])
170
+
171
+ asset_source: dict[UUID, UUID | None] = {}
172
+ asset_info: dict[UUID, AssetRef] = {}
173
+ for a in assets:
174
+ if not a.id:
175
+ continue
176
+ asset_source[a.id] = a.parent_id
177
+ asset_info[a.id] = AssetRef(asset_key=a.key, source_id=str(a.parent_id) if a.parent_id else "")
178
+
179
+ cross_deps = []
180
+ for dep in deps:
181
+ src_down = asset_source.get(dep.src_id)
182
+ src_up = asset_source.get(dep.dst_id)
183
+ if src_down and src_up and src_down != src_up:
184
+ cross_deps.append(CrossSourceEdge(
185
+ downstream_asset_id=str(dep.src_id),
186
+ downstream=asset_info.get(dep.src_id, AssetRef()),
187
+ upstream_asset_id=str(dep.dst_id),
188
+ upstream=asset_info.get(dep.dst_id, AssetRef()),
189
+ param_name=dep.slot,
190
+ ))
191
+
192
+ return CrossSourceDependencies(cross_source_count=len(cross_deps), dependencies=cross_deps)
193
+ except Exception as e:
194
+ return ToolError(error=str(e))
195
+
196
+
197
+ # -- Helpers -------------------------------------------------------------------
198
+
199
+
200
+ def _build_adjacency(
201
+ ctx: ToolkitContext,
202
+ direction: str,
203
+ ) -> tuple[dict[UUID, list[UUID]], dict[UUID, dict[str, str]]]:
204
+ """Build an adjacency map and asset info lookup from all dependencies.
205
+
206
+ Args:
207
+ ctx: The toolkit context.
208
+ direction: 'upstream' builds child→parents; 'downstream' builds parent→children.
209
+
210
+ Returns:
211
+ ``(adjacency_map, asset_info_map)`` — info values feed
212
+ :class:`LineageItem` kwargs (asset_key, source_id, source_key).
213
+ """
214
+ deps = ctx.store.list_relations(ctx.org_id, type="dependency")
215
+ assets = ctx.store.list_components(ctx.org_id, kinds=["asset"])
216
+
217
+ asset_info: dict[UUID, dict[str, str]] = {}
218
+ source_keys: dict[UUID, str] = {}
219
+ for a in assets:
220
+ if not a.id:
221
+ continue
222
+ asset_info[a.id] = {"asset_key": a.key, "source_id": str(a.parent_id) if a.parent_id else ""}
223
+ if a.parent and a.parent_id:
224
+ source_keys[a.parent_id] = a.parent.key
225
+
226
+ # Add source_key to asset_info
227
+ for uid, info in asset_info.items():
228
+ sid_str = info["source_id"]
229
+ if sid_str:
230
+ info["source_key"] = source_keys.get(UUID(sid_str), "unknown")
231
+ else:
232
+ info["source_key"] = ""
233
+
234
+ adj: dict[UUID, list[UUID]] = defaultdict(list)
235
+ for dep in deps:
236
+ if direction == "upstream":
237
+ adj[dep.src_id].append(dep.dst_id)
238
+ else:
239
+ adj[dep.dst_id].append(dep.src_id)
240
+
241
+ return adj, asset_info
@@ -0,0 +1,386 @@
1
+ """Typed result models for the toolkit surface.
2
+
3
+ Every toolkit function returns ``<SuccessModel> | ToolError`` — a union
4
+ discriminated by the literal ``status`` field, preserving the
5
+ ``{"status": "success" | "error"}`` envelope both AI surfaces already
6
+ speak. The models are the tool contract: MCP derives per-tool output
7
+ schemas from them, and the fields of row-projecting models act as an
8
+ allowlist of what leaves the platform.
9
+
10
+ Row-shaped payloads deliberately embed the interloper-db models (``Run``,
11
+ ``Backfill``, ``Event``, ``Component`` — already pydantic via SQLModel)
12
+ rather than duplicating their shape: the full-row contract predates this
13
+ module, and embedding makes the coupling visible in the signature. The one
14
+ exception is :class:`ComponentSummary`, where projecting is the point —
15
+ sensitive kinds must never expose config or credential material, so the
16
+ model's fields fail closed instead of relying on conditional key insertion.
17
+
18
+ Catalog definition payloads stay ``dict[str, Any]``: their content is
19
+ definition-specific JSON schema material with no fixed shape to type.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from datetime import datetime
25
+ from typing import Any, Literal
26
+
27
+ from interloper_db.models import AssetExecution, Backfill, Component, Event, Run
28
+ from pydantic import BaseModel
29
+
30
+
31
+ class ToolError(BaseModel):
32
+ """A failed tool call, as a structured result rather than an exception."""
33
+
34
+ status: Literal["error"] = "error"
35
+ error: str
36
+ valid_kinds: list[str] | None = None
37
+
38
+
39
+ # -- Catalog --------------------------------------------------------------------
40
+
41
+
42
+ class DefinitionCounts(BaseModel):
43
+ """Per-kind definition counts (the kind-less ``list_definitions`` call)."""
44
+
45
+ status: Literal["success"] = "success"
46
+ definition_counts: dict[str, int]
47
+ message: str
48
+
49
+
50
+ class DefinitionEntry(BaseModel):
51
+ """One catalog definition, summarized for listing.
52
+
53
+ The trailing optional fields are kind-specific: ``asset_count`` /
54
+ ``in_collection`` / ``collection_count`` are populated for sources,
55
+ ``provider`` / ``required_fields`` / ``oauth`` / ``oauth_available``
56
+ for connections.
57
+ """
58
+
59
+ key: str
60
+ name: str
61
+ description: str | None = None
62
+ icon: str | None = None
63
+ tags: list[str] = []
64
+ asset_count: int | None = None
65
+ in_collection: bool | None = None
66
+ collection_count: int | None = None
67
+ provider: str | None = None
68
+ required_fields: list[str] | None = None
69
+ oauth: bool | None = None
70
+ oauth_available: bool | None = None
71
+
72
+
73
+ class DefinitionList(BaseModel):
74
+ """Catalog definitions of one kind."""
75
+
76
+ status: Literal["success"] = "success"
77
+ kind: str
78
+ count: int
79
+ definitions: list[DefinitionEntry]
80
+
81
+
82
+ class DefinitionDetail(BaseModel):
83
+ """One definition's full catalog payload (definition-specific shape)."""
84
+
85
+ status: Literal["success"] = "success"
86
+ definition: dict[str, Any]
87
+
88
+
89
+ class AssetSchemaResult(BaseModel):
90
+ """The JSON schema and metadata of one asset within a source."""
91
+
92
+ status: Literal["success"] = "success"
93
+ source_key: str
94
+ asset_key: str
95
+ qualified_key: str
96
+ asset_schema: dict[str, Any] | None = None
97
+ partitioning: Any = None
98
+ tags: list[str] = []
99
+ requires: dict[str, Any] = {}
100
+ optional_requires: dict[str, Any] = {}
101
+
102
+
103
+ class FieldMatch(BaseModel):
104
+ """One schema field matching a search query."""
105
+
106
+ source_key: str
107
+ asset_key: str
108
+ qualified_key: str
109
+ field_name: str
110
+ field_type: str
111
+ description: str
112
+
113
+
114
+ class FieldSearchResult(BaseModel):
115
+ """Fields across all asset schemas matching a query."""
116
+
117
+ status: Literal["success"] = "success"
118
+ query: str
119
+ match_count: int
120
+ matches: list[FieldMatch]
121
+
122
+
123
+ class SharedField(BaseModel):
124
+ """A field present in both compared schemas, with type agreement."""
125
+
126
+ field: str
127
+ type_a: str
128
+ type_b: str
129
+ type_match: bool
130
+
131
+
132
+ class SchemaComparison(BaseModel):
133
+ """Side-by-side comparison of two asset schemas."""
134
+
135
+ status: Literal["success"] = "success"
136
+ asset_a: str
137
+ asset_b: str
138
+ shared_count: int
139
+ only_a_count: int
140
+ only_b_count: int
141
+ shared_fields: list[SharedField]
142
+ only_in_a: list[str]
143
+ only_in_b: list[str]
144
+
145
+
146
+ # -- Collection -----------------------------------------------------------------
147
+
148
+
149
+ class ComponentCounts(BaseModel):
150
+ """Per-kind component counts (the kind-less ``list_components`` call)."""
151
+
152
+ status: Literal["success"] = "success"
153
+ component_counts: dict[str, int]
154
+ message: str
155
+
156
+
157
+ class ComponentSummary(BaseModel):
158
+ """A component instance, projected for listing.
159
+
160
+ Deliberately not the db row: these fields are an allowlist. ``config``
161
+ is only populated for non-sensitive kinds; credential-bearing kinds
162
+ surface identity and metadata alone.
163
+ """
164
+
165
+ id: str
166
+ key: str
167
+ name: str | None = None
168
+ type_name: str
169
+ created_at: datetime | None = None
170
+ config: dict[str, Any] | None = None
171
+ asset_count: int | None = None
172
+
173
+
174
+ class ComponentList(BaseModel):
175
+ """The org's component instances of one kind."""
176
+
177
+ status: Literal["success"] = "success"
178
+ kind: str
179
+ count: int
180
+ components: list[ComponentSummary]
181
+
182
+
183
+ # -- Lineage --------------------------------------------------------------------
184
+
185
+
186
+ class DependencyEdge(BaseModel):
187
+ """A direct dependency edge from the perspective of one asset."""
188
+
189
+ asset_id: str
190
+ param_name: str
191
+ asset_key: str
192
+ source_id: str
193
+
194
+
195
+ class UpstreamResult(BaseModel):
196
+ """Direct upstream dependencies of an asset."""
197
+
198
+ status: Literal["success"] = "success"
199
+ asset_id: str
200
+ upstream: list[DependencyEdge]
201
+
202
+
203
+ class DownstreamResult(BaseModel):
204
+ """Direct downstream dependents of an asset."""
205
+
206
+ status: Literal["success"] = "success"
207
+ asset_id: str
208
+ downstream: list[DependencyEdge]
209
+
210
+
211
+ class LineageItem(BaseModel):
212
+ """One asset in a lineage traversal, with its BFS depth."""
213
+
214
+ asset_id: str
215
+ depth: int
216
+ asset_key: str | None = None
217
+ source_id: str | None = None
218
+ source_key: str | None = None
219
+
220
+
221
+ class LineageResult(BaseModel):
222
+ """The full recursive lineage of an asset in one direction."""
223
+
224
+ status: Literal["success"] = "success"
225
+ asset_id: str
226
+ direction: str
227
+ lineage_count: int
228
+ lineage: list[LineageItem]
229
+
230
+
231
+ class ImpactAnalysis(BaseModel):
232
+ """All downstream assets affected by a failure, grouped by source."""
233
+
234
+ status: Literal["success"] = "success"
235
+ asset_id: str
236
+ total_affected: int
237
+ by_source: dict[str, list[LineageItem]]
238
+
239
+
240
+ class AssetRef(BaseModel):
241
+ """A minimal asset reference on a cross-source edge."""
242
+
243
+ asset_key: str | None = None
244
+ source_id: str | None = None
245
+
246
+
247
+ class CrossSourceEdge(BaseModel):
248
+ """A dependency edge whose endpoints belong to different sources."""
249
+
250
+ downstream_asset_id: str
251
+ downstream: AssetRef
252
+ upstream_asset_id: str
253
+ upstream: AssetRef
254
+ param_name: str
255
+
256
+
257
+ class CrossSourceDependencies(BaseModel):
258
+ """All dependency edges crossing source boundaries."""
259
+
260
+ status: Literal["success"] = "success"
261
+ cross_source_count: int
262
+ dependencies: list[CrossSourceEdge]
263
+
264
+
265
+ # -- Scheduling -----------------------------------------------------------------
266
+
267
+
268
+ class JobList(BaseModel):
269
+ """The org's scheduled jobs (full component rows)."""
270
+
271
+ status: Literal["success"] = "success"
272
+ count: int
273
+ jobs: list[Component]
274
+
275
+
276
+ class JobHealthStats(BaseModel):
277
+ """Success/failure statistics over a job's recent runs."""
278
+
279
+ total_recent_runs: int
280
+ success_count: int
281
+ failed_count: int
282
+ success_rate: float | None = None
283
+ avg_duration_seconds: float | None = None
284
+
285
+
286
+ class JobHealth(BaseModel):
287
+ """A job's metadata plus health computed from its last runs."""
288
+
289
+ status: Literal["success"] = "success"
290
+ job: Component
291
+ health: JobHealthStats
292
+
293
+
294
+ class RunList(BaseModel):
295
+ """Recent runs matching the filters."""
296
+
297
+ status: Literal["success"] = "success"
298
+ count: int
299
+ runs: list[Run]
300
+
301
+
302
+ class RunDetail(BaseModel):
303
+ """One run with its event timeline and per-asset execution summary."""
304
+
305
+ status: Literal["success"] = "success"
306
+ run: Run
307
+ events: list[Event]
308
+ asset_executions: list[AssetExecution]
309
+
310
+
311
+ class RunErrorEvent(BaseModel):
312
+ """One error event of a failed run."""
313
+
314
+ asset_key: str | None = None
315
+ error: str
316
+ timestamp: datetime
317
+
318
+
319
+ class RunFailure(BaseModel):
320
+ """A failed run with its error events."""
321
+
322
+ run: Run
323
+ errors: list[RunErrorEvent]
324
+
325
+
326
+ class FailureList(BaseModel):
327
+ """Recent failed runs with their errors."""
328
+
329
+ status: Literal["success"] = "success"
330
+ count: int
331
+ failures: list[RunFailure]
332
+
333
+
334
+ class BackfillList(BaseModel):
335
+ """Backfills, optionally only the active ones."""
336
+
337
+ status: Literal["success"] = "success"
338
+ count: int
339
+ backfills: list[Backfill]
340
+
341
+
342
+ # -- Analytics ------------------------------------------------------------------
343
+
344
+
345
+ class RunHistorySummary(BaseModel):
346
+ """Aggregate run statistics over a look-back period."""
347
+
348
+ status: Literal["success"] = "success"
349
+ period_days: int
350
+ component_id: str | None = None
351
+ total_runs: int
352
+ by_status: dict[str, int]
353
+ success_rate: float | None = None
354
+ avg_duration_seconds: float | None = None
355
+
356
+
357
+ class PartitionCoverage(BaseModel):
358
+ """Which partition dates in a range have successful runs."""
359
+
360
+ status: Literal["success"] = "success"
361
+ component_id: str
362
+ start_date: str
363
+ end_date: str
364
+ total_days: int
365
+ covered_days: int
366
+ missing_days: int
367
+ coverage_percent: float
368
+ missing_dates: list[str]
369
+
370
+
371
+ class JobFreshness(BaseModel):
372
+ """One job's data freshness."""
373
+
374
+ job: Component
375
+ last_success_at: datetime | None = None
376
+ hours_since_success: float | None = None
377
+ stale: bool
378
+
379
+
380
+ class FreshnessReport(BaseModel):
381
+ """Freshness across all enabled jobs."""
382
+
383
+ status: Literal["success"] = "success"
384
+ total_jobs: int
385
+ stale_count: int
386
+ jobs: list[JobFreshness]