adp-hypervisor 0.1.0.dev0__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.
Files changed (53) hide show
  1. adp_hypervisor/__init__.py +28 -0
  2. adp_hypervisor/__main__.py +289 -0
  3. adp_hypervisor/handlers/__init__.py +35 -0
  4. adp_hypervisor/handlers/base.py +45 -0
  5. adp_hypervisor/handlers/describe.py +286 -0
  6. adp_hypervisor/handlers/discover.py +228 -0
  7. adp_hypervisor/handlers/execute.py +211 -0
  8. adp_hypervisor/handlers/initialize.py +123 -0
  9. adp_hypervisor/handlers/ping.py +53 -0
  10. adp_hypervisor/handlers/validate.py +387 -0
  11. adp_hypervisor/manifest/__init__.py +84 -0
  12. adp_hypervisor/manifest/index.py +446 -0
  13. adp_hypervisor/manifest/physical.py +177 -0
  14. adp_hypervisor/manifest/policy.py +210 -0
  15. adp_hypervisor/manifest/provider.py +56 -0
  16. adp_hypervisor/manifest/semantic.py +92 -0
  17. adp_hypervisor/manifest/yaml_provider.py +108 -0
  18. adp_hypervisor/policy/__init__.py +33 -0
  19. adp_hypervisor/policy/authenticator.py +48 -0
  20. adp_hypervisor/policy/basic_authenticator.py +114 -0
  21. adp_hypervisor/policy/enforcer.py +232 -0
  22. adp_hypervisor/policy/role_resolver.py +61 -0
  23. adp_hypervisor/policy/yaml_role_resolver.py +64 -0
  24. adp_hypervisor/protocol/__init__.py +221 -0
  25. adp_hypervisor/protocol/dispatcher.py +487 -0
  26. adp_hypervisor/protocol/errors.py +206 -0
  27. adp_hypervisor/protocol/jsonrpc.py +105 -0
  28. adp_hypervisor/protocol/types.py +840 -0
  29. adp_hypervisor/server.py +278 -0
  30. adp_hypervisor/transport/__init__.py +23 -0
  31. adp_hypervisor/transport/base.py +54 -0
  32. adp_hypervisor/transport/stdio.py +152 -0
  33. adp_hypervisor-0.1.0.dev0.dist-info/METADATA +236 -0
  34. adp_hypervisor-0.1.0.dev0.dist-info/RECORD +53 -0
  35. adp_hypervisor-0.1.0.dev0.dist-info/WHEEL +4 -0
  36. adp_hypervisor-0.1.0.dev0.dist-info/licenses/LICENSE +217 -0
  37. adp_hypervisor-0.1.0.dev0.dist-info/licenses/NOTICE +2 -0
  38. backends/__init__.py +43 -0
  39. backends/base.py +91 -0
  40. backends/blob_storage/__init__.py +74 -0
  41. backends/blob_storage/backend.py +35 -0
  42. backends/blob_storage/local.py +777 -0
  43. backends/credentials.py +82 -0
  44. backends/nosql/__init__.py +25 -0
  45. backends/nosql/backend.py +35 -0
  46. backends/nosql/mongodb.py +519 -0
  47. backends/rdbms/__init__.py +25 -0
  48. backends/rdbms/backend.py +268 -0
  49. backends/rdbms/postgres.py +133 -0
  50. backends/registry.py +111 -0
  51. backends/vector/__init__.py +25 -0
  52. backends/vector/backend.py +112 -0
  53. backends/vector/pgvector.py +284 -0
@@ -0,0 +1,28 @@
1
+ # Copyright 2026 Datastrato, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """ADP Hypervisor - Agentic Data Protocol Python Implementation."""
16
+
17
+ __all__ = ["ADPServer"]
18
+
19
+ __version__ = "0.1.0"
20
+
21
+
22
+ def __getattr__(name: str) -> object:
23
+ """Lazy import to avoid circular dependency with backends."""
24
+ if name == "ADPServer":
25
+ from adp_hypervisor.server import ADPServer
26
+
27
+ return ADPServer
28
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,289 @@
1
+ # Copyright 2026 Datastrato, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ ADP Hypervisor CLI entry point.
17
+
18
+ Starts the ADP server with the given configuration directory,
19
+ log level, and transport type.
20
+
21
+ Usage::
22
+
23
+ python -m adp_hypervisor --config /path/to/manifests
24
+ python -m adp_hypervisor --config /path/to/manifests --log-level DEBUG
25
+ python -m adp_hypervisor --config /path/to/manifests --transport stdio
26
+ """
27
+
28
+ import argparse
29
+ import asyncio
30
+ import logging
31
+ import logging.config
32
+ import logging.handlers
33
+ import sys
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ import yaml
38
+
39
+ from adp_hypervisor.manifest.yaml_provider import YamlManifestProvider
40
+ from adp_hypervisor.policy import UserRoleConfig, YamlRoleResolver
41
+ from adp_hypervisor.server import ADPServer
42
+ from adp_hypervisor.transport.stdio import StdioTransport
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ _VALID_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
47
+ _VALID_TRANSPORTS = ("stdio", "http")
48
+
49
+ # Built-in default logging config, matches conf/logging_conf.yaml.template.
50
+ # Used when no logging_conf.yaml is present in the config directory.
51
+ _DEFAULT_LOGGING_CONFIG: dict[str, Any] = {
52
+ "version": 1,
53
+ "disable_existing_loggers": False,
54
+ "formatters": {
55
+ "standard": {
56
+ "format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
57
+ "datefmt": "%Y-%m-%dT%H:%M:%S",
58
+ }
59
+ },
60
+ "handlers": {
61
+ "console": {
62
+ "class": "logging.StreamHandler",
63
+ "formatter": "standard",
64
+ "stream": "ext://sys.stderr",
65
+ },
66
+ "file_handler": {
67
+ "class": "logging.handlers.RotatingFileHandler",
68
+ "formatter": "standard",
69
+ "filename": "./hypervisor-logs/hypervisor.log",
70
+ "maxBytes": 10485760,
71
+ "backupCount": 5,
72
+ "encoding": "utf-8",
73
+ },
74
+ },
75
+ "root": {
76
+ "level": "INFO",
77
+ "handlers": ["console", "file_handler"],
78
+ },
79
+ }
80
+
81
+
82
+ def _build_parser() -> argparse.ArgumentParser:
83
+ """Build the CLI argument parser."""
84
+ parser = argparse.ArgumentParser(
85
+ prog="adp_hypervisor",
86
+ description="ADP Hypervisor - Agentic Data Protocol server",
87
+ )
88
+ parser.add_argument(
89
+ "--config",
90
+ required=True,
91
+ help="Path to the manifest directory containing physical.yaml, "
92
+ "semantic.yaml, and policy.yaml",
93
+ )
94
+ parser.add_argument(
95
+ "--log-level",
96
+ default=None,
97
+ choices=_VALID_LOG_LEVELS,
98
+ help="Override the root logging level (default: level from logging_conf.yaml or INFO)",
99
+ )
100
+ parser.add_argument(
101
+ "--transport",
102
+ default="stdio",
103
+ choices=_VALID_TRANSPORTS,
104
+ help="Transport type (default: stdio)",
105
+ )
106
+ return parser
107
+
108
+
109
+ def _load_logging_config(config_dir: Path) -> dict[str, Any]:
110
+ """Load logging configuration from config_dir/logging_conf.yaml.
111
+
112
+ Falls back to the built-in default when the file is absent.
113
+
114
+ Args:
115
+ config_dir: The runtime config directory.
116
+
117
+ Returns:
118
+ A dict suitable for logging.config.dictConfig.
119
+ """
120
+ logging_conf_path = config_dir / "logging_conf.yaml"
121
+ if logging_conf_path.exists():
122
+ try:
123
+ raw = yaml.safe_load(logging_conf_path.read_text(encoding="utf-8"))
124
+ if isinstance(raw, dict):
125
+ return raw
126
+ print(
127
+ f"Warning: {logging_conf_path} is not a YAML mapping (got {type(raw).__name__}), "
128
+ "using built-in logging defaults.",
129
+ file=sys.stderr,
130
+ )
131
+ except (yaml.YAMLError, OSError):
132
+ print(
133
+ f"Warning: failed to parse {logging_conf_path}, using built-in logging defaults.",
134
+ file=sys.stderr,
135
+ )
136
+ return _DEFAULT_LOGGING_CONFIG
137
+
138
+
139
+ def _ensure_log_directories(config: dict[str, Any]) -> None:
140
+ """Create parent directories for all file-based log handlers.
141
+
142
+ Scans every handler in the config dict, resolves the parent directory
143
+ for any handler that has a ``filename`` key, and creates it if missing.
144
+ Emits a startup notice to stderr for each resolved log file path.
145
+
146
+ Args:
147
+ config: The logging config dict (as passed to dictConfig).
148
+ """
149
+ handlers = config.get("handlers", {})
150
+ for _name, handler_cfg in handlers.items():
151
+ filename = handler_cfg.get("filename")
152
+ if not filename:
153
+ continue
154
+ log_path = Path(filename).resolve()
155
+ log_dir = log_path.parent
156
+ log_dir.mkdir(parents=True, exist_ok=True)
157
+ print(
158
+ f"ADP Hypervisor: logging to file {log_path}",
159
+ file=sys.stderr,
160
+ )
161
+
162
+
163
+ def _configure_logging(config_dir: Path, level_override: str | None) -> None:
164
+ """Load and apply logging configuration.
165
+
166
+ Reads logging_conf.yaml from config_dir (falls back to built-in defaults),
167
+ ensures all file-handler directories exist, then applies the config via
168
+ dictConfig. Overrides the root log level when --log-level is provided.
169
+
170
+ Args:
171
+ config_dir: The runtime config directory.
172
+ level_override: Optional log level string (e.g. "DEBUG") from --log-level.
173
+ """
174
+ config = _load_logging_config(config_dir)
175
+
176
+ if level_override is not None:
177
+ config.setdefault("root", {})["level"] = level_override
178
+
179
+ _ensure_log_directories(config)
180
+ logging.config.dictConfig(config)
181
+
182
+
183
+ def _create_yaml_provider(config_dir: Path) -> YamlManifestProvider:
184
+ """Create a YAML manifest provider from a config directory.
185
+
186
+ Args:
187
+ config_dir: Path to the directory containing physical.yaml,
188
+ semantic.yaml, and policy.yaml manifest files.
189
+
190
+ Returns:
191
+ A configured YamlManifestProvider instance.
192
+
193
+ Raises:
194
+ FileNotFoundError: If required manifest files are missing.
195
+ """
196
+ physical_path = config_dir / "physical.yaml"
197
+ semantic_path = config_dir / "semantic.yaml"
198
+ policy_path = config_dir / "policy.yaml"
199
+
200
+ for path in (physical_path, semantic_path):
201
+ if not path.exists():
202
+ raise FileNotFoundError(f"Manifest file not found: {path}")
203
+
204
+ # Accept a missing policy file gracefully — ACCESS enforcement uses
205
+ # closed-by-default semantics, so an empty policy file simply denies all.
206
+ if not policy_path.exists():
207
+ try:
208
+ policy_path.write_text("version: '1.0.0'\n", encoding="utf-8")
209
+ except OSError as exc:
210
+ raise FileNotFoundError(
211
+ f"Policy manifest not found at {policy_path} and could not be created. "
212
+ "Ensure the config directory is writable or provide a policy.yaml file."
213
+ ) from exc
214
+
215
+ return YamlManifestProvider(
216
+ physical_path=physical_path,
217
+ semantic_path=semantic_path,
218
+ policy_path=policy_path,
219
+ )
220
+
221
+
222
+ def _create_role_resolver(config_dir: Path) -> YamlRoleResolver:
223
+ """Create a role resolver from a config directory.
224
+
225
+ Loads the user-to-role mapping from ``users.yaml`` in the config
226
+ directory. If the file does not exist, returns a resolver with
227
+ default configuration.
228
+
229
+ Args:
230
+ config_dir: Path to the directory containing users.yaml.
231
+
232
+ Returns:
233
+ A configured YamlRoleResolver instance.
234
+ """
235
+ users_path = config_dir / "users.yaml"
236
+ if not users_path.exists():
237
+ logger.info("No users.yaml found in %s, using default role config", config_dir)
238
+ return YamlRoleResolver(UserRoleConfig())
239
+
240
+ import yaml
241
+
242
+ try:
243
+ raw = yaml.safe_load(users_path.read_text(encoding="utf-8"))
244
+ config = UserRoleConfig.model_validate(raw or {})
245
+ except (yaml.YAMLError, ValueError) as exc:
246
+ logger.error("Failed to load %s, using default role config: %s", users_path, exc)
247
+ return YamlRoleResolver(UserRoleConfig())
248
+
249
+ logger.info(
250
+ "Loaded user-role config: %d users, default_role=%r",
251
+ len(config.users),
252
+ config.default_role,
253
+ )
254
+ return YamlRoleResolver(config)
255
+
256
+
257
+ def main(args: list[str] | None = None) -> None:
258
+ """Parse arguments and run the ADP server.
259
+
260
+ Args:
261
+ args: Command-line arguments. Defaults to sys.argv[1:].
262
+ """
263
+ parser = _build_parser()
264
+ parsed = parser.parse_args(args)
265
+
266
+ config_dir = Path(parsed.config)
267
+ _configure_logging(config_dir, parsed.log_level)
268
+
269
+ if parsed.transport == "http":
270
+ logger.error("HTTP transport is not yet implemented")
271
+ sys.exit(1)
272
+
273
+ transport = StdioTransport()
274
+ provider = _create_yaml_provider(config_dir)
275
+ role_resolver = _create_role_resolver(config_dir)
276
+ server = ADPServer(manifest_provider=provider, transport=transport, role_resolver=role_resolver)
277
+
278
+ logger.info(
279
+ "Starting ADP Hypervisor: config=%s, transport=%s, log_level=%s",
280
+ parsed.config,
281
+ parsed.transport,
282
+ parsed.log_level or "from config",
283
+ )
284
+
285
+ asyncio.run(server.run())
286
+
287
+
288
+ if __name__ == "__main__":
289
+ main()
@@ -0,0 +1,35 @@
1
+ # Copyright 2026 Datastrato, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """ADP Request Handlers."""
16
+
17
+ from adp_hypervisor.handlers.base import Handler
18
+ from adp_hypervisor.handlers.describe import DescribeHandler
19
+ from adp_hypervisor.handlers.discover import DiscoverHandler
20
+ from adp_hypervisor.handlers.execute import ExecuteHandler
21
+ from adp_hypervisor.handlers.initialize import InitializeHandler
22
+ from adp_hypervisor.handlers.ping import PingHandler
23
+ from adp_hypervisor.handlers.validate import ValidateHandler
24
+
25
+ __all__ = [
26
+ # Base
27
+ "Handler",
28
+ # Handlers
29
+ "DescribeHandler",
30
+ "DiscoverHandler",
31
+ "ExecuteHandler",
32
+ "InitializeHandler",
33
+ "PingHandler",
34
+ "ValidateHandler",
35
+ ]
@@ -0,0 +1,45 @@
1
+ # Copyright 2026 Datastrato, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Handler abstract base class.
17
+
18
+ Defines the abstract interface that all ADP request handlers must implement.
19
+ Each handler processes a specific JSON-RPC method (e.g., adp.initialize, adp.ping).
20
+ """
21
+
22
+ from abc import ABC, abstractmethod
23
+ from typing import Any
24
+
25
+ from pydantic import BaseModel
26
+
27
+
28
+ class Handler(ABC):
29
+ """Handler abstract base class for processing ADP requests."""
30
+
31
+ @property
32
+ @abstractmethod
33
+ def method(self) -> str:
34
+ """Return the JSON-RPC method name this handler processes."""
35
+
36
+ @abstractmethod
37
+ async def handle(self, params: dict[str, Any]) -> BaseModel:
38
+ """Process a request and return the result.
39
+
40
+ Args:
41
+ params: The JSON-RPC request parameters.
42
+
43
+ Returns:
44
+ A Pydantic model representing the response.
45
+ """
@@ -0,0 +1,286 @@
1
+ # Copyright 2026 Datastrato, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Describe Handler.
17
+
18
+ Implements the adp.describe method which returns the UsageContract
19
+ (field definitions and capabilities) for a specific resource and intent class.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ from pydantic import BaseModel
28
+
29
+ from adp_hypervisor.handlers.base import Handler
30
+ from adp_hypervisor.policy.enforcer import PolicyEnforcer
31
+ from adp_hypervisor.protocol.errors import InvalidParamsError, ResourceNotFoundError
32
+ from adp_hypervisor.protocol.types import (
33
+ Capabilities,
34
+ DescribeRequestParams,
35
+ DescribeResult,
36
+ Field,
37
+ FieldType,
38
+ IntentClass,
39
+ MutableCapability,
40
+ PredicateCapability,
41
+ PredicateOperator,
42
+ PredicateUsage,
43
+ ProjectionCapability,
44
+ UsageContract,
45
+ )
46
+
47
+ if TYPE_CHECKING:
48
+ from adp_hypervisor.manifest.index import ManifestIndex
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+ # Operator mapping by FieldType
53
+ # Operator Sets (Reusable groups of operators)
54
+ _BASIC_OPS = [
55
+ PredicateOperator.EQ,
56
+ PredicateOperator.NEQ,
57
+ ]
58
+
59
+ _ORDERING_OPS = [
60
+ PredicateOperator.GT,
61
+ PredicateOperator.LT,
62
+ PredicateOperator.GTE,
63
+ PredicateOperator.LTE,
64
+ ]
65
+
66
+ _MATCHING_OPS = [
67
+ PredicateOperator.LIKE,
68
+ PredicateOperator.ILIKE,
69
+ PredicateOperator.CONTAINS,
70
+ ]
71
+
72
+ _SET_OPS = [
73
+ PredicateOperator.IN,
74
+ ]
75
+
76
+ # Combined Operator Groups
77
+ _NUMERIC_OPS = _BASIC_OPS + _ORDERING_OPS + _SET_OPS
78
+ _TEXT_OPS = _NUMERIC_OPS + _MATCHING_OPS
79
+ _CONTAINER_OPS = _BASIC_OPS + [PredicateOperator.CONTAINS]
80
+ _VECTOR_OPS = [PredicateOperator.SIMILAR]
81
+
82
+
83
+ # Field Type Categories
84
+ _TEXT_TYPES = {
85
+ FieldType.STRING,
86
+ FieldType.DATE,
87
+ FieldType.TIMESTAMP,
88
+ }
89
+
90
+ _NUMERIC_TYPES = {
91
+ FieldType.INTEGER,
92
+ FieldType.FLOAT,
93
+ }
94
+
95
+ _BASIC_TYPES = {
96
+ FieldType.BOOLEAN,
97
+ }
98
+
99
+ _CONTAINER_TYPES = {
100
+ FieldType.JSON,
101
+ FieldType.BLOB,
102
+ }
103
+
104
+ _VECTOR_TYPES = {
105
+ FieldType.VECTOR,
106
+ }
107
+
108
+
109
+ # Dynamic Operator Mapping Construction
110
+ _OPERATORS_BY_FIELD_TYPE: dict[FieldType, list[PredicateOperator]] = {}
111
+
112
+
113
+ def _register_operators(field_types: set[FieldType], operators: list[PredicateOperator]) -> None:
114
+ for field_type in field_types:
115
+ _OPERATORS_BY_FIELD_TYPE[field_type] = operators
116
+
117
+
118
+ _register_operators(_TEXT_TYPES, _TEXT_OPS)
119
+ _register_operators(_NUMERIC_TYPES, _NUMERIC_OPS)
120
+ _register_operators(_BASIC_TYPES, _BASIC_OPS)
121
+ _register_operators(_CONTAINER_TYPES, _CONTAINER_OPS)
122
+ _register_operators(_VECTOR_TYPES, _VECTOR_OPS)
123
+
124
+
125
+ def _get_operators_for_field(field_type: FieldType | None) -> list[PredicateOperator]:
126
+ """Return allowed predicate operators for a given field type."""
127
+ if field_type is None:
128
+ return [PredicateOperator.EQ]
129
+ return list(_OPERATORS_BY_FIELD_TYPE.get(field_type, [PredicateOperator.EQ]))
130
+
131
+
132
+ def _get_mandatory_field_ids(manifest_index: ManifestIndex, resource_id: str) -> set[str]:
133
+ """Get field IDs that have MANDATORY_FILTER policy rules."""
134
+
135
+ policies = manifest_index.get_mandatory_filter_policies(resource_id)
136
+ return {p.field_id for p in policies}
137
+
138
+
139
+ def _build_read_capabilities(fields: list[Field], mandatory_field_ids: set[str]) -> Capabilities:
140
+ """Build capabilities for READ intent classes (LOOKUP, QUERY)."""
141
+ predicates = [
142
+ PredicateCapability(
143
+ field_id=f.field_id,
144
+ usage=(
145
+ PredicateUsage.REQUIRED
146
+ if f.field_id in mandatory_field_ids
147
+ else PredicateUsage.OPTIONAL
148
+ ),
149
+ operators=_get_operators_for_field(f.type),
150
+ )
151
+ for f in fields
152
+ ]
153
+ projections = [ProjectionCapability(field_id=f.field_id) for f in fields]
154
+ return Capabilities(predicates=predicates, projections=projections)
155
+
156
+
157
+ def _build_write_capabilities(fields: list[Field]) -> Capabilities:
158
+ """Build capabilities for WRITE intent classes (INGEST, REVISE)."""
159
+ mutables = [MutableCapability(field_id=f.field_id) for f in fields]
160
+ return Capabilities(mutables=mutables)
161
+
162
+
163
+ def _is_read_intent(intent_class: IntentClass) -> bool:
164
+ return intent_class in (IntentClass.LOOKUP, IntentClass.QUERY)
165
+
166
+
167
+ class DescribeHandler(Handler):
168
+ """Handler for the adp.describe method.
169
+
170
+ Returns the UsageContract (field definitions and capabilities) for a
171
+ specific resource and intent class. Capabilities are auto-derived from
172
+ the resource's field types and policy rules.
173
+ """
174
+
175
+ def __init__(self, manifest_index: ManifestIndex, policy_enforcer: PolicyEnforcer) -> None:
176
+ """Initialize the handler.
177
+
178
+ Args:
179
+ manifest_index: The manifest index to look up resources and policies.
180
+ policy_enforcer: The policy enforcer to check access.
181
+ """
182
+ self._manifest_index = manifest_index
183
+ self._policy_enforcer = policy_enforcer
184
+
185
+ @property
186
+ def method(self) -> str:
187
+ """Return the JSON-RPC method name this handler processes."""
188
+ return "adp.describe"
189
+
190
+ async def handle(self, params: dict[str, Any]) -> BaseModel:
191
+ """Process a describe request.
192
+
193
+ Args:
194
+ params: The JSON-RPC request parameters.
195
+
196
+ Returns:
197
+ A DescribeResult with the usage contract for the requested resource.
198
+
199
+ Raises:
200
+ InvalidParamsError: If the intent class is invalid or unsupported.
201
+ ResourceNotFoundError: If the resource does not exist.
202
+ """
203
+ request = DescribeRequestParams.model_validate(params)
204
+
205
+ role = self._policy_enforcer.resolve_role(params)
206
+
207
+ self._validate_intent_class(request.intent_class)
208
+
209
+ resource = self._manifest_index.get_resource(request.resource_id, version=request.version)
210
+ if resource is None:
211
+ raise ResourceNotFoundError(
212
+ f"Resource not found: {request.resource_id!r}"
213
+ + (f" version={request.version}" if request.version is not None else "")
214
+ )
215
+
216
+ self._validate_resource_supports_intent(
217
+ request.resource_id, request.intent_class, resource.intent_classes
218
+ )
219
+
220
+ self._policy_enforcer.check_access(request.resource_id, role, request.intent_class)
221
+
222
+ fields = self._extract_fields(resource)
223
+ capabilities = self._build_capabilities(fields, request.intent_class, request.resource_id)
224
+
225
+ logger.info(
226
+ "Describe: resource=%s, version=%s, intent_class=%s, fields=%d",
227
+ request.resource_id,
228
+ resource.version,
229
+ request.intent_class,
230
+ len(fields),
231
+ )
232
+
233
+ return DescribeResult(
234
+ resource_id=request.resource_id,
235
+ version=resource.version or 1,
236
+ intent_class=request.intent_class,
237
+ usage_contract=UsageContract(fields=fields, capabilities=capabilities),
238
+ )
239
+
240
+ def _validate_intent_class(self, intent_class: IntentClass) -> None:
241
+ if intent_class == IntentClass.WILDCARD:
242
+ raise InvalidParamsError(
243
+ "WILDCARD intent class is not allowed in describe requests. "
244
+ "Please specify a concrete intent class (LOOKUP, QUERY, INGEST, REVISE)."
245
+ )
246
+
247
+ def _validate_resource_supports_intent(
248
+ self,
249
+ resource_id: str,
250
+ intent_class: IntentClass,
251
+ resource_intent_classes: list[IntentClass],
252
+ ) -> None:
253
+ if not resource_intent_classes:
254
+ raise InvalidParamsError(
255
+ f"Resource {resource_id!r} does not declare any supported intent classes. "
256
+ "Use adp.discover to find resources supporting your intent class."
257
+ )
258
+ if (
259
+ intent_class not in resource_intent_classes
260
+ and IntentClass.WILDCARD not in resource_intent_classes
261
+ ):
262
+ supported = ", ".join(ic.value for ic in resource_intent_classes)
263
+ raise InvalidParamsError(
264
+ f"Resource {resource_id!r} does not support intent class {intent_class.value}. "
265
+ f"Supported intent classes: {supported}. "
266
+ "Use adp.discover to find resources supporting your intent class."
267
+ )
268
+
269
+ def _extract_fields(self, resource: Any) -> list[Field]:
270
+ """Extract fields from the resource's source definition."""
271
+ if resource.source_definition.fields:
272
+ return list(resource.source_definition.fields)
273
+ return []
274
+
275
+ def _build_capabilities(
276
+ self,
277
+ fields: list[Field],
278
+ intent_class: IntentClass,
279
+ resource_id: str,
280
+ ) -> Capabilities:
281
+ if _is_read_intent(intent_class):
282
+ # TODO: enforce policy rules once the policy spec is finalized.
283
+ # Use _get_mandatory_field_ids(self._manifest_index, resource_id) to
284
+ # derive REQUIRED predicates from MandatoryFilterRule entries.
285
+ return _build_read_capabilities(fields, mandatory_field_ids=set())
286
+ return _build_write_capabilities(fields)