interloper-toolkit 0.76.0__tar.gz → 0.78.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: interloper-toolkit
3
- Version: 0.76.0
3
+ Version: 0.78.0
4
4
  Summary: Interloper shared read-only tool functions for AI surfaces (agent, MCP)
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-toolkit"
3
- version = "0.76.0"
3
+ version = "0.78.0"
4
4
  description = "Interloper shared read-only tool functions for AI surfaces (agent, MCP)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -14,7 +14,7 @@ name = "Guillaume Onfroy"
14
14
  email = "guillaume@digitlcloud.com"
15
15
 
16
16
  [build-system]
17
- requires = ["uv_build>=0.11.5,<0.12"]
17
+ requires = ["uv_build>=0.12.9,<0.13"]
18
18
  build-backend = "uv_build"
19
19
 
20
20
  [tool.uv.sources.interloper-core]
@@ -54,6 +54,8 @@ convention = "google"
54
54
  "D102",
55
55
  "D103",
56
56
  "D104",
57
+ "RUF069",
58
+ "PLW0108",
57
59
  ]
58
60
  "src/interloper_toolkit/{analytics,catalog,collection,lineage,scheduling}.py" = [
59
61
  "D417",
@@ -3,7 +3,7 @@
3
3
  # ###############
4
4
  [project]
5
5
  name = "interloper-toolkit"
6
- version = "0.76.0"
6
+ version = "0.78.0"
7
7
  description = "Interloper shared read-only tool functions for AI surfaces (agent, MCP)"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
@@ -14,7 +14,7 @@ dependencies = [
14
14
  ]
15
15
 
16
16
  [build-system]
17
- requires = ["uv_build>=0.11.5,<0.12"]
17
+ requires = ["uv_build>=0.12.9,<0.13"]
18
18
  build-backend = "uv_build"
19
19
 
20
20
  [tool.uv.sources]
@@ -36,5 +36,5 @@ convention = "google"
36
36
 
37
37
  [tool.ruff.lint.per-file-ignores]
38
38
  "__init__.py" = ["F401", "F403"]
39
- "tests/**" = ["ANN", "F811", "D101", "D102", "D103", "D104"]
39
+ "tests/**" = ["ANN", "F811", "D101", "D102", "D103", "D104", "RUF069", "PLW0108"]
40
40
  "src/interloper_toolkit/{analytics,catalog,collection,lineage,scheduling}.py" = ["D417", "DOC201", "BLE001"]
@@ -0,0 +1,23 @@
1
+ """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
+ Almost every function here is read-only; the sole exceptions are
11
+ :func:`interloper_toolkit.collection.bind_relation` and
12
+ :func:`interloper_toolkit.collection.unbind_relation`, which write and are
13
+ re-exported here as this package's whole write surface. A surface that must
14
+ stay read-only (the MCP server's own registration is one) never registers
15
+ those two; the ADK agent, whose own write tools already live beside them,
16
+ does.
17
+ """
18
+
19
+ from interloper_toolkit.collection import bind_relation, unbind_relation
20
+ from interloper_toolkit.context import ToolkitContext, serialize
21
+ from interloper_toolkit.models import ToolError
22
+
23
+ __all__ = ["ToolError", "ToolkitContext", "bind_relation", "serialize", "unbind_relation"]
@@ -122,7 +122,7 @@ def list_definitions(
122
122
  def get_definition(ctx: ToolkitContext, key: str) -> DefinitionDetail | ToolError:
123
123
  """Get a component definition's full catalog detail.
124
124
 
125
- For a source this includes the config schema, resource slots,
125
+ For a source this includes the config schema, declared relations,
126
126
  destination types, and all its assets with their schemas. This is the
127
127
  catalog definition (the component *type*), not an instance from the
128
128
  org's collection.
@@ -0,0 +1,120 @@
1
+ """Collection tools: the org's component instances.
2
+
3
+ ``list_components`` is read-only, shared with surfaces that must stay
4
+ read-only (kind-specific creation and connection operations stay with the
5
+ agent). ``bind_relation`` and ``unbind_relation`` are the exception: they
6
+ write, generically over every kind's declared relations, so a caller must
7
+ never register them alongside a read-only tool set (see
8
+ ``interloper_mcp.tools``, deliberately read-only), only wherever that
9
+ surface's own write tools already live.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from uuid import UUID
15
+
16
+ from interloper.component import KINDS
17
+ from interloper.errors import ConfigError, NotFoundError
18
+
19
+ from interloper_toolkit.context import ToolkitContext
20
+ from interloper_toolkit.models import (
21
+ BindResult,
22
+ ComponentCounts,
23
+ ComponentList,
24
+ ComponentSummary,
25
+ ToolError,
26
+ UnbindResult,
27
+ )
28
+
29
+
30
+ def list_components(
31
+ ctx: ToolkitContext, kind: str | None = None
32
+ ) -> ComponentCounts | ComponentList | ToolError:
33
+ """List the components in the organisation's collection.
34
+
35
+ This answers "what do we have?" — what *could* be added (the catalog of
36
+ definitions) is the Catalog specialist's domain. Sensitive kinds
37
+ (connections, configs, resources) always return identity and metadata
38
+ only — never credential or config values.
39
+
40
+ Args:
41
+ kind: Component kind to list — e.g. 'source', 'connection',
42
+ 'destination'. Omit for per-kind counts only; call again with a
43
+ kind for the entries.
44
+ """
45
+ try:
46
+ if kind is None:
47
+ counts: dict[str, int] = {}
48
+ for c in ctx.store.components.list_all(ctx.org_id):
49
+ counts[c.kind] = counts.get(c.kind, 0) + 1
50
+ return ComponentCounts(
51
+ component_counts=counts,
52
+ message="Call again with a kind for the entries.",
53
+ )
54
+ if kind not in KINDS:
55
+ return ToolError(error=f"Unknown kind '{kind}'", valid_kinds=sorted(KINDS.keys()))
56
+
57
+ results = []
58
+ for c in ctx.store.components.list_all(ctx.org_id, kinds=[kind]):
59
+ entry = ComponentSummary(
60
+ id=str(c.id),
61
+ key=c.key,
62
+ name=c.name,
63
+ type_name=(ctx.catalog.get(c.key) or {}).get("name", c.key),
64
+ created_at=c.created_at,
65
+ )
66
+ # Fail closed: a sensitive kind's config is (or wraps) credentials.
67
+ if not KINDS[kind].sensitive:
68
+ entry.config = c.config
69
+ if kind == "source":
70
+ entry.asset_count = len(c.children)
71
+ results.append(entry)
72
+
73
+ return ComponentList(kind=kind, count=len(results), components=results)
74
+ except Exception as e:
75
+ return ToolError(error=str(e))
76
+
77
+
78
+ # -- Relations (write) ---------------------------------------------------------
79
+
80
+
81
+ def bind_relation(ctx: ToolkitContext, component_id: str, name: str, dst_id: str) -> BindResult | ToolError:
82
+ """Bind one component to another under a declared relation name.
83
+
84
+ Works on any kind: a source's connection, a job's watched assets, a
85
+ destination target, whatever the component's own class declares under
86
+ that name. A ``many`` name accumulates; a single-valued one repoints, so
87
+ rebinding it needs no prior unbind_relation call. Recap what the binding
88
+ changes and get the user's explicit confirmation BEFORE calling this.
89
+
90
+ Args:
91
+ component_id: UUID of the component the relation originates from.
92
+ name: Relation name, which the component's class must declare.
93
+ dst_id: UUID of the destination component the relation points at.
94
+ Must belong to the same organisation as the source.
95
+ """
96
+ try:
97
+ row = ctx.store.relations.add(UUID(component_id), name=name, dst_id=UUID(dst_id))
98
+ except (ConfigError, NotFoundError, ValueError) as e:
99
+ return ToolError(error=str(e))
100
+ return BindResult(src_id=str(row.src_id), name=row.name, dst_id=str(row.dst_id), dst_kind=row.dst_kind)
101
+
102
+
103
+ def unbind_relation(ctx: ToolkitContext, component_id: str, name: str, dst_id: str) -> UnbindResult | ToolError:
104
+ """Detach one component from another under a declared relation name.
105
+
106
+ A non-optional relation cannot be emptied, only repointed with
107
+ bind_relation. Recap what the component loses and get the user's
108
+ explicit confirmation BEFORE calling this.
109
+
110
+ Args:
111
+ component_id: UUID of the component the relation originates from.
112
+ name: Relation name the edge is filed under.
113
+ dst_id: UUID of the destination the removed edge points at. Removing
114
+ an edge that isn't there is a no-op.
115
+ """
116
+ try:
117
+ ctx.store.relations.remove(UUID(component_id), name=name, dst_id=UUID(dst_id))
118
+ except (ConfigError, NotFoundError, ValueError) as e:
119
+ return ToolError(error=str(e))
120
+ return UnbindResult(src_id=component_id, name=name, dst_id=dst_id)
@@ -10,11 +10,11 @@ from interloper_toolkit.models import (
10
10
  AssetRef,
11
11
  CrossSourceDependencies,
12
12
  CrossSourceEdge,
13
- DependencyEdge,
14
13
  DownstreamResult,
15
14
  ImpactAnalysis,
16
15
  LineageItem,
17
16
  LineageResult,
17
+ RelationEdge,
18
18
  ToolError,
19
19
  UpstreamResult,
20
20
  )
@@ -27,19 +27,19 @@ def get_upstream(ctx: ToolkitContext, asset_id: str) -> UpstreamResult | ToolErr
27
27
  asset_id: UUID of the asset to inspect.
28
28
 
29
29
  Returns the list of upstream assets that this asset depends on,
30
- including the parameter name used for each dependency.
30
+ including the relation name used for each dependency.
31
31
  """
32
32
  try:
33
- deps = ctx.store.relations.list_all(ctx.org_id, type="dependency")
33
+ deps = ctx.store.relations.list_all(ctx.org_id, src_kind="asset", dst_kind="asset")
34
34
  target = UUID(asset_id)
35
35
 
36
36
  upstream = []
37
37
  for dep in deps:
38
38
  if dep.src_id == target:
39
39
  asset = ctx.store.components.get(dep.dst_id, kind="asset")
40
- upstream.append(DependencyEdge(
40
+ upstream.append(RelationEdge(
41
41
  asset_id=str(dep.dst_id),
42
- param_name=dep.slot,
42
+ param_name=dep.name,
43
43
  asset_key=asset.key,
44
44
  source_id=str(asset.parent_id),
45
45
  ))
@@ -58,16 +58,16 @@ def get_downstream(ctx: ToolkitContext, asset_id: str) -> DownstreamResult | Too
58
58
  Returns the list of assets that directly depend on this asset.
59
59
  """
60
60
  try:
61
- deps = ctx.store.relations.list_all(ctx.org_id, type="dependency")
61
+ deps = ctx.store.relations.list_all(ctx.org_id, src_kind="asset", dst_kind="asset")
62
62
  target = UUID(asset_id)
63
63
 
64
64
  downstream = []
65
65
  for dep in deps:
66
66
  if dep.dst_id == target:
67
67
  asset = ctx.store.components.get(dep.src_id, kind="asset")
68
- downstream.append(DependencyEdge(
68
+ downstream.append(RelationEdge(
69
69
  asset_id=str(dep.src_id),
70
- param_name=dep.slot,
70
+ param_name=dep.name,
71
71
  asset_key=asset.key,
72
72
  source_id=str(asset.parent_id),
73
73
  ))
@@ -165,7 +165,7 @@ def cross_source_dependencies(ctx: ToolkitContext) -> CrossSourceDependencies |
165
165
  different sources.
166
166
  """
167
167
  try:
168
- deps = ctx.store.relations.list_all(ctx.org_id, type="dependency")
168
+ deps = ctx.store.relations.list_all(ctx.org_id, src_kind="asset", dst_kind="asset")
169
169
  assets = ctx.store.components.list_all(ctx.org_id, kinds=["asset"])
170
170
 
171
171
  asset_source: dict[UUID, UUID | None] = {}
@@ -186,7 +186,7 @@ def cross_source_dependencies(ctx: ToolkitContext) -> CrossSourceDependencies |
186
186
  downstream=asset_info.get(dep.src_id, AssetRef()),
187
187
  upstream_asset_id=str(dep.dst_id),
188
188
  upstream=asset_info.get(dep.dst_id, AssetRef()),
189
- param_name=dep.slot,
189
+ param_name=dep.name,
190
190
  ))
191
191
 
192
192
  return CrossSourceDependencies(cross_source_count=len(cross_deps), dependencies=cross_deps)
@@ -201,7 +201,7 @@ def _build_adjacency(
201
201
  ctx: ToolkitContext,
202
202
  direction: str,
203
203
  ) -> tuple[dict[UUID, list[UUID]], dict[UUID, dict[str, str]]]:
204
- """Build an adjacency map and asset info lookup from all dependencies.
204
+ """Build an adjacency map and asset info lookup from all asset-to-asset relations.
205
205
 
206
206
  Args:
207
207
  ctx: The toolkit context.
@@ -211,7 +211,7 @@ def _build_adjacency(
211
211
  ``(adjacency_map, asset_info_map)`` — info values feed
212
212
  :class:`LineageItem` kwargs (asset_key, source_id, source_key).
213
213
  """
214
- deps = ctx.store.relations.list_all(ctx.org_id, type="dependency")
214
+ deps = ctx.store.relations.list_all(ctx.org_id, src_kind="asset", dst_kind="asset")
215
215
  assets = ctx.store.components.list_all(ctx.org_id, kinds=["asset"])
216
216
 
217
217
  asset_info: dict[UUID, dict[str, str]] = {}
@@ -180,11 +180,30 @@ class ComponentList(BaseModel):
180
180
  components: list[ComponentSummary]
181
181
 
182
182
 
183
+ class BindResult(BaseModel):
184
+ """One relation edge created or repointed by ``bind_relation``."""
185
+
186
+ status: Literal["success"] = "success"
187
+ src_id: str
188
+ name: str
189
+ dst_id: str
190
+ dst_kind: str
191
+
192
+
193
+ class UnbindResult(BaseModel):
194
+ """One relation edge removed by ``unbind_relation``."""
195
+
196
+ status: Literal["success"] = "success"
197
+ src_id: str
198
+ name: str
199
+ dst_id: str
200
+
201
+
183
202
  # -- Lineage --------------------------------------------------------------------
184
203
 
185
204
 
186
- class DependencyEdge(BaseModel):
187
- """A direct dependency edge from the perspective of one asset."""
205
+ class RelationEdge(BaseModel):
206
+ """A direct asset-to-asset relation edge from the perspective of one asset."""
188
207
 
189
208
  asset_id: str
190
209
  param_name: str
@@ -197,7 +216,7 @@ class UpstreamResult(BaseModel):
197
216
 
198
217
  status: Literal["success"] = "success"
199
218
  asset_id: str
200
- upstream: list[DependencyEdge]
219
+ upstream: list[RelationEdge]
201
220
 
202
221
 
203
222
  class DownstreamResult(BaseModel):
@@ -205,7 +224,7 @@ class DownstreamResult(BaseModel):
205
224
 
206
225
  status: Literal["success"] = "success"
207
226
  asset_id: str
208
- downstream: list[DependencyEdge]
227
+ downstream: list[RelationEdge]
209
228
 
210
229
 
211
230
  class LineageItem(BaseModel):
@@ -1,14 +0,0 @@
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__ = ["ToolError", "ToolkitContext", "serialize"]
@@ -1,60 +0,0 @@
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.components.list_all(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.components.list_all(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))