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.
- deepquery_sdk/__init__.py +101 -0
- deepquery_sdk/action.py +104 -0
- deepquery_sdk/auth/__init__.py +45 -0
- deepquery_sdk/auth/base.py +96 -0
- deepquery_sdk/auth/oauth2.py +106 -0
- deepquery_sdk/auth/strategies.py +91 -0
- deepquery_sdk/classification.py +43 -0
- deepquery_sdk/cli/__init__.py +5 -0
- deepquery_sdk/cli/__main__.py +64 -0
- deepquery_sdk/cli/commands.py +208 -0
- deepquery_sdk/cli/loader.py +95 -0
- deepquery_sdk/compat.py +79 -0
- deepquery_sdk/connector.py +283 -0
- deepquery_sdk/gate.py +83 -0
- deepquery_sdk/harness/__init__.py +5 -0
- deepquery_sdk/harness/mock_agent.py +119 -0
- deepquery_sdk/manifest.py +78 -0
- deepquery_sdk/mcp_emit/__init__.py +5 -0
- deepquery_sdk/mcp_emit/emitter.py +174 -0
- deepquery_sdk/provenance.py +65 -0
- deepquery_sdk/resource.py +65 -0
- deepquery_sdk/templates/connector/.gitignore.tmpl +9 -0
- deepquery_sdk/templates/connector/README.md.tmpl +22 -0
- deepquery_sdk/templates/connector/connector.py.tmpl +74 -0
- deepquery_sdk/templates/connector/pyproject.toml.tmpl +23 -0
- deepquery_sdk/templates/connector/tests/test_connector.py.tmpl +41 -0
- deepquery_sdk/validation.py +201 -0
- deepquery_sdk-1.0.0.dist-info/METADATA +195 -0
- deepquery_sdk-1.0.0.dist-info/RECORD +32 -0
- deepquery_sdk-1.0.0.dist-info/WHEEL +4 -0
- deepquery_sdk-1.0.0.dist-info/entry_points.txt +2 -0
- deepquery_sdk-1.0.0.dist-info/licenses/LICENSE +190 -0
|
@@ -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)()
|
deepquery_sdk/compat.py
ADDED
|
@@ -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
|
+
)
|