arbiter-server 0.9.1.dev1__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.
- arbiter_server/__init__.py +1 -0
- arbiter_server/__main__.py +5 -0
- arbiter_server/app.py +44 -0
- arbiter_server/artifacts.py +344 -0
- arbiter_server/cli_errors.py +31 -0
- arbiter_server/config.py +231 -0
- arbiter_server/deploy/docker/arbiter-docker +4477 -0
- arbiter_server/deploy/docker/compose.yaml +101 -0
- arbiter_server/file_protection/__init__.py +20 -0
- arbiter_server/file_protection/posix.py +70 -0
- arbiter_server/file_protection/windows.py +379 -0
- arbiter_server/main.py +2843 -0
- arbiter_server/plugins/__init__.py +36 -0
- arbiter_server/py.typed +1 -0
- arbiter_server/services.py +706 -0
- arbiter_server/storage.py +60 -0
- arbiter_server/version.py +135 -0
- arbiter_server-0.9.1.dev1.dist-info/METADATA +26 -0
- arbiter_server-0.9.1.dev1.dist-info/RECORD +22 -0
- arbiter_server-0.9.1.dev1.dist-info/WHEEL +5 -0
- arbiter_server-0.9.1.dev1.dist-info/entry_points.txt +2 -0
- arbiter_server-0.9.1.dev1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, ItemsView, KeysView, Mapping, Sequence
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, NoReturn, Protocol, TypeVar, cast
|
|
6
|
+
|
|
7
|
+
from .version import arbiter_server_version, compatibility_line, server_api_version
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
RuntimeT = TypeVar("RuntimeT")
|
|
11
|
+
SERVER_VERSION = arbiter_server_version()
|
|
12
|
+
SERVER_API_VERSION = server_api_version()
|
|
13
|
+
ACCOUNT_TEST_STATUSES = {"ok", "failed", "skipped"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ToolServer(Protocol):
|
|
17
|
+
def tool(
|
|
18
|
+
self,
|
|
19
|
+
name: str | None = None,
|
|
20
|
+
title: str | None = None,
|
|
21
|
+
description: str | None = None,
|
|
22
|
+
annotations: Any = None,
|
|
23
|
+
icons: Any = None,
|
|
24
|
+
meta: Any = None,
|
|
25
|
+
structured_output: bool | None = None,
|
|
26
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class RuntimeRegistry:
|
|
31
|
+
runtimes: Mapping[str, object]
|
|
32
|
+
|
|
33
|
+
def require_object(self, service_name: str) -> object:
|
|
34
|
+
runtime = self.runtimes.get(service_name)
|
|
35
|
+
if runtime is None:
|
|
36
|
+
raise RuntimeError(f"service runtime is not configured: {service_name}")
|
|
37
|
+
return runtime
|
|
38
|
+
|
|
39
|
+
def require(self, service_name: str, runtime_type: type[RuntimeT]) -> RuntimeT:
|
|
40
|
+
runtime = self.require_object(service_name)
|
|
41
|
+
if not isinstance(runtime, runtime_type):
|
|
42
|
+
raise RuntimeError(f"service runtime is not configured: {service_name}")
|
|
43
|
+
return runtime
|
|
44
|
+
|
|
45
|
+
def items(self) -> ItemsView[str, object]:
|
|
46
|
+
return self.runtimes.items()
|
|
47
|
+
|
|
48
|
+
def keys(self) -> KeysView[str]:
|
|
49
|
+
return self.runtimes.keys()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ServiceRuntimeContext:
|
|
54
|
+
dependencies: Mapping[str, object] = field(default_factory=dict)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class ServicePluginContext:
|
|
59
|
+
runtimes: RuntimeRegistry
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class CapabilityDescriptor:
|
|
64
|
+
name: str
|
|
65
|
+
description: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class OperationDescriptor:
|
|
70
|
+
name: str
|
|
71
|
+
description: str
|
|
72
|
+
input_schema: Mapping[str, object]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def operation_id(capability: str, operation: str) -> str:
|
|
76
|
+
return f"{capability}:{operation}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_operation_id(value: str) -> tuple[str, str]:
|
|
80
|
+
capability, separator, operation = value.partition(":")
|
|
81
|
+
if not capability or not separator or not operation:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
"operation id must use CAPABILITY:OPERATION syntax: " f"{value}"
|
|
84
|
+
)
|
|
85
|
+
return capability, operation
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ServicePlugin(Protocol):
|
|
89
|
+
name: str
|
|
90
|
+
version: str
|
|
91
|
+
server_api_version: str
|
|
92
|
+
|
|
93
|
+
# Called before Hydra composes application config. Plugins register all
|
|
94
|
+
# service-owned schema and example options in their ConfigStore groups here.
|
|
95
|
+
def register_configs(self, config_store: Any) -> None: ...
|
|
96
|
+
|
|
97
|
+
def bootstrap_config(self, *, kind: str, name: str) -> object | None: ...
|
|
98
|
+
|
|
99
|
+
def build_runtime(
|
|
100
|
+
self,
|
|
101
|
+
accounts: Mapping[str, object],
|
|
102
|
+
policies: Mapping[str, object],
|
|
103
|
+
context: ServiceRuntimeContext,
|
|
104
|
+
) -> object: ...
|
|
105
|
+
|
|
106
|
+
def describe_capability(
|
|
107
|
+
self,
|
|
108
|
+
context: ServicePluginContext,
|
|
109
|
+
) -> CapabilityDescriptor: ...
|
|
110
|
+
|
|
111
|
+
def describe_operations(
|
|
112
|
+
self,
|
|
113
|
+
context: ServicePluginContext,
|
|
114
|
+
) -> Sequence[OperationDescriptor]: ...
|
|
115
|
+
|
|
116
|
+
def invoke_operation(
|
|
117
|
+
self,
|
|
118
|
+
operation: str,
|
|
119
|
+
arguments: Mapping[str, Any],
|
|
120
|
+
context: ServicePluginContext,
|
|
121
|
+
) -> object: ...
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class ServicePluginRuntimeInfo:
|
|
126
|
+
name: str
|
|
127
|
+
version: str
|
|
128
|
+
server_api_version: str
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def service_plugin_runtime_info(
|
|
132
|
+
service_plugin: ServicePlugin,
|
|
133
|
+
) -> ServicePluginRuntimeInfo:
|
|
134
|
+
try:
|
|
135
|
+
plugin_version = service_plugin.version
|
|
136
|
+
except AttributeError as exc:
|
|
137
|
+
raise RuntimeError(
|
|
138
|
+
f"service plugin {service_plugin.name} does not declare a version"
|
|
139
|
+
) from exc
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
plugin_server_api_version = service_plugin.server_api_version
|
|
143
|
+
except AttributeError as exc:
|
|
144
|
+
raise RuntimeError(
|
|
145
|
+
f"service plugin {service_plugin.name} does not declare "
|
|
146
|
+
"an Arbiter server API version"
|
|
147
|
+
) from exc
|
|
148
|
+
|
|
149
|
+
return ServicePluginRuntimeInfo(
|
|
150
|
+
name=service_plugin.name,
|
|
151
|
+
version=plugin_version,
|
|
152
|
+
server_api_version=plugin_server_api_version,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def validate_service_plugin_compatibility(
|
|
157
|
+
service_plugin: ServicePlugin,
|
|
158
|
+
) -> None:
|
|
159
|
+
info = service_plugin_runtime_info(service_plugin)
|
|
160
|
+
if info.server_api_version != SERVER_API_VERSION:
|
|
161
|
+
raise RuntimeError(
|
|
162
|
+
f"service plugin {info.name} targets Arbiter server API "
|
|
163
|
+
f"{info.server_api_version}, but loaded server API is {SERVER_API_VERSION}"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
plugin_line = compatibility_line(info.version)
|
|
167
|
+
if plugin_line != SERVER_API_VERSION:
|
|
168
|
+
raise RuntimeError(
|
|
169
|
+
f"service plugin {info.name} version {info.version} is not on "
|
|
170
|
+
f"loaded server API line {SERVER_API_VERSION}"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def validate_service_plugins(
|
|
175
|
+
service_plugins: Sequence[ServicePlugin],
|
|
176
|
+
) -> None:
|
|
177
|
+
for service_plugin in service_plugins:
|
|
178
|
+
validate_service_plugin_compatibility(service_plugin)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class OperationCatalog:
|
|
182
|
+
def __init__(
|
|
183
|
+
self,
|
|
184
|
+
service_plugins: Sequence[ServicePlugin],
|
|
185
|
+
context: ServicePluginContext,
|
|
186
|
+
*,
|
|
187
|
+
max_account_preview_limit: int,
|
|
188
|
+
max_operation_preview_limit: int,
|
|
189
|
+
) -> None:
|
|
190
|
+
if max_account_preview_limit < 1:
|
|
191
|
+
raise ValueError("max_account_preview_limit must be >= 1")
|
|
192
|
+
if max_operation_preview_limit < 1:
|
|
193
|
+
raise ValueError("max_operation_preview_limit must be >= 1")
|
|
194
|
+
self._context = context
|
|
195
|
+
self._capabilities: dict[str, CapabilityDescriptor] = {}
|
|
196
|
+
self._operations: dict[str, dict[str, OperationDescriptor]] = {}
|
|
197
|
+
self._plugins: dict[str, ServicePlugin] = {}
|
|
198
|
+
self._max_account_preview_limit = max_account_preview_limit
|
|
199
|
+
self._max_operation_preview_limit = max_operation_preview_limit
|
|
200
|
+
|
|
201
|
+
for plugin in service_plugins:
|
|
202
|
+
capability = plugin.describe_capability(context)
|
|
203
|
+
if capability.name in self._capabilities:
|
|
204
|
+
raise RuntimeError(f"duplicate capability: {capability.name}")
|
|
205
|
+
operations: dict[str, OperationDescriptor] = {}
|
|
206
|
+
for operation in plugin.describe_operations(context):
|
|
207
|
+
if operation.name in operations:
|
|
208
|
+
raise RuntimeError(
|
|
209
|
+
f"duplicate operation for {capability.name}: {operation.name}"
|
|
210
|
+
)
|
|
211
|
+
operations[operation.name] = operation
|
|
212
|
+
self._capabilities[capability.name] = capability
|
|
213
|
+
self._operations[capability.name] = operations
|
|
214
|
+
self._plugins[capability.name] = plugin
|
|
215
|
+
|
|
216
|
+
def list_capabilities(self) -> dict[str, object]:
|
|
217
|
+
return {"capabilities": sorted(self._capabilities)}
|
|
218
|
+
|
|
219
|
+
def describe_capabilities(
|
|
220
|
+
self,
|
|
221
|
+
*,
|
|
222
|
+
operation_preview_limit: int = 8,
|
|
223
|
+
account_preview_limit: int = 8,
|
|
224
|
+
) -> dict[str, object]:
|
|
225
|
+
if operation_preview_limit < 0:
|
|
226
|
+
raise ValueError("operation_preview_limit must be >= 0")
|
|
227
|
+
if account_preview_limit < 0:
|
|
228
|
+
raise ValueError("account_preview_limit must be >= 0")
|
|
229
|
+
effective_operation_preview_limit = min(
|
|
230
|
+
operation_preview_limit,
|
|
231
|
+
self._max_operation_preview_limit,
|
|
232
|
+
)
|
|
233
|
+
effective_account_preview_limit = min(
|
|
234
|
+
account_preview_limit,
|
|
235
|
+
self._max_account_preview_limit,
|
|
236
|
+
)
|
|
237
|
+
return {
|
|
238
|
+
"capabilities": [
|
|
239
|
+
self._capability_summary(
|
|
240
|
+
capability,
|
|
241
|
+
operation_preview_limit=effective_operation_preview_limit,
|
|
242
|
+
account_preview_limit=effective_account_preview_limit,
|
|
243
|
+
)
|
|
244
|
+
for capability in sorted(self._capabilities)
|
|
245
|
+
]
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
def describe_capability(self, capability: str) -> dict[str, object]:
|
|
249
|
+
descriptor = self._require_capability(capability)
|
|
250
|
+
return {
|
|
251
|
+
"id": descriptor.name,
|
|
252
|
+
"description": descriptor.description,
|
|
253
|
+
"accounts": self._account_summaries(capability),
|
|
254
|
+
"operations": [
|
|
255
|
+
self._operation_summary(capability, operation)
|
|
256
|
+
for operation in sorted(self._operations[capability])
|
|
257
|
+
],
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
def describe_operation(self, operation_ref: str) -> dict[str, object]:
|
|
261
|
+
capability, operation = parse_operation_id(operation_ref)
|
|
262
|
+
descriptor = self._require_operation(capability, operation)
|
|
263
|
+
return {
|
|
264
|
+
"id": operation_id(capability, operation),
|
|
265
|
+
"capability": capability,
|
|
266
|
+
"name": descriptor.name,
|
|
267
|
+
"description": descriptor.description,
|
|
268
|
+
"input_schema": dict(descriptor.input_schema),
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
def info(
|
|
272
|
+
self,
|
|
273
|
+
*,
|
|
274
|
+
kind: str = "overview",
|
|
275
|
+
plugin: str | None = None,
|
|
276
|
+
account: str | None = None,
|
|
277
|
+
operation: str | None = None,
|
|
278
|
+
version_info: Mapping[str, object] | None = None,
|
|
279
|
+
) -> dict[str, object]:
|
|
280
|
+
if kind == "overview":
|
|
281
|
+
return self._info_overview(version_info or {})
|
|
282
|
+
if kind == "plugins":
|
|
283
|
+
return {
|
|
284
|
+
"kind": "plugins",
|
|
285
|
+
"plugins": [
|
|
286
|
+
self._info_plugin_summary(capability, include_accounts=False)
|
|
287
|
+
for capability in sorted(self._capabilities)
|
|
288
|
+
],
|
|
289
|
+
}
|
|
290
|
+
if kind == "plugin":
|
|
291
|
+
if plugin is None:
|
|
292
|
+
raise ValueError("info plugin requires plugin")
|
|
293
|
+
return self._info_plugin(plugin)
|
|
294
|
+
if kind == "accounts":
|
|
295
|
+
if plugin is None:
|
|
296
|
+
raise ValueError("info accounts requires plugin")
|
|
297
|
+
return {
|
|
298
|
+
"kind": "accounts",
|
|
299
|
+
"plugin": plugin,
|
|
300
|
+
"accounts": self._info_account_summaries(plugin),
|
|
301
|
+
}
|
|
302
|
+
if kind == "account":
|
|
303
|
+
if plugin is None or account is None:
|
|
304
|
+
raise ValueError("info account requires plugin and account")
|
|
305
|
+
return self._info_account(plugin, account)
|
|
306
|
+
if kind == "tests":
|
|
307
|
+
return {
|
|
308
|
+
"kind": "tests",
|
|
309
|
+
"plugins": [
|
|
310
|
+
self._info_plugin_tests(capability)
|
|
311
|
+
for capability in sorted(self._capabilities)
|
|
312
|
+
],
|
|
313
|
+
}
|
|
314
|
+
if kind == "test":
|
|
315
|
+
if plugin is None:
|
|
316
|
+
raise ValueError("info test requires plugin")
|
|
317
|
+
if account is not None:
|
|
318
|
+
return self._info_account_test(plugin, account)
|
|
319
|
+
plugin_tests = self._info_plugin_tests(plugin)
|
|
320
|
+
plugin_tests["kind"] = "test"
|
|
321
|
+
return plugin_tests
|
|
322
|
+
if kind == "ops":
|
|
323
|
+
if plugin is None:
|
|
324
|
+
raise ValueError("info ops requires plugin")
|
|
325
|
+
self._require_capability(plugin)
|
|
326
|
+
return {
|
|
327
|
+
"kind": "ops",
|
|
328
|
+
"plugin": plugin,
|
|
329
|
+
"operations": [
|
|
330
|
+
self._operation_summary(plugin, operation_name)
|
|
331
|
+
for operation_name in sorted(self._operations[plugin])
|
|
332
|
+
],
|
|
333
|
+
}
|
|
334
|
+
if kind == "op":
|
|
335
|
+
if plugin is None or operation is None:
|
|
336
|
+
raise ValueError("info op requires plugin and operation")
|
|
337
|
+
operation_info = self.describe_operation(operation_id(plugin, operation))
|
|
338
|
+
operation_info["kind"] = "op"
|
|
339
|
+
return operation_info
|
|
340
|
+
supported = "account, accounts, op, ops, overview, plugin, plugins, test, tests"
|
|
341
|
+
raise ValueError(f"unknown info kind: {kind}; supported kinds: {supported}")
|
|
342
|
+
|
|
343
|
+
def invoke_operation(
|
|
344
|
+
self,
|
|
345
|
+
operation_ref: str,
|
|
346
|
+
arguments: Mapping[str, Any] | None = None,
|
|
347
|
+
) -> object:
|
|
348
|
+
capability, operation = parse_operation_id(operation_ref)
|
|
349
|
+
descriptor = self._require_operation(capability, operation)
|
|
350
|
+
operation_arguments = dict(arguments or {})
|
|
351
|
+
_validate_operation_arguments(
|
|
352
|
+
operation_id(capability, operation),
|
|
353
|
+
descriptor.input_schema,
|
|
354
|
+
operation_arguments,
|
|
355
|
+
)
|
|
356
|
+
return self._plugins[capability].invoke_operation(
|
|
357
|
+
operation,
|
|
358
|
+
operation_arguments,
|
|
359
|
+
self._context,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def _require_capability(self, capability: str) -> CapabilityDescriptor:
|
|
363
|
+
descriptor = self._capabilities.get(capability)
|
|
364
|
+
if descriptor is None:
|
|
365
|
+
raise ValueError(f"unknown capability: {capability}")
|
|
366
|
+
return descriptor
|
|
367
|
+
|
|
368
|
+
def _require_operation(
|
|
369
|
+
self,
|
|
370
|
+
capability: str,
|
|
371
|
+
operation: str,
|
|
372
|
+
) -> OperationDescriptor:
|
|
373
|
+
self._require_capability(capability)
|
|
374
|
+
descriptor = self._operations[capability].get(operation)
|
|
375
|
+
if descriptor is None:
|
|
376
|
+
raise ValueError(
|
|
377
|
+
f"unknown operation: {operation_id(capability, operation)}"
|
|
378
|
+
)
|
|
379
|
+
return descriptor
|
|
380
|
+
|
|
381
|
+
def _info_overview(
|
|
382
|
+
self,
|
|
383
|
+
version_info: Mapping[str, object],
|
|
384
|
+
) -> dict[str, object]:
|
|
385
|
+
overview: dict[str, object] = {
|
|
386
|
+
"kind": "overview",
|
|
387
|
+
"deployment_scope": version_info.get("deployment_scope", "unknown"),
|
|
388
|
+
"plugins": [
|
|
389
|
+
self._info_plugin_summary(capability, include_accounts=True)
|
|
390
|
+
for capability in sorted(self._capabilities)
|
|
391
|
+
],
|
|
392
|
+
}
|
|
393
|
+
server = version_info.get("server")
|
|
394
|
+
if server is not None:
|
|
395
|
+
overview["server"] = server
|
|
396
|
+
source = version_info.get("source")
|
|
397
|
+
if source is not None:
|
|
398
|
+
overview["source"] = source
|
|
399
|
+
return overview
|
|
400
|
+
|
|
401
|
+
def _info_plugin_summary(
|
|
402
|
+
self,
|
|
403
|
+
capability: str,
|
|
404
|
+
*,
|
|
405
|
+
include_accounts: bool,
|
|
406
|
+
) -> dict[str, object]:
|
|
407
|
+
descriptor = self._capabilities[capability]
|
|
408
|
+
account_summaries = self._account_summaries(capability)
|
|
409
|
+
operation_names = sorted(self._operations[capability])
|
|
410
|
+
summary: dict[str, object] = {
|
|
411
|
+
"id": descriptor.name,
|
|
412
|
+
"description": descriptor.description,
|
|
413
|
+
"version": self._plugins[capability].version,
|
|
414
|
+
"account_count": len(account_summaries),
|
|
415
|
+
"operation_count": len(operation_names),
|
|
416
|
+
}
|
|
417
|
+
if include_accounts:
|
|
418
|
+
summary["accounts"] = self._info_account_summaries(capability)
|
|
419
|
+
return summary
|
|
420
|
+
|
|
421
|
+
def _info_plugin(self, capability: str) -> dict[str, object]:
|
|
422
|
+
self._require_capability(capability)
|
|
423
|
+
summary = self._info_plugin_summary(capability, include_accounts=True)
|
|
424
|
+
summary["kind"] = "plugin"
|
|
425
|
+
summary["operations"] = [
|
|
426
|
+
self._operation_summary(capability, operation)
|
|
427
|
+
for operation in sorted(self._operations[capability])
|
|
428
|
+
]
|
|
429
|
+
return summary
|
|
430
|
+
|
|
431
|
+
def _info_account_summaries(self, capability: str) -> list[dict[str, object]]:
|
|
432
|
+
self._require_capability(capability)
|
|
433
|
+
summaries = self._account_summaries(capability)
|
|
434
|
+
return [
|
|
435
|
+
self._info_account_summary(capability, account_name, account)
|
|
436
|
+
for account_name, account in sorted(summaries.items())
|
|
437
|
+
if isinstance(account_name, str)
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
def _info_account_summary(
|
|
441
|
+
self,
|
|
442
|
+
capability: str,
|
|
443
|
+
account_name: str,
|
|
444
|
+
account: object,
|
|
445
|
+
) -> dict[str, object]:
|
|
446
|
+
description = ""
|
|
447
|
+
guidance = ""
|
|
448
|
+
if isinstance(account, Mapping):
|
|
449
|
+
raw_description = account.get("description")
|
|
450
|
+
if isinstance(raw_description, str):
|
|
451
|
+
description = raw_description
|
|
452
|
+
raw_guidance = account.get("guidance")
|
|
453
|
+
if isinstance(raw_guidance, str):
|
|
454
|
+
guidance = raw_guidance
|
|
455
|
+
return {
|
|
456
|
+
"plugin": capability,
|
|
457
|
+
"name": account_name,
|
|
458
|
+
"description": description,
|
|
459
|
+
"guidance": guidance,
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
def _info_account(self, capability: str, account_name: str) -> dict[str, object]:
|
|
463
|
+
self._require_capability(capability)
|
|
464
|
+
accounts = self._account_summaries(capability)
|
|
465
|
+
account = accounts.get(account_name)
|
|
466
|
+
if account is None:
|
|
467
|
+
raise ValueError(f"unknown account for {capability}: {account_name}")
|
|
468
|
+
details: dict[str, object] = {
|
|
469
|
+
"kind": "account",
|
|
470
|
+
"plugin": capability,
|
|
471
|
+
"account": account_name,
|
|
472
|
+
"guidance": "",
|
|
473
|
+
}
|
|
474
|
+
if isinstance(account, Mapping):
|
|
475
|
+
details.update(account)
|
|
476
|
+
if "guidance" not in details:
|
|
477
|
+
details["guidance"] = ""
|
|
478
|
+
else:
|
|
479
|
+
details["details"] = account
|
|
480
|
+
return details
|
|
481
|
+
|
|
482
|
+
def _info_plugin_tests(self, capability: str) -> dict[str, object]:
|
|
483
|
+
self._require_capability(capability)
|
|
484
|
+
account_summaries = self._account_summaries(capability)
|
|
485
|
+
account_tests = self._account_tests(capability)
|
|
486
|
+
account_names_set: set[str] = set()
|
|
487
|
+
for account_name in account_summaries:
|
|
488
|
+
if isinstance(account_name, str):
|
|
489
|
+
account_names_set.add(account_name)
|
|
490
|
+
for account_name in account_tests:
|
|
491
|
+
if isinstance(account_name, str):
|
|
492
|
+
account_names_set.add(account_name)
|
|
493
|
+
account_names = sorted(account_names_set)
|
|
494
|
+
return {
|
|
495
|
+
"plugin": capability,
|
|
496
|
+
"accounts": [
|
|
497
|
+
self._info_account_test_summary(
|
|
498
|
+
capability,
|
|
499
|
+
account_name,
|
|
500
|
+
account_tests.get(
|
|
501
|
+
account_name,
|
|
502
|
+
{
|
|
503
|
+
"status": "skipped",
|
|
504
|
+
"reason": "account test did not return a result",
|
|
505
|
+
},
|
|
506
|
+
),
|
|
507
|
+
)
|
|
508
|
+
for account_name in account_names
|
|
509
|
+
],
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
def _info_account_test(
|
|
513
|
+
self,
|
|
514
|
+
capability: str,
|
|
515
|
+
account_name: str,
|
|
516
|
+
) -> dict[str, object]:
|
|
517
|
+
self._require_capability(capability)
|
|
518
|
+
account_summaries = self._account_summaries(capability)
|
|
519
|
+
if account_name not in account_summaries:
|
|
520
|
+
raise ValueError(f"unknown account for {capability}: {account_name}")
|
|
521
|
+
account_tests = self._account_tests(capability)
|
|
522
|
+
return {
|
|
523
|
+
"kind": "test",
|
|
524
|
+
**self._info_account_test_summary(
|
|
525
|
+
capability,
|
|
526
|
+
account_name,
|
|
527
|
+
account_tests.get(
|
|
528
|
+
account_name,
|
|
529
|
+
{
|
|
530
|
+
"status": "skipped",
|
|
531
|
+
"reason": "account test did not return a result",
|
|
532
|
+
},
|
|
533
|
+
),
|
|
534
|
+
),
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
def _info_account_test_summary(
|
|
538
|
+
self,
|
|
539
|
+
capability: str,
|
|
540
|
+
account_name: str,
|
|
541
|
+
account_test: object,
|
|
542
|
+
) -> dict[str, object]:
|
|
543
|
+
summary: dict[str, object] = {
|
|
544
|
+
"plugin": capability,
|
|
545
|
+
"account": account_name,
|
|
546
|
+
}
|
|
547
|
+
if isinstance(account_test, Mapping):
|
|
548
|
+
summary.update(account_test)
|
|
549
|
+
else:
|
|
550
|
+
summary["status"] = "ok"
|
|
551
|
+
summary["details"] = account_test
|
|
552
|
+
status = summary.get("status", "ok")
|
|
553
|
+
if not isinstance(status, str) or status not in ACCOUNT_TEST_STATUSES:
|
|
554
|
+
raise RuntimeError(
|
|
555
|
+
"account test status must be one of "
|
|
556
|
+
f"{', '.join(sorted(ACCOUNT_TEST_STATUSES))}: "
|
|
557
|
+
f"{capability}:{account_name}"
|
|
558
|
+
)
|
|
559
|
+
summary["status"] = status
|
|
560
|
+
return summary
|
|
561
|
+
|
|
562
|
+
def _capability_summary(
|
|
563
|
+
self,
|
|
564
|
+
capability: str,
|
|
565
|
+
*,
|
|
566
|
+
operation_preview_limit: int,
|
|
567
|
+
account_preview_limit: int,
|
|
568
|
+
) -> dict[str, object]:
|
|
569
|
+
descriptor = self._capabilities[capability]
|
|
570
|
+
account_names = sorted(self._account_summaries(capability))
|
|
571
|
+
operation_names = sorted(self._operations[capability])
|
|
572
|
+
return {
|
|
573
|
+
"id": descriptor.name,
|
|
574
|
+
"description": descriptor.description,
|
|
575
|
+
"version": self._plugins[capability].version,
|
|
576
|
+
"account_count": len(account_names),
|
|
577
|
+
"accounts": account_names[:account_preview_limit],
|
|
578
|
+
"accounts_truncated": len(account_names) > account_preview_limit,
|
|
579
|
+
"operation_count": len(operation_names),
|
|
580
|
+
"operations": operation_names[:operation_preview_limit],
|
|
581
|
+
"operations_truncated": len(operation_names) > operation_preview_limit,
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
def _operation_summary(
|
|
585
|
+
self,
|
|
586
|
+
capability: str,
|
|
587
|
+
operation: str,
|
|
588
|
+
) -> dict[str, object]:
|
|
589
|
+
descriptor = self._operations[capability][operation]
|
|
590
|
+
return {
|
|
591
|
+
"id": operation_id(capability, operation),
|
|
592
|
+
"name": descriptor.name,
|
|
593
|
+
"description": descriptor.description,
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
def _account_summaries(self, capability: str) -> Mapping[str, object]:
|
|
597
|
+
runtime = self._context.runtimes.require_object(capability)
|
|
598
|
+
account_summaries = getattr(runtime, "account_summaries", None)
|
|
599
|
+
if not callable(account_summaries):
|
|
600
|
+
return {}
|
|
601
|
+
result = account_summaries()
|
|
602
|
+
if not isinstance(result, Mapping):
|
|
603
|
+
raise RuntimeError(
|
|
604
|
+
f"capability account summaries must be a mapping: {capability}"
|
|
605
|
+
)
|
|
606
|
+
return result
|
|
607
|
+
|
|
608
|
+
def _account_tests(self, capability: str) -> Mapping[str, object]:
|
|
609
|
+
runtime = self._context.runtimes.require_object(capability)
|
|
610
|
+
test_accounts = getattr(runtime, "test_accounts", None)
|
|
611
|
+
if not callable(test_accounts):
|
|
612
|
+
return {
|
|
613
|
+
account_name: {
|
|
614
|
+
"status": "skipped",
|
|
615
|
+
"reason": "runtime does not implement account tests",
|
|
616
|
+
}
|
|
617
|
+
for account_name in self._account_summaries(capability)
|
|
618
|
+
}
|
|
619
|
+
result = test_accounts()
|
|
620
|
+
if not isinstance(result, Mapping):
|
|
621
|
+
raise RuntimeError(f"account tests must be a mapping: {capability}")
|
|
622
|
+
return result
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _validate_operation_arguments(
|
|
626
|
+
operation_ref: str,
|
|
627
|
+
schema: Mapping[str, object],
|
|
628
|
+
arguments: Mapping[str, Any],
|
|
629
|
+
) -> None:
|
|
630
|
+
required = schema.get("required", [])
|
|
631
|
+
if isinstance(required, Sequence) and not isinstance(required, str):
|
|
632
|
+
missing = [
|
|
633
|
+
key for key in required if isinstance(key, str) and key not in arguments
|
|
634
|
+
]
|
|
635
|
+
if missing:
|
|
636
|
+
raise ValueError(
|
|
637
|
+
f"{operation_ref} missing required argument(s): {', '.join(missing)}"
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
properties = schema.get("properties", {})
|
|
641
|
+
if not isinstance(properties, Mapping):
|
|
642
|
+
return
|
|
643
|
+
|
|
644
|
+
if schema.get("additionalProperties") is False:
|
|
645
|
+
unknown = sorted(str(key) for key in arguments if key not in properties)
|
|
646
|
+
if unknown:
|
|
647
|
+
raise ValueError(
|
|
648
|
+
f"{operation_ref} received unknown argument(s): {', '.join(unknown)}"
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
for name, value in arguments.items():
|
|
652
|
+
property_schema = properties.get(name)
|
|
653
|
+
if not isinstance(property_schema, Mapping):
|
|
654
|
+
continue
|
|
655
|
+
_validate_argument_value(operation_ref, str(name), property_schema, value)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _validate_argument_value(
|
|
659
|
+
operation_ref: str,
|
|
660
|
+
name: str,
|
|
661
|
+
schema: Mapping[str, object],
|
|
662
|
+
value: object,
|
|
663
|
+
) -> None:
|
|
664
|
+
expected_type = schema.get("type")
|
|
665
|
+
if expected_type == "string":
|
|
666
|
+
if not isinstance(value, str):
|
|
667
|
+
_raise_argument_type_error(operation_ref, name, "string")
|
|
668
|
+
return
|
|
669
|
+
if expected_type == "integer":
|
|
670
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
671
|
+
_raise_argument_type_error(operation_ref, name, "integer")
|
|
672
|
+
integer_value = cast(int, value)
|
|
673
|
+
minimum = schema.get("minimum")
|
|
674
|
+
maximum = schema.get("maximum")
|
|
675
|
+
if isinstance(minimum, int) and integer_value < minimum:
|
|
676
|
+
raise ValueError(f"{operation_ref} argument {name} must be >= {minimum}")
|
|
677
|
+
if isinstance(maximum, int) and integer_value > maximum:
|
|
678
|
+
raise ValueError(f"{operation_ref} argument {name} must be <= {maximum}")
|
|
679
|
+
return
|
|
680
|
+
if expected_type == "boolean":
|
|
681
|
+
if not isinstance(value, bool):
|
|
682
|
+
_raise_argument_type_error(operation_ref, name, "boolean")
|
|
683
|
+
return
|
|
684
|
+
if expected_type == "array":
|
|
685
|
+
if not isinstance(value, list):
|
|
686
|
+
_raise_argument_type_error(operation_ref, name, "array")
|
|
687
|
+
list_value = cast(list[object], value)
|
|
688
|
+
items = schema.get("items")
|
|
689
|
+
if isinstance(items, Mapping) and items.get("type") == "string":
|
|
690
|
+
for index, item in enumerate(list_value):
|
|
691
|
+
if not isinstance(item, str):
|
|
692
|
+
raise ValueError(
|
|
693
|
+
f"{operation_ref} argument {name}[{index}] must be string"
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _raise_argument_type_error(
|
|
698
|
+
operation_ref: str,
|
|
699
|
+
name: str,
|
|
700
|
+
expected_type: str,
|
|
701
|
+
) -> NoReturn:
|
|
702
|
+
raise ValueError(f"{operation_ref} argument {name} must be {expected_type}")
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
ServicePluginFactory = Callable[[], ServicePlugin]
|
|
706
|
+
SERVICE_PLUGIN_ENTRY_POINT_GROUP = "arbiter.services"
|