deepquery-sdk 1.0.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,208 @@
1
+ """Implementations of the `deepquery` CLI subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import json
7
+ import re
8
+ import shutil
9
+ from pathlib import Path
10
+
11
+ from ..auth.base import Credential, static_credential_provider
12
+ from ..validation import validate_connector
13
+ from .loader import load_connector, load_connector_class
14
+
15
+ _TEMPLATE_DIR = Path(__file__).resolve().parents[1] / "templates" / "connector"
16
+
17
+
18
+ # --------------------------------------------------------------------------- #
19
+ # scaffold
20
+ # --------------------------------------------------------------------------- #
21
+ def _class_name_from(name: str) -> str:
22
+ parts = re.split(r"[^0-9a-zA-Z]+", name)
23
+ cls = "".join(p[:1].upper() + p[1:] for p in parts if p)
24
+ if not cls or not cls[0].isalpha():
25
+ cls = "My" + cls
26
+ return cls + "Connector"
27
+
28
+
29
+ def cmd_scaffold(args) -> int:
30
+ name = args.name
31
+ class_name = _class_name_from(name)
32
+ title = name.replace("_", " ").replace("-", " ").title()
33
+ out_dir = Path(args.dir) if args.dir else Path(name)
34
+ if out_dir.exists() and any(out_dir.iterdir()):
35
+ print(f"error: target directory '{out_dir}' already exists and is not empty")
36
+ return 1
37
+ out_dir.mkdir(parents=True, exist_ok=True)
38
+
39
+ subs = {
40
+ "__CONNECTOR_NAME__": name,
41
+ "__CLASS_NAME__": class_name,
42
+ "__CONNECTOR_TITLE__": title,
43
+ }
44
+ # Walk the template tree recursively, preserving subdirectories (e.g. tests/)
45
+ # and stripping the .tmpl suffix.
46
+ for tmpl in sorted(_TEMPLATE_DIR.rglob("*.tmpl")):
47
+ rel = tmpl.relative_to(_TEMPLATE_DIR).with_suffix("") # drops .tmpl
48
+ dest = out_dir / rel
49
+ dest.parent.mkdir(parents=True, exist_ok=True)
50
+ text = tmpl.read_text(encoding="utf-8")
51
+ for key, value in subs.items():
52
+ text = text.replace(key, value)
53
+ dest.write_text(text, encoding="utf-8")
54
+ print(f" created {dest}")
55
+
56
+ print(f"\nScaffolded '{name}' ({class_name}) in {out_dir}/")
57
+ print(f"Next: deepquery validate {out_dir / 'connector.py'}")
58
+ return 0
59
+
60
+
61
+ # --------------------------------------------------------------------------- #
62
+ # validate
63
+ # --------------------------------------------------------------------------- #
64
+ def cmd_validate(args) -> int:
65
+ try:
66
+ connector = load_connector(args.target)
67
+ except Exception as exc:
68
+ # A connector that violates a structural contract may fail to import
69
+ # (e.g. an action with no preview raises at class definition). Report it.
70
+ print(f"FAIL could not load connector: {exc}")
71
+ return 1
72
+ report = validate_connector(connector)
73
+ print(report.format())
74
+ return 0 if report.ok else 1
75
+
76
+
77
+ # --------------------------------------------------------------------------- #
78
+ # manifest
79
+ # --------------------------------------------------------------------------- #
80
+ def cmd_manifest(args) -> int:
81
+ connector = load_connector(args.target)
82
+ text = connector.build_manifest().to_json()
83
+ if args.out:
84
+ Path(args.out).write_text(text, encoding="utf-8")
85
+ print(f"wrote manifest to {args.out}")
86
+ else:
87
+ print(text)
88
+ return 0
89
+
90
+
91
+ # --------------------------------------------------------------------------- #
92
+ # emit
93
+ # --------------------------------------------------------------------------- #
94
+ _SERVER_TEMPLATE = '''"""Deployable MCP server for the {name} connector (emitted by DeepQuerySDK)."""
95
+
96
+ import sys
97
+ from pathlib import Path
98
+
99
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
100
+
101
+ from {module} import {cls}
102
+ from deepquery_sdk.mcp_emit import run_stdio
103
+
104
+ if __name__ == "__main__":
105
+ # The gateway injects credentials at call time; wire your secret source here.
106
+ run_stdio({cls}())
107
+ '''
108
+
109
+
110
+ def cmd_emit(args) -> int:
111
+ cls = load_connector_class(args.target)
112
+ out_dir = Path(args.out)
113
+ out_dir.mkdir(parents=True, exist_ok=True)
114
+
115
+ # manifest.json
116
+ manifest_path = out_dir / "manifest.json"
117
+ manifest_path.write_text(cls().build_manifest().to_json(), encoding="utf-8")
118
+
119
+ # copy the connector source so the artifact is self-contained
120
+ src_file = inspect.getsourcefile(cls)
121
+ if src_file is None:
122
+ print("error: cannot locate connector source file to emit")
123
+ return 1
124
+ src_path = Path(src_file)
125
+ copied = out_dir / src_path.name
126
+ shutil.copyfile(src_path, copied)
127
+
128
+ # server entry point
129
+ server_path = out_dir / "server.py"
130
+ server_path.write_text(
131
+ _SERVER_TEMPLATE.format(name=cls().name, module=src_path.stem, cls=cls.__name__),
132
+ encoding="utf-8",
133
+ )
134
+
135
+ print(f"emitted MCP server artifact to {out_dir}/")
136
+ for p in (manifest_path, copied, server_path):
137
+ print(f" {p}")
138
+ print(f"\nrun it: python {server_path}")
139
+ return 0
140
+
141
+
142
+ # --------------------------------------------------------------------------- #
143
+ # run-dev
144
+ # --------------------------------------------------------------------------- #
145
+ def cmd_run_dev(args) -> int:
146
+ import anyio
147
+
148
+ connector = load_connector(args.target)
149
+ # Stand in for the gateway with a dev credential so actions can execute.
150
+ connector.set_credential_provider(static_credential_provider(Credential(token="dev-token")))
151
+ anyio.run(_run_dev_session, connector)
152
+ return 0
153
+
154
+
155
+ def _parse_kv(pairs: list[str]) -> dict[str, str]:
156
+ args: dict[str, str] = {}
157
+ for pair in pairs:
158
+ if "=" in pair:
159
+ key, _, value = pair.partition("=")
160
+ args[key] = value
161
+ return args
162
+
163
+
164
+ async def _run_dev_session(connector) -> None:
165
+ from ..harness import HarnessError, MockAgent
166
+
167
+ async with MockAgent(connector) as agent:
168
+ caps = await agent.capabilities()
169
+ print(f"\nconnected to '{connector.name}' via the mock agent. Capabilities:")
170
+ for group, entries in caps.items():
171
+ for e in entries:
172
+ print(f" [{group[:-1]}] {e['name']} (dq.mutates={e['dq.mutates']})")
173
+ print(
174
+ "\nCommands:\n"
175
+ " read <name> key=value ...\n"
176
+ " action <name> key=value ... (you'll be asked to approve/reject)\n"
177
+ " caps | quit\n"
178
+ )
179
+ while True:
180
+ try:
181
+ line = input("harness> ").strip()
182
+ except (EOFError, KeyboardInterrupt):
183
+ print()
184
+ return
185
+ if not line:
186
+ continue
187
+ parts = line.split()
188
+ cmd, rest = parts[0], parts[1:]
189
+ try:
190
+ if cmd in {"quit", "exit", "q"}:
191
+ return
192
+ if cmd == "caps":
193
+ print(json.dumps(await agent.capabilities(), indent=2))
194
+ elif cmd == "read" and rest:
195
+ records = await agent.read(rest[0], **_parse_kv(rest[1:]))
196
+ print(json.dumps(records, indent=2))
197
+ elif cmd == "action" and rest:
198
+ preview = await agent.request_action(rest[0], **_parse_kv(rest[1:]))
199
+ print(f"\nPREVIEW: {preview['preview']}")
200
+ decision = input("approve? [y/N] ").strip().lower()
201
+ if decision in {"y", "yes"}:
202
+ print(json.dumps(await agent.approve(preview["approval_token"]), indent=2))
203
+ else:
204
+ print(json.dumps(await agent.reject(preview["approval_token"]), indent=2))
205
+ else:
206
+ print(" unknown command")
207
+ except (HarnessError, KeyError, Exception) as exc: # surface, don't crash
208
+ print(f" error: {exc}")
@@ -0,0 +1,95 @@
1
+ """Load a developer's `Connector` from a target spec, for the CLI.
2
+
3
+ Accepted target forms:
4
+ module.path:ClassName import a module and pick the class
5
+ module.path import a module, find its single Connector
6
+ path/to/connector.py:Cls load a file, pick the class
7
+ path/to/connector.py load a file, find its single Connector
8
+ path/to/dir load <dir>/connector.py, find its single Connector
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib
14
+ import importlib.util
15
+ import inspect
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from ..connector import Connector
20
+
21
+
22
+ class LoaderError(Exception):
23
+ pass
24
+
25
+
26
+ def _load_file_module(path: Path):
27
+ path = path.resolve()
28
+ if not path.exists():
29
+ raise LoaderError(f"file not found: {path}")
30
+ # Make the file's directory importable so any sibling imports resolve.
31
+ sys.path.insert(0, str(path.parent))
32
+ mod_name = f"_dq_connector_{path.stem}"
33
+ spec = importlib.util.spec_from_file_location(mod_name, path)
34
+ if spec is None or spec.loader is None:
35
+ raise LoaderError(f"cannot load module from {path}")
36
+ module = importlib.util.module_from_spec(spec)
37
+ sys.modules[mod_name] = module
38
+ spec.loader.exec_module(module)
39
+ return module
40
+
41
+
42
+ def _find_single_connector(module) -> type[Connector]:
43
+ candidates = [
44
+ obj
45
+ for _, obj in inspect.getmembers(module, inspect.isclass)
46
+ if issubclass(obj, Connector) and obj is not Connector and obj.__module__ == module.__name__
47
+ ]
48
+ if len(candidates) == 1:
49
+ return candidates[0]
50
+ if not candidates:
51
+ raise LoaderError(f"no Connector subclass found in {module.__name__!r}")
52
+ names = ", ".join(c.__name__ for c in candidates)
53
+ raise LoaderError(
54
+ f"multiple Connector subclasses in {module.__name__!r} ({names}); "
55
+ f"specify one with 'target:ClassName'."
56
+ )
57
+
58
+
59
+ def load_connector_class(target: str) -> type[Connector]:
60
+ """Resolve a target spec to a Connector subclass."""
61
+ # Split a trailing ":ClassName" only when the suffix is a real identifier, so
62
+ # a Windows drive letter ("C:\\...") or a path is not mistaken for a class.
63
+ location, sep, classname = target.rpartition(":")
64
+ if not sep or not classname.isidentifier():
65
+ location, classname = target, ""
66
+
67
+ path = Path(location)
68
+ looks_like_path = location.endswith(".py") or path.exists() or "/" in location or "\\" in location
69
+
70
+ if looks_like_path:
71
+ if path.is_dir():
72
+ path = path / "connector.py"
73
+ module = _load_file_module(path)
74
+ else:
75
+ try:
76
+ module = importlib.import_module(location)
77
+ except ImportError as exc:
78
+ raise LoaderError(f"could not import module {location!r}: {exc}") from exc
79
+
80
+ if classname:
81
+ try:
82
+ cls = getattr(module, classname)
83
+ except AttributeError as exc:
84
+ raise LoaderError(f"{classname!r} not found in {location!r}") from exc
85
+ else:
86
+ cls = _find_single_connector(module)
87
+
88
+ if not (isinstance(cls, type) and issubclass(cls, Connector)):
89
+ raise LoaderError(f"{cls!r} is not a Connector subclass")
90
+ return cls
91
+
92
+
93
+ def load_connector(target: str) -> Connector:
94
+ """Resolve a target spec to an instantiated Connector."""
95
+ return load_connector_class(target)()
@@ -0,0 +1,79 @@
1
+ """Versioning & compatibility contract (SDK_GUIDE.md §11).
2
+
3
+ Connectors are semantically versioned and declare the SDK major version they
4
+ target. The gateway refuses to load a connector built against an incompatible
5
+ SDK major version — with a clear error, rather than failing mysteriously at call
6
+ time. This module is that negotiation.
7
+
8
+ Rules:
9
+ - A connector targeting the same SDK major as the runtime is compatible.
10
+ - A connector targeting a *newer* SDK major than the runtime is refused: the
11
+ deployment's SDK must be upgraded first.
12
+ - A connector targeting an *older* SDK major is refused: the connector must be
13
+ upgraded to the current major (see MIGRATIONS.md).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from dataclasses import dataclass
20
+
21
+ from .manifest import SDK_MAJOR_VERSION, SDK_VERSION, Manifest
22
+
23
+ _SEMVER = re.compile(r"^\d+\.\d+\.\d+([-+].+)?$")
24
+
25
+
26
+ class IncompatibleConnectorError(Exception):
27
+ """Raised when a connector cannot be loaded against the running SDK."""
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CompatibilityResult:
32
+ compatible: bool
33
+ reason: str
34
+ connector_sdk_major: int
35
+ runtime_sdk_major: int
36
+
37
+
38
+ def is_valid_semver(version: str) -> bool:
39
+ return bool(_SEMVER.match(version or ""))
40
+
41
+
42
+ def check_manifest_compatibility(
43
+ manifest: Manifest, *, runtime_sdk_major: int = SDK_MAJOR_VERSION
44
+ ) -> CompatibilityResult:
45
+ """Decide whether a connector manifest can be loaded by this SDK runtime."""
46
+ declared = manifest.sdk_major_version
47
+
48
+ if declared == runtime_sdk_major:
49
+ reason = (
50
+ f"connector '{manifest.name}' targets SDK major {declared}; "
51
+ f"runtime SDK is major {runtime_sdk_major} (v{SDK_VERSION}) — compatible."
52
+ )
53
+ return CompatibilityResult(True, reason, declared, runtime_sdk_major)
54
+
55
+ if declared > runtime_sdk_major:
56
+ reason = (
57
+ f"connector '{manifest.name}' targets SDK major {declared}, which is newer than "
58
+ f"this runtime's SDK major {runtime_sdk_major} (v{SDK_VERSION}). Upgrade the Deep "
59
+ f"Query SDK to load it."
60
+ )
61
+ else:
62
+ reason = (
63
+ f"connector '{manifest.name}' targets SDK major {declared}, which is older than "
64
+ f"this runtime's SDK major {runtime_sdk_major} (v{SDK_VERSION}). The connector must "
65
+ f"be upgraded to SDK major {runtime_sdk_major} (see MIGRATIONS.md)."
66
+ )
67
+ return CompatibilityResult(False, reason, declared, runtime_sdk_major)
68
+
69
+
70
+ def assert_compatible(
71
+ manifest: Manifest, *, runtime_sdk_major: int = SDK_MAJOR_VERSION
72
+ ) -> None:
73
+ """Raise `IncompatibleConnectorError` if the connector cannot be loaded.
74
+
75
+ This is the check the Connector Gateway performs before loading a connector.
76
+ """
77
+ result = check_manifest_compatibility(manifest, runtime_sdk_major=runtime_sdk_major)
78
+ if not result.compatible:
79
+ raise IncompatibleConnectorError(result.reason)
@@ -0,0 +1,283 @@
1
+ """The base `Connector` class developers subclass.
2
+
3
+ A connector declares three things (SDK_GUIDE.md §4): authentication, resources
4
+ (reads), and actions (writes). This module collects the `@resource` and
5
+ `@action` declarations off a subclass at definition time, enforces the
6
+ structural safety contracts (every action has a preview), and exposes the
7
+ collected capabilities to the `mcp_emit` layer and the manifest generator.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextvars
13
+ from contextlib import contextmanager
14
+ from typing import Any, Iterable, Iterator
15
+
16
+ from .action import ActionSpec
17
+ from .auth.base import AppliedAuth, AuthStrategy, Credential, CredentialError, CredentialProvider
18
+ from .gate import ApprovalGate, PendingAction
19
+ from .manifest import (
20
+ ActionManifest,
21
+ AuthMethod,
22
+ DeploymentCompat,
23
+ Manifest,
24
+ ResourceManifest,
25
+ SDK_MAJOR_VERSION,
26
+ )
27
+ from .provenance import CitedRecord, Provenance, make_provenance
28
+ from .resource import ResourceSpec
29
+
30
+ # Call-scoped credential: the gateway injects one before a tool call and the
31
+ # connector reads it via `self.current_credential`. It is never stored on the
32
+ # connector instance, so it cannot leak between calls or be persisted.
33
+ _active_credential: contextvars.ContextVar[Credential | None] = contextvars.ContextVar(
34
+ "dq_active_credential", default=None
35
+ )
36
+
37
+
38
+ class ConnectorContractError(Exception):
39
+ """Raised when a connector subclass violates an SDK safety contract."""
40
+
41
+
42
+ class Connector:
43
+ """Base class for all Deep Query connectors.
44
+
45
+ Subclasses set the class attributes below and declare capabilities with the
46
+ `@resource` and `@action` decorators.
47
+ """
48
+
49
+ # -- identity (override in subclasses) --------------------------------
50
+ name: str = ""
51
+ version: str = "0.0.0"
52
+ description: str = ""
53
+
54
+ # -- auth & deployment declaration ------------------------------------
55
+ # Either declare an AuthStrategy instance (preferred — carries scopes), or
56
+ # just the method enum for connectors with no outbound auth wiring yet.
57
+ auth: AuthStrategy | None = None
58
+ auth_method: AuthMethod = AuthMethod.NONE
59
+ auth_scopes: list[str] = []
60
+ requires_network: bool = True
61
+ air_gapped_capable: bool = False
62
+
63
+ # -- compatibility ----------------------------------------------------
64
+ sdk_major_version: int = SDK_MAJOR_VERSION
65
+
66
+ # populated by __init_subclass__
67
+ _resources: list[ResourceSpec]
68
+ _actions: list[ActionSpec]
69
+
70
+ def __init_subclass__(cls, **kwargs: Any) -> None:
71
+ super().__init_subclass__(**kwargs)
72
+ cls._resources = list(cls._collect_resources())
73
+ cls._actions = list(cls._collect_actions())
74
+ cls._validate_contracts()
75
+
76
+ # -- collection -------------------------------------------------------
77
+ @classmethod
78
+ def _collect_resources(cls) -> Iterable[ResourceSpec]:
79
+ for attr_name in dir(cls):
80
+ obj = getattr(cls, attr_name, None)
81
+ meta = getattr(obj, "__dq_resource__", None)
82
+ if meta is not None:
83
+ yield ResourceSpec(
84
+ name=meta["name"],
85
+ description=meta["description"],
86
+ input_schema=meta["input_schema"],
87
+ attr_name=attr_name,
88
+ )
89
+
90
+ @classmethod
91
+ def _collect_actions(cls) -> Iterable[ActionSpec]:
92
+ seen: set[int] = set()
93
+ for attr_name in dir(cls):
94
+ obj = getattr(cls, attr_name, None)
95
+ if isinstance(obj, ActionSpec) and id(obj) not in seen:
96
+ seen.add(id(obj))
97
+ yield obj
98
+
99
+ @classmethod
100
+ def _validate_contracts(cls) -> None:
101
+ # An abstract base (no name) is allowed to be empty; concrete subclasses
102
+ # are checked structurally.
103
+ names: set[str] = set()
104
+ for spec in cls._resources:
105
+ if spec.name in names:
106
+ raise ConnectorContractError(f"duplicate capability name: {spec.name!r}")
107
+ names.add(spec.name)
108
+ for spec in cls._actions:
109
+ if not spec.has_preview():
110
+ raise ConnectorContractError(
111
+ f"action {spec.name!r} has no preview; every action must declare a "
112
+ f"preview (use @{spec.name}.preview). See SDK_GUIDE.md §4.3."
113
+ )
114
+ if spec.execute_fn is None:
115
+ raise ConnectorContractError(f"action {spec.name!r} has no execute function")
116
+ if spec.name in names:
117
+ raise ConnectorContractError(f"duplicate capability name: {spec.name!r}")
118
+ names.add(spec.name)
119
+
120
+ # -- provenance helper ------------------------------------------------
121
+ def cite(
122
+ self,
123
+ data: Any,
124
+ *,
125
+ source_object_id: str,
126
+ title_or_label: str,
127
+ deep_link: str | None = None,
128
+ mutability_note: str | None = None,
129
+ ) -> CitedRecord:
130
+ """Wrap a record in the provenance envelope (SDK_GUIDE.md §6).
131
+
132
+ Fills in `connector_name` and a fresh `retrieved_at` stamp automatically;
133
+ the developer supplies the source-specific citation fields.
134
+ """
135
+ prov = make_provenance(
136
+ connector_name=self.name,
137
+ source_object_id=source_object_id,
138
+ title_or_label=title_or_label,
139
+ deep_link=deep_link,
140
+ mutability_note=mutability_note,
141
+ )
142
+ return CitedRecord(data=data, provenance=prov)
143
+
144
+ # -- credential injection (SDK_GUIDE.md §7) ---------------------------
145
+ @property
146
+ def _gate(self) -> ApprovalGate:
147
+ gate = self.__dict__.get("_gate_instance")
148
+ if gate is None:
149
+ gate = self.__dict__["_gate_instance"] = ApprovalGate()
150
+ return gate
151
+
152
+ def set_credential_provider(self, provider: CredentialProvider | None) -> None:
153
+ """Register the gateway's per-call credential source. The connector never
154
+ stores the credential itself — only this provider reference."""
155
+ self.__dict__["_credential_provider"] = provider
156
+
157
+ @contextmanager
158
+ def _credential_scope(self) -> Iterator[None]:
159
+ """Activate the injected credential for the duration of one call."""
160
+ provider: CredentialProvider | None = self.__dict__.get("_credential_provider")
161
+ credential = provider() if provider is not None else None
162
+ token = _active_credential.set(credential)
163
+ try:
164
+ yield
165
+ finally:
166
+ _active_credential.reset(token)
167
+
168
+ @property
169
+ def current_credential(self) -> Credential:
170
+ """The credential injected for the current call. Raises outside a call,
171
+ or if the gateway injected none — credentials are never persisted."""
172
+ credential = _active_credential.get()
173
+ if credential is None:
174
+ raise CredentialError(
175
+ "no credential is active for this call; the gateway must inject one via "
176
+ "set_credential_provider(...). Credentials are never stored on the connector."
177
+ )
178
+ return credential
179
+
180
+ def apply_auth(self) -> AppliedAuth:
181
+ """Apply the declared auth strategy to the injected credential, yielding
182
+ the headers / client cert to attach to outbound requests."""
183
+ if self.auth is None:
184
+ raise CredentialError("connector declares no auth strategy (self.auth is None)")
185
+ return self.auth.apply(self.current_credential)
186
+
187
+ # -- gated action lifecycle (SDK_GUIDE.md §4.3, §5) -------------------
188
+ def _action_by_name(self, name: str) -> ActionSpec:
189
+ for spec in self.actions:
190
+ if spec.name == name:
191
+ return spec
192
+ raise KeyError(f"no such action: {name!r}")
193
+
194
+ def request_action(self, name: str, arguments: dict[str, Any]) -> PendingAction:
195
+ """Step 1: preview the action and mint a single-use approval token.
196
+ Does not execute."""
197
+ spec = self._action_by_name(name)
198
+ with self._credential_scope():
199
+ preview = self.preview_action(spec, arguments)
200
+ return self._gate.request(name, arguments, preview)
201
+
202
+ def execute_approved(self, token: str) -> Any:
203
+ """Step 2a (after human approval): run execute for the previewed args.
204
+ The token is consumed first so an action can never double-fire."""
205
+ pending = self._gate.get(token)
206
+ spec = self._action_by_name(pending.action_name)
207
+ self._gate.mark_executed(token) # consume before running
208
+ with self._credential_scope():
209
+ return self.execute_action(spec, pending.arguments)
210
+
211
+ def reject_action(self, token: str) -> PendingAction:
212
+ """Step 2b (human rejection): discard the previewed action unexecuted."""
213
+ return self._gate.reject(token)
214
+
215
+ def fetch_resource(self, name: str, arguments: dict[str, Any]) -> list[CitedRecord]:
216
+ """Run a resource by name inside an active credential scope."""
217
+ spec = next((s for s in self.resources if s.name == name), None)
218
+ if spec is None:
219
+ raise KeyError(f"no such resource: {name!r}")
220
+ with self._credential_scope():
221
+ return self.run_resource(spec, arguments)
222
+
223
+ # -- introspection for the emitter ------------------------------------
224
+ @property
225
+ def resources(self) -> list[ResourceSpec]:
226
+ return type(self)._resources
227
+
228
+ @property
229
+ def actions(self) -> list[ActionSpec]:
230
+ return type(self)._actions
231
+
232
+ def run_resource(self, spec: ResourceSpec, arguments: dict[str, Any]) -> list[CitedRecord]:
233
+ """Invoke a resource's bound fetch method and normalise its output."""
234
+ bound = getattr(self, spec.attr_name)
235
+ result = bound(**arguments)
236
+ records = list(result) if result is not None else []
237
+ for r in records:
238
+ if not isinstance(r, CitedRecord):
239
+ raise ConnectorContractError(
240
+ f"resource {spec.name!r} returned a {type(r).__name__}, expected CitedRecord; "
241
+ f"use self.cite(...) to wrap each record."
242
+ )
243
+ return records
244
+
245
+ def preview_action(self, spec: ActionSpec, arguments: dict[str, Any]) -> str:
246
+ """Invoke an action's preview to describe what execute *would* do."""
247
+ assert spec.preview_fn is not None # enforced by _validate_contracts
248
+ return spec.preview_fn(self, **arguments)
249
+
250
+ def execute_action(self, spec: ActionSpec, arguments: dict[str, Any]) -> Any:
251
+ """Invoke an action's execute. Only ever called after approval (Phase 2)."""
252
+ assert spec.execute_fn is not None # enforced by _validate_contracts
253
+ return spec.execute_fn(self, **arguments)
254
+
255
+ # -- manifest ---------------------------------------------------------
256
+ @property
257
+ def effective_auth_method(self) -> AuthMethod:
258
+ return self.auth.method if self.auth is not None else self.auth_method
259
+
260
+ @property
261
+ def effective_auth_scopes(self) -> list[str]:
262
+ return list(self.auth.scopes) if self.auth is not None else list(self.auth_scopes)
263
+
264
+ def build_manifest(self) -> Manifest:
265
+ return Manifest(
266
+ name=self.name,
267
+ version=self.version,
268
+ description=self.description,
269
+ sdk_major_version=self.sdk_major_version,
270
+ auth_method=self.effective_auth_method,
271
+ auth_scopes=self.effective_auth_scopes,
272
+ resources=[
273
+ ResourceManifest(name=s.name, description=s.description) for s in self.resources
274
+ ],
275
+ actions=[
276
+ ActionManifest(name=s.name, description=s.description, has_preview=s.has_preview())
277
+ for s in self.actions
278
+ ],
279
+ deployment=DeploymentCompat(
280
+ requires_network=self.requires_network,
281
+ air_gapped_capable=self.air_gapped_capable,
282
+ ),
283
+ )