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.
- interloper_toolkit/__init__.py +14 -0
- interloper_toolkit/analytics.py +153 -0
- interloper_toolkit/catalog.py +303 -0
- interloper_toolkit/collection.py +60 -0
- interloper_toolkit/context.py +55 -0
- interloper_toolkit/lineage.py +241 -0
- interloper_toolkit/models.py +386 -0
- interloper_toolkit/scheduling.py +172 -0
- interloper_toolkit-0.43.0.dist-info/METADATA +40 -0
- interloper_toolkit-0.43.0.dist-info/RECORD +11 -0
- interloper_toolkit-0.43.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Read-only tool functions shared by AI surfaces (agent, MCP server).
|
|
2
|
+
|
|
3
|
+
Every function takes a :class:`~interloper_toolkit.context.ToolkitContext`
|
|
4
|
+
as its first argument and returns ``<SuccessModel> | ToolError`` — typed
|
|
5
|
+
pydantic results (see :mod:`interloper_toolkit.models`) discriminated by
|
|
6
|
+
the literal ``status`` field, never raising. The docstrings are LLM-facing:
|
|
7
|
+
both the ADK agent and the MCP server surface them verbatim as tool
|
|
8
|
+
descriptions.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from interloper_toolkit.context import ToolkitContext, serialize
|
|
12
|
+
from interloper_toolkit.models import ToolError
|
|
13
|
+
|
|
14
|
+
__all__ = ["ToolkitContext", "ToolError", "serialize"]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Analytics tools — run statistics, partition coverage, and data freshness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from interloper_toolkit.context import ToolkitContext
|
|
9
|
+
from interloper_toolkit.models import (
|
|
10
|
+
FreshnessReport,
|
|
11
|
+
JobFreshness,
|
|
12
|
+
PartitionCoverage,
|
|
13
|
+
RunHistorySummary,
|
|
14
|
+
ToolError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_history_summary(
|
|
19
|
+
ctx: ToolkitContext,
|
|
20
|
+
component_id: str | None = None,
|
|
21
|
+
days: int = 7,
|
|
22
|
+
) -> RunHistorySummary | ToolError:
|
|
23
|
+
"""Summarize run statistics over a period.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
component_id: Filter to a specific job UUID (optional, all jobs if omitted).
|
|
27
|
+
days: Number of days to look back (default 7).
|
|
28
|
+
|
|
29
|
+
Returns aggregate counts (total, success, failed, canceled),
|
|
30
|
+
success rate, and average duration.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
runs = ctx.store.list_runs(
|
|
34
|
+
ctx.org_id,
|
|
35
|
+
component_id=UUID(component_id) if component_id else None,
|
|
36
|
+
limit=500,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
cutoff = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=days)
|
|
40
|
+
recent = [r for r in runs if r.created_at and r.created_at >= cutoff]
|
|
41
|
+
|
|
42
|
+
total = len(recent)
|
|
43
|
+
by_status: dict[str, int] = {}
|
|
44
|
+
durations: list[float] = []
|
|
45
|
+
for r in recent:
|
|
46
|
+
by_status[r.status] = by_status.get(r.status, 0) + 1
|
|
47
|
+
if r.started_at and r.completed_at:
|
|
48
|
+
durations.append((r.completed_at - r.started_at).total_seconds())
|
|
49
|
+
|
|
50
|
+
success = by_status.get("success", 0)
|
|
51
|
+
return RunHistorySummary(
|
|
52
|
+
period_days=days,
|
|
53
|
+
component_id=component_id,
|
|
54
|
+
total_runs=total,
|
|
55
|
+
by_status=by_status,
|
|
56
|
+
success_rate=round(success / total, 2) if total > 0 else None,
|
|
57
|
+
avg_duration_seconds=round(sum(durations) / len(durations), 1) if durations else None,
|
|
58
|
+
)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
return ToolError(error=str(e))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def partition_coverage(
|
|
64
|
+
ctx: ToolkitContext,
|
|
65
|
+
component_id: str,
|
|
66
|
+
start_date: str,
|
|
67
|
+
end_date: str,
|
|
68
|
+
) -> PartitionCoverage | ToolError:
|
|
69
|
+
"""Check partition coverage for a job over a date range.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
component_id: UUID of the job.
|
|
73
|
+
start_date: Start date in ISO format (YYYY-MM-DD).
|
|
74
|
+
end_date: End date in ISO format (YYYY-MM-DD), inclusive.
|
|
75
|
+
|
|
76
|
+
Returns which dates have successful runs and which are missing.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
jid = UUID(component_id)
|
|
80
|
+
runs = ctx.store.list_runs(ctx.org_id, component_id=jid, limit=1000)
|
|
81
|
+
|
|
82
|
+
start = datetime.date.fromisoformat(start_date)
|
|
83
|
+
end = datetime.date.fromisoformat(end_date)
|
|
84
|
+
|
|
85
|
+
# Collect dates with successful runs
|
|
86
|
+
covered: set[datetime.date] = set()
|
|
87
|
+
for r in runs:
|
|
88
|
+
if r.status == "success" and r.partition_date:
|
|
89
|
+
if start <= r.partition_date <= end:
|
|
90
|
+
covered.add(r.partition_date)
|
|
91
|
+
|
|
92
|
+
# Build expected date range
|
|
93
|
+
expected: list[datetime.date] = []
|
|
94
|
+
current = start
|
|
95
|
+
while current <= end:
|
|
96
|
+
expected.append(current)
|
|
97
|
+
current += datetime.timedelta(days=1)
|
|
98
|
+
|
|
99
|
+
missing = sorted(set(expected) - covered)
|
|
100
|
+
coverage_pct = round(len(covered) / len(expected) * 100, 1) if expected else 100.0
|
|
101
|
+
|
|
102
|
+
return PartitionCoverage(
|
|
103
|
+
component_id=component_id,
|
|
104
|
+
start_date=start_date,
|
|
105
|
+
end_date=end_date,
|
|
106
|
+
total_days=len(expected),
|
|
107
|
+
covered_days=len(covered),
|
|
108
|
+
missing_days=len(missing),
|
|
109
|
+
coverage_percent=coverage_pct,
|
|
110
|
+
missing_dates=[d.isoformat() for d in missing],
|
|
111
|
+
)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
return ToolError(error=str(e))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def freshness_check(ctx: ToolkitContext) -> FreshnessReport | ToolError:
|
|
117
|
+
"""Check data freshness for all jobs.
|
|
118
|
+
|
|
119
|
+
Returns the last successful run timestamp for each job and flags
|
|
120
|
+
any that haven't succeeded in over 24 hours.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
jobs = ctx.store.list_components(ctx.org_id, kinds=["job"])
|
|
124
|
+
now = datetime.datetime.now(tz=datetime.timezone.utc)
|
|
125
|
+
|
|
126
|
+
results = []
|
|
127
|
+
for job in jobs:
|
|
128
|
+
if not (job.config or {}).get("enabled", True):
|
|
129
|
+
continue
|
|
130
|
+
component_id = job.id
|
|
131
|
+
runs = ctx.store.list_runs(ctx.org_id, component_id=component_id, status="success", limit=1)
|
|
132
|
+
last_success = runs[0] if runs else None
|
|
133
|
+
|
|
134
|
+
hours_since = None
|
|
135
|
+
if last_success and last_success.completed_at:
|
|
136
|
+
delta = now - last_success.completed_at
|
|
137
|
+
hours_since = round(delta.total_seconds() / 3600, 1)
|
|
138
|
+
|
|
139
|
+
results.append(JobFreshness(
|
|
140
|
+
job=job,
|
|
141
|
+
last_success_at=last_success.completed_at if last_success else None,
|
|
142
|
+
hours_since_success=hours_since,
|
|
143
|
+
stale=hours_since is None or hours_since > 24,
|
|
144
|
+
))
|
|
145
|
+
|
|
146
|
+
stale_count = sum(1 for r in results if r.stale)
|
|
147
|
+
return FreshnessReport(
|
|
148
|
+
total_jobs=len(results),
|
|
149
|
+
stale_count=stale_count,
|
|
150
|
+
jobs=results,
|
|
151
|
+
)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
return ToolError(error=str(e))
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Catalog tools — the component definitions the platform ships.
|
|
2
|
+
|
|
3
|
+
Generic over component kinds, mirroring the framework's component
|
|
4
|
+
architecture: one lister and one getter for any kind, plus the
|
|
5
|
+
asset-schema operations that are irreducibly kind-specific.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from interloper.oauth import is_provider_configured
|
|
13
|
+
|
|
14
|
+
from interloper_toolkit.context import ToolkitContext
|
|
15
|
+
from interloper_toolkit.models import (
|
|
16
|
+
AssetSchemaResult,
|
|
17
|
+
DefinitionCounts,
|
|
18
|
+
DefinitionDetail,
|
|
19
|
+
DefinitionEntry,
|
|
20
|
+
DefinitionList,
|
|
21
|
+
FieldMatch,
|
|
22
|
+
FieldSearchResult,
|
|
23
|
+
SchemaComparison,
|
|
24
|
+
SharedField,
|
|
25
|
+
ToolError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _inline_refs(node: Any, defs: dict[str, Any] | None = None, _stack: frozenset[str] = frozenset()) -> Any:
|
|
30
|
+
"""Resolve local ``#/$defs/*`` references in-place and drop ``$defs``.
|
|
31
|
+
|
|
32
|
+
Gemini rejects tool responses containing JSON-Schema ``$ref`` pointers
|
|
33
|
+
(400 INVALID_ARGUMENT), so every schema leaving the toolkit is inlined:
|
|
34
|
+
a ``$ref`` is replaced by its definition (ref siblings win on conflict);
|
|
35
|
+
cyclic or unresolvable refs are dropped, keeping the siblings.
|
|
36
|
+
"""
|
|
37
|
+
if isinstance(node, list):
|
|
38
|
+
return [_inline_refs(item, defs, _stack) for item in node]
|
|
39
|
+
if not isinstance(node, dict):
|
|
40
|
+
return node
|
|
41
|
+
if isinstance(node.get("$defs"), dict):
|
|
42
|
+
defs = {**(defs or {}), **node["$defs"]}
|
|
43
|
+
ref = node.get("$ref")
|
|
44
|
+
if isinstance(ref, str) and ref.startswith("#/$defs/"):
|
|
45
|
+
name = ref.rsplit("/", 1)[-1]
|
|
46
|
+
if defs and name in defs and name not in _stack:
|
|
47
|
+
siblings = {k: v for k, v in node.items() if k not in ("$ref", "$defs")}
|
|
48
|
+
return _inline_refs({**defs[name], **siblings}, defs, _stack | {name})
|
|
49
|
+
return {k: _inline_refs(v, defs, _stack) for k, v in node.items() if k not in ("$defs", "$ref")}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def list_definitions(
|
|
53
|
+
ctx: ToolkitContext, kind: str | None = None
|
|
54
|
+
) -> DefinitionCounts | DefinitionList | ToolError:
|
|
55
|
+
"""List the component definitions available in the catalog.
|
|
56
|
+
|
|
57
|
+
This answers "what does Interloper support / what could we add?" — the
|
|
58
|
+
org's collection itself is the Collection specialist's domain.
|
|
59
|
+
|
|
60
|
+
Source entries note how many instances the org's collection holds
|
|
61
|
+
(``in_collection`` / ``collection_count``); connection entries note
|
|
62
|
+
whether OAuth sign-in is supported (``oauth``) and usable in this
|
|
63
|
+
deployment (``oauth_available``) vs manual credential entry.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
kind: Component kind to list — e.g. 'source', 'connection',
|
|
67
|
+
'destination'. Omit for per-kind counts only; call again with a
|
|
68
|
+
kind for the entries.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
catalog = ctx.catalog
|
|
72
|
+
counts: dict[str, int] = {}
|
|
73
|
+
for defn in catalog.values():
|
|
74
|
+
counts[defn.get("kind", "?")] = counts.get(defn.get("kind", "?"), 0) + 1
|
|
75
|
+
|
|
76
|
+
if kind is None:
|
|
77
|
+
return DefinitionCounts(
|
|
78
|
+
definition_counts=counts,
|
|
79
|
+
message="Call again with a kind for the entries.",
|
|
80
|
+
)
|
|
81
|
+
if kind not in counts:
|
|
82
|
+
return ToolError(
|
|
83
|
+
error=f"No '{kind}' definitions in the catalog",
|
|
84
|
+
valid_kinds=sorted(counts),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
collection_counts: dict[str, int] = {}
|
|
88
|
+
if kind == "source":
|
|
89
|
+
for s in ctx.store.list_components(ctx.org_id, kinds=["source"]):
|
|
90
|
+
collection_counts[s.key] = collection_counts.get(s.key, 0) + 1
|
|
91
|
+
|
|
92
|
+
results = []
|
|
93
|
+
for key, defn in catalog.items():
|
|
94
|
+
if defn.get("kind") != kind:
|
|
95
|
+
continue
|
|
96
|
+
entry = DefinitionEntry(
|
|
97
|
+
key=key,
|
|
98
|
+
name=defn.get("name", key),
|
|
99
|
+
description=defn.get("description"),
|
|
100
|
+
icon=defn.get("icon"),
|
|
101
|
+
tags=defn.get("tags", []),
|
|
102
|
+
)
|
|
103
|
+
if kind == "source":
|
|
104
|
+
count = collection_counts.get(key, 0)
|
|
105
|
+
entry.asset_count = len(defn.get("assets", []))
|
|
106
|
+
entry.in_collection = count > 0
|
|
107
|
+
entry.collection_count = count
|
|
108
|
+
elif kind == "connection":
|
|
109
|
+
schema = defn.get("config_schema") or {}
|
|
110
|
+
oauth = schema.get("x-oauth")
|
|
111
|
+
entry.provider = defn.get("provider")
|
|
112
|
+
entry.required_fields = schema.get("required", [])
|
|
113
|
+
entry.oauth = oauth is not None
|
|
114
|
+
entry.oauth_available = is_provider_configured(oauth["provider"]) if oauth else False
|
|
115
|
+
results.append(entry)
|
|
116
|
+
|
|
117
|
+
return DefinitionList(kind=kind, count=len(results), definitions=results)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
return ToolError(error=str(e))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_definition(ctx: ToolkitContext, key: str) -> DefinitionDetail | ToolError:
|
|
123
|
+
"""Get a component definition's full catalog detail.
|
|
124
|
+
|
|
125
|
+
For a source this includes the config schema, resource slots,
|
|
126
|
+
destination types, and all its assets with their schemas. This is the
|
|
127
|
+
catalog definition (the component *type*), not an instance from the
|
|
128
|
+
org's collection.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
key: The definition's catalog key (e.g. 'facebook_ads',
|
|
132
|
+
'facebook_ads_connection').
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
defn = ctx.catalog.get(key)
|
|
136
|
+
if defn is None:
|
|
137
|
+
return ToolError(error=f"Definition '{key}' not found in catalog")
|
|
138
|
+
return DefinitionDetail(definition=_inline_refs(defn))
|
|
139
|
+
except Exception as e:
|
|
140
|
+
return ToolError(error=str(e))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# -- Asset schemas (kind-specific by nature) -------------------------------------
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_asset_schema(ctx: ToolkitContext, source_key: str, asset_key: str) -> AssetSchemaResult | ToolError:
|
|
147
|
+
"""Get the JSON schema for a specific asset within a source.
|
|
148
|
+
|
|
149
|
+
Schemas come from the catalog: they are a property of the source
|
|
150
|
+
definition, shared by every instance in the org's collection.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
source_key: The source key (e.g. 'facebook_ads').
|
|
154
|
+
asset_key: The asset key within the source (e.g. 'ad_insights').
|
|
155
|
+
|
|
156
|
+
Returns the asset schema with field names, types, and descriptions.
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
defn = ctx.catalog.get(source_key)
|
|
160
|
+
if defn is None or defn.get("kind") != "source":
|
|
161
|
+
return ToolError(error=f"Source '{source_key}' not found in catalog")
|
|
162
|
+
|
|
163
|
+
for asset_def in defn.get("assets", []):
|
|
164
|
+
if asset_def.get("key") == asset_key or asset_def.get("qualified_key") == f"{source_key}.{asset_key}":
|
|
165
|
+
asset_def = _inline_refs(asset_def)
|
|
166
|
+
return AssetSchemaResult(
|
|
167
|
+
source_key=source_key,
|
|
168
|
+
asset_key=asset_key,
|
|
169
|
+
qualified_key=f"{source_key}.{asset_key}",
|
|
170
|
+
asset_schema=asset_def.get("asset_schema"),
|
|
171
|
+
partitioning=asset_def.get("partitioning"),
|
|
172
|
+
tags=asset_def.get("tags", []),
|
|
173
|
+
requires=asset_def.get("requires", {}),
|
|
174
|
+
optional_requires=asset_def.get("optional_requires", {}),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return ToolError(error=f"Asset '{asset_key}' not found in source '{source_key}'")
|
|
178
|
+
except Exception as e:
|
|
179
|
+
return ToolError(error=str(e))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def search_fields(ctx: ToolkitContext, query: str) -> FieldSearchResult | ToolError:
|
|
183
|
+
"""Search for fields across all asset schemas in the catalog matching a query string.
|
|
184
|
+
|
|
185
|
+
Searches every source definition's schemas, whether or not the source is
|
|
186
|
+
in the org's collection.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
query: Substring to search for in field names and descriptions (case-insensitive).
|
|
190
|
+
|
|
191
|
+
Returns matching fields grouped by source and asset, with field type and description.
|
|
192
|
+
"""
|
|
193
|
+
try:
|
|
194
|
+
query_lower = query.lower()
|
|
195
|
+
matches: list[FieldMatch] = []
|
|
196
|
+
|
|
197
|
+
for key, defn in ctx.catalog.items():
|
|
198
|
+
if defn.get("kind") != "source":
|
|
199
|
+
continue
|
|
200
|
+
source_key = key
|
|
201
|
+
for asset_def in defn.get("assets", []):
|
|
202
|
+
asset_key = asset_def.get("key", "")
|
|
203
|
+
schema = asset_def.get("asset_schema")
|
|
204
|
+
if not schema or "properties" not in schema:
|
|
205
|
+
continue
|
|
206
|
+
for field_name, field_info in schema["properties"].items():
|
|
207
|
+
field_desc = field_info.get("description", "")
|
|
208
|
+
if query_lower in field_name.lower() or query_lower in field_desc.lower():
|
|
209
|
+
matches.append(FieldMatch(
|
|
210
|
+
source_key=source_key,
|
|
211
|
+
asset_key=asset_key,
|
|
212
|
+
qualified_key=f"{source_key}.{asset_key}",
|
|
213
|
+
field_name=field_name,
|
|
214
|
+
field_type=_extract_type(field_info),
|
|
215
|
+
description=field_desc,
|
|
216
|
+
))
|
|
217
|
+
|
|
218
|
+
return FieldSearchResult(query=query, match_count=len(matches), matches=matches)
|
|
219
|
+
except Exception as e:
|
|
220
|
+
return ToolError(error=str(e))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def compare_schemas(
|
|
224
|
+
ctx: ToolkitContext,
|
|
225
|
+
source_key_a: str,
|
|
226
|
+
asset_key_a: str,
|
|
227
|
+
source_key_b: str,
|
|
228
|
+
asset_key_b: str,
|
|
229
|
+
) -> SchemaComparison | ToolError:
|
|
230
|
+
"""Compare the schemas of two assets side by side.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
source_key_a: Source key for the first asset.
|
|
234
|
+
asset_key_a: Asset key for the first asset.
|
|
235
|
+
source_key_b: Source key for the second asset.
|
|
236
|
+
asset_key_b: Asset key for the second asset.
|
|
237
|
+
|
|
238
|
+
Returns shared fields, fields unique to each asset, and any type mismatches.
|
|
239
|
+
"""
|
|
240
|
+
try:
|
|
241
|
+
schema_a = _get_schema_properties(ctx, source_key_a, asset_key_a)
|
|
242
|
+
schema_b = _get_schema_properties(ctx, source_key_b, asset_key_b)
|
|
243
|
+
|
|
244
|
+
if isinstance(schema_a, ToolError):
|
|
245
|
+
return schema_a
|
|
246
|
+
if isinstance(schema_b, ToolError):
|
|
247
|
+
return schema_b
|
|
248
|
+
|
|
249
|
+
fields_a = set(schema_a.keys())
|
|
250
|
+
fields_b = set(schema_b.keys())
|
|
251
|
+
|
|
252
|
+
shared = fields_a & fields_b
|
|
253
|
+
only_a = fields_a - fields_b
|
|
254
|
+
only_b = fields_b - fields_a
|
|
255
|
+
|
|
256
|
+
shared_details = []
|
|
257
|
+
for field in sorted(shared):
|
|
258
|
+
type_a = _extract_type(schema_a[field])
|
|
259
|
+
type_b = _extract_type(schema_b[field])
|
|
260
|
+
shared_details.append(SharedField(
|
|
261
|
+
field=field,
|
|
262
|
+
type_a=type_a,
|
|
263
|
+
type_b=type_b,
|
|
264
|
+
type_match=type_a == type_b,
|
|
265
|
+
))
|
|
266
|
+
|
|
267
|
+
return SchemaComparison(
|
|
268
|
+
asset_a=f"{source_key_a}.{asset_key_a}",
|
|
269
|
+
asset_b=f"{source_key_b}.{asset_key_b}",
|
|
270
|
+
shared_count=len(shared),
|
|
271
|
+
only_a_count=len(only_a),
|
|
272
|
+
only_b_count=len(only_b),
|
|
273
|
+
shared_fields=shared_details,
|
|
274
|
+
only_in_a=sorted(only_a),
|
|
275
|
+
only_in_b=sorted(only_b),
|
|
276
|
+
)
|
|
277
|
+
except Exception as e:
|
|
278
|
+
return ToolError(error=str(e))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# -- Helpers -------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _extract_type(field_info: dict[str, Any]) -> str:
|
|
285
|
+
"""Extract a human-readable type string from a JSON schema field definition."""
|
|
286
|
+
if "anyOf" in field_info:
|
|
287
|
+
types = [t.get("type", "unknown") for t in field_info["anyOf"] if t.get("type") != "null"]
|
|
288
|
+
return types[0] if len(types) == 1 else " | ".join(types) if types else "any"
|
|
289
|
+
return field_info.get("type", "unknown")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _get_schema_properties(ctx: ToolkitContext, source_key: str, asset_key: str) -> dict[str, Any] | ToolError:
|
|
293
|
+
"""Retrieve the properties dict from an asset's JSON schema."""
|
|
294
|
+
defn = ctx.catalog.get(source_key)
|
|
295
|
+
if defn is None or defn.get("kind") != "source":
|
|
296
|
+
return ToolError(error=f"Source '{source_key}' not found in catalog")
|
|
297
|
+
for asset_def in defn.get("assets", []):
|
|
298
|
+
if asset_def.get("key") == asset_key:
|
|
299
|
+
schema = asset_def.get("asset_schema")
|
|
300
|
+
if not schema or "properties" not in schema:
|
|
301
|
+
return ToolError(error=f"Asset '{source_key}.{asset_key}' has no schema")
|
|
302
|
+
return _inline_refs(schema)["properties"]
|
|
303
|
+
return ToolError(error=f"Asset '{asset_key}' not found in source '{source_key}'")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Collection tools — the org's component instances, read-only.
|
|
2
|
+
|
|
3
|
+
Creation and connection operations stay with the agent; this module is
|
|
4
|
+
shared with surfaces that must stay read-only.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from interloper.component import KINDS
|
|
10
|
+
|
|
11
|
+
from interloper_toolkit.context import ToolkitContext
|
|
12
|
+
from interloper_toolkit.models import ComponentCounts, ComponentList, ComponentSummary, ToolError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def list_components(
|
|
16
|
+
ctx: ToolkitContext, kind: str | None = None
|
|
17
|
+
) -> ComponentCounts | ComponentList | ToolError:
|
|
18
|
+
"""List the components in the organisation's collection.
|
|
19
|
+
|
|
20
|
+
This answers "what do we have?" — what *could* be added (the catalog of
|
|
21
|
+
definitions) is the Catalog specialist's domain. Sensitive kinds
|
|
22
|
+
(connections, configs, resources) always return identity and metadata
|
|
23
|
+
only — never credential or config values.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
kind: Component kind to list — e.g. 'source', 'connection',
|
|
27
|
+
'destination'. Omit for per-kind counts only; call again with a
|
|
28
|
+
kind for the entries.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
if kind is None:
|
|
32
|
+
counts: dict[str, int] = {}
|
|
33
|
+
for c in ctx.store.list_components(ctx.org_id):
|
|
34
|
+
counts[c.kind] = counts.get(c.kind, 0) + 1
|
|
35
|
+
return ComponentCounts(
|
|
36
|
+
component_counts=counts,
|
|
37
|
+
message="Call again with a kind for the entries.",
|
|
38
|
+
)
|
|
39
|
+
if kind not in KINDS:
|
|
40
|
+
return ToolError(error=f"Unknown kind '{kind}'", valid_kinds=sorted(KINDS.keys()))
|
|
41
|
+
|
|
42
|
+
results = []
|
|
43
|
+
for c in ctx.store.list_components(ctx.org_id, kinds=[kind]):
|
|
44
|
+
entry = ComponentSummary(
|
|
45
|
+
id=str(c.id),
|
|
46
|
+
key=c.key,
|
|
47
|
+
name=c.name,
|
|
48
|
+
type_name=(ctx.catalog.get(c.key) or {}).get("name", c.key),
|
|
49
|
+
created_at=c.created_at,
|
|
50
|
+
)
|
|
51
|
+
# Fail closed: a sensitive kind's config is (or wraps) credentials.
|
|
52
|
+
if not KINDS[kind].sensitive:
|
|
53
|
+
entry.config = c.config
|
|
54
|
+
if kind == "source":
|
|
55
|
+
entry.asset_count = len(c.children)
|
|
56
|
+
results.append(entry)
|
|
57
|
+
|
|
58
|
+
return ComponentList(kind=kind, count=len(results), components=results)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
return ToolError(error=str(e))
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Execution context and serialization for toolkit functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from interloper_db.store import Store
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ToolkitContext:
|
|
16
|
+
"""Everything a toolkit function needs: persistence, catalog, tenant.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
store: The Store to query.
|
|
20
|
+
catalog: The *dumped* catalog (``Catalog.dump()``) — a plain dict of
|
|
21
|
+
component definitions keyed by catalog key.
|
|
22
|
+
org_id: The organisation every query is scoped to.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
store: Store
|
|
26
|
+
catalog: dict[str, Any]
|
|
27
|
+
org_id: UUID
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def serialize(obj: Any) -> Any:
|
|
31
|
+
"""Convert a SQLModel instance (or collection) to a JSON-safe dict.
|
|
32
|
+
|
|
33
|
+
Recursively handles UUIDs, datetimes, dates, lists, and nested models.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
obj: A SQLModel row, dict, list, or primitive.
|
|
37
|
+
"""
|
|
38
|
+
if obj is None:
|
|
39
|
+
return None
|
|
40
|
+
if isinstance(obj, (str, int, float, bool)):
|
|
41
|
+
return obj
|
|
42
|
+
if isinstance(obj, UUID):
|
|
43
|
+
return str(obj)
|
|
44
|
+
if isinstance(obj, datetime.datetime):
|
|
45
|
+
return obj.isoformat()
|
|
46
|
+
if isinstance(obj, datetime.date):
|
|
47
|
+
return obj.isoformat()
|
|
48
|
+
if isinstance(obj, dict):
|
|
49
|
+
return {k: serialize(v) for k, v in obj.items()}
|
|
50
|
+
if isinstance(obj, (list, tuple)):
|
|
51
|
+
return [serialize(item) for item in obj]
|
|
52
|
+
# SQLModel / Pydantic BaseModel
|
|
53
|
+
if hasattr(obj, "model_dump"):
|
|
54
|
+
return serialize(obj.model_dump())
|
|
55
|
+
return str(obj)
|