offering-protocol 0.1.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.
Files changed (80) hide show
  1. offering_protocol/__init__.py +7 -0
  2. offering_protocol/agent/__init__.py +66 -0
  3. offering_protocol/agent/agent.py +135 -0
  4. offering_protocol/agent/cache.py +51 -0
  5. offering_protocol/agent/capabilities.py +262 -0
  6. offering_protocol/agent/client.py +667 -0
  7. offering_protocol/agent/details.py +227 -0
  8. offering_protocol/agent/schema.py +129 -0
  9. offering_protocol/core/__init__.py +64 -0
  10. offering_protocol/core/models.py +475 -0
  11. offering_protocol/core/references.py +135 -0
  12. offering_protocol/core/schemas/action-relation.schema.json +9 -0
  13. offering_protocol/core/schemas/action-request.schema.json +18 -0
  14. offering_protocol/core/schemas/action.schema.json +56 -0
  15. offering_protocol/core/schemas/attribute-schema-reference.schema.json +6 -0
  16. offering_protocol/core/schemas/authentication-requirement.schema.json +11 -0
  17. offering_protocol/core/schemas/capability-identifier.schema.json +9 -0
  18. offering_protocol/core/schemas/capability-link.schema.json +14 -0
  19. offering_protocol/core/schemas/collection-search-request.schema.json +45 -0
  20. offering_protocol/core/schemas/collection.schema.json +72 -0
  21. offering_protocol/core/schemas/detail-fields.schema.json +16 -0
  22. offering_protocol/core/schemas/enrollment-protocol.schema.json +15 -0
  23. offering_protocol/core/schemas/filter-capability-source.schema.json +41 -0
  24. offering_protocol/core/schemas/filter-definition-page.schema.json +25 -0
  25. offering_protocol/core/schemas/filter-definition.schema.json +63 -0
  26. offering_protocol/core/schemas/filter-expression.schema.json +45 -0
  27. offering_protocol/core/schemas/filter-operator.schema.json +14 -0
  28. offering_protocol/core/schemas/filter-type.schema.json +14 -0
  29. offering_protocol/core/schemas/filter-unit.schema.json +45 -0
  30. offering_protocol/core/schemas/http-action-target.schema.json +36 -0
  31. offering_protocol/core/schemas/invalid-parameter.schema.json +55 -0
  32. offering_protocol/core/schemas/local-resource-identifier-list.schema.json +10 -0
  33. offering_protocol/core/schemas/local-resource-identifier.schema.json +10 -0
  34. offering_protocol/core/schemas/mcp-endpoint.schema.json +29 -0
  35. offering_protocol/core/schemas/offering-search-request.schema.json +68 -0
  36. offering_protocol/core/schemas/offering-search-response.schema.json +20 -0
  37. offering_protocol/core/schemas/offering.schema.json +92 -0
  38. offering_protocol/core/schemas/openapi-action-target.schema.json +20 -0
  39. offering_protocol/core/schemas/operation-descriptor.schema.json +27 -0
  40. offering_protocol/core/schemas/page-envelope.schema.json +25 -0
  41. offering_protocol/core/schemas/page-limit.schema.json +8 -0
  42. offering_protocol/core/schemas/payment-option.schema.json +24 -0
  43. offering_protocol/core/schemas/payment-protocol.schema.json +34 -0
  44. offering_protocol/core/schemas/price-preview.schema.json +133 -0
  45. offering_protocol/core/schemas/problem-code.schema.json +9 -0
  46. offering_protocol/core/schemas/problem-details.schema.json +66 -0
  47. offering_protocol/core/schemas/protocol-version.schema.json +8 -0
  48. offering_protocol/core/schemas/refinement-bucket.schema.json +28 -0
  49. offering_protocol/core/schemas/refinement-group.schema.json +24 -0
  50. offering_protocol/core/schemas/representation.schema.json +11 -0
  51. offering_protocol/core/schemas/resource-identity.schema.json +27 -0
  52. offering_protocol/core/schemas/resource-image.schema.json +40 -0
  53. offering_protocol/core/schemas/resource-reference.schema.json +19 -0
  54. offering_protocol/core/schemas/schema-reference.schema.json +15 -0
  55. offering_protocol/core/schemas/search-capabilities.schema.json +26 -0
  56. offering_protocol/core/schemas/service-branding-image.schema.json +23 -0
  57. offering_protocol/core/schemas/service-branding.schema.json +19 -0
  58. offering_protocol/core/schemas/service-document.schema.json +337 -0
  59. offering_protocol/core/schemas/service-openapi.schema.json +15 -0
  60. offering_protocol/core/schemas/service-origin.schema.json +9 -0
  61. offering_protocol/core/schemas/service-protocols.schema.json +101 -0
  62. offering_protocol/core/schemas/sort-capability-source.schema.json +41 -0
  63. offering_protocol/core/schemas/sort-definition-page.schema.json +25 -0
  64. offering_protocol/core/schemas/sort-definition.schema.json +35 -0
  65. offering_protocol/core/schemas/sort-key.schema.json +28 -0
  66. offering_protocol/core/schemas/top-level-document.schema.json +16 -0
  67. offering_protocol/core/schemas/trust-protocol.schema.json +15 -0
  68. offering_protocol/core/validation.py +390 -0
  69. offering_protocol/directory/__init__.py +51 -0
  70. offering_protocol/directory/client.py +206 -0
  71. offering_protocol/directory/models.py +103 -0
  72. offering_protocol/directory/transport.py +145 -0
  73. offering_protocol/py.typed +1 -0
  74. offering_protocol/service/__init__.py +32 -0
  75. offering_protocol/service/service.py +444 -0
  76. offering_protocol/service/static_catalog.py +258 -0
  77. offering_protocol-0.1.0.dist-info/METADATA +362 -0
  78. offering_protocol-0.1.0.dist-info/RECORD +80 -0
  79. offering_protocol-0.1.0.dist-info/WHEEL +4 -0
  80. offering_protocol-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,7 @@
1
+ """Offering Discovery Protocol SDK."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("offering-protocol")
6
+
7
+ __all__ = ["__version__"]
@@ -0,0 +1,66 @@
1
+ """Agent-side Service and catalog discovery."""
2
+
3
+ from offering_protocol.agent.agent import (
4
+ Agent,
5
+ DefaultServiceClientFactory,
6
+ DiscoveryEvent,
7
+ FederatedSearchRequest,
8
+ ServiceClientFactory,
9
+ )
10
+ from offering_protocol.agent.cache import Cache, CacheFallbacks, CacheRecord, MemoryCache
11
+ from offering_protocol.agent.capabilities import (
12
+ CapabilityIssue,
13
+ CapabilityKind,
14
+ CapabilityScope,
15
+ ResolvedSortDefinition,
16
+ SearchCapabilityCatalog,
17
+ )
18
+ from offering_protocol.agent.client import (
19
+ AgentError,
20
+ Freshness,
21
+ Inspection,
22
+ ServiceClient,
23
+ ServiceRequestError,
24
+ TraversalOptions,
25
+ UnsupportedOperationError,
26
+ )
27
+ from offering_protocol.agent.details import (
28
+ DiscoveredAction,
29
+ DiscoveredHttpAction,
30
+ DiscoveredOpenApiAction,
31
+ OfferingDetails,
32
+ OfferingIssue,
33
+ OfferingIssueScope,
34
+ ResolvedAction,
35
+ )
36
+
37
+ __all__ = [
38
+ "Agent",
39
+ "AgentError",
40
+ "Cache",
41
+ "CacheFallbacks",
42
+ "CacheRecord",
43
+ "CapabilityIssue",
44
+ "CapabilityKind",
45
+ "CapabilityScope",
46
+ "DefaultServiceClientFactory",
47
+ "DiscoveredAction",
48
+ "DiscoveredHttpAction",
49
+ "DiscoveredOpenApiAction",
50
+ "DiscoveryEvent",
51
+ "FederatedSearchRequest",
52
+ "Freshness",
53
+ "Inspection",
54
+ "MemoryCache",
55
+ "OfferingDetails",
56
+ "OfferingIssue",
57
+ "OfferingIssueScope",
58
+ "ResolvedAction",
59
+ "ResolvedSortDefinition",
60
+ "SearchCapabilityCatalog",
61
+ "ServiceClient",
62
+ "ServiceClientFactory",
63
+ "ServiceRequestError",
64
+ "TraversalOptions",
65
+ "UnsupportedOperationError",
66
+ ]
@@ -0,0 +1,135 @@
1
+ """Directory-to-Service federated Offering discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass, field
7
+ from typing import Protocol
8
+
9
+ from offering_protocol.agent.client import AgentError, ServiceClient, TraversalOptions
10
+ from offering_protocol.core import Offering, OfferingSearchRequest, Representation
11
+ from offering_protocol.directory import (
12
+ DirectoryClient,
13
+ DirectoryError,
14
+ DirectoryService,
15
+ Environment,
16
+ IterationOptions,
17
+ SearchRequest,
18
+ TransportError,
19
+ )
20
+
21
+
22
+ class ServiceClientFactory(Protocol):
23
+ def create(self, service: DirectoryService) -> ServiceClient: ...
24
+
25
+
26
+ class DefaultServiceClientFactory:
27
+ def create(self, service: DirectoryService) -> ServiceClient:
28
+ return ServiceClient(service.service_origin)
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class FederatedSearchRequest:
33
+ concurrency: int = 0
34
+ max_offerings_per_service: int = 0
35
+ max_services: int = 0
36
+ offerings: OfferingSearchRequest = field(default_factory=OfferingSearchRequest)
37
+ services: SearchRequest = field(default_factory=SearchRequest)
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class DiscoveryEvent:
42
+ service: DirectoryService
43
+ offering: Offering | None = None
44
+ issue: str | None = None
45
+
46
+
47
+ class Agent:
48
+ def __init__(
49
+ self,
50
+ environment: Environment = Environment.PRODUCTION,
51
+ *,
52
+ directory: DirectoryClient | None = None,
53
+ factory: ServiceClientFactory | None = None,
54
+ ) -> None:
55
+ self._owns_directory = directory is None
56
+ self.directory = directory or DirectoryClient(environment)
57
+ self.factory = factory or DefaultServiceClientFactory()
58
+
59
+ async def __aenter__(self) -> Agent:
60
+ return self
61
+
62
+ async def __aexit__(self, *args: object) -> None:
63
+ await self.aclose()
64
+
65
+ async def aclose(self) -> None:
66
+ if self._owns_directory:
67
+ await self.directory.aclose()
68
+
69
+ @property
70
+ def environment(self) -> Environment:
71
+ return self.directory.environment
72
+
73
+ async def search_offerings_across_services(
74
+ self, request: FederatedSearchRequest
75
+ ) -> list[DiscoveryEvent]:
76
+ maximum_services = _bounded(request.max_services, 10, 100, "max_services")
77
+ maximum_offerings = _bounded(
78
+ request.max_offerings_per_service, 10, 100, "max_offerings_per_service"
79
+ )
80
+ concurrency = _bounded(request.concurrency, 4, 16, "concurrency")
81
+ try:
82
+ services = await self.directory.search_services(
83
+ request.services,
84
+ IterationOptions(max_items=maximum_services, max_pages=16),
85
+ )
86
+ except (DirectoryError, TransportError) as error:
87
+ raise AgentError(f"ODP Directory failed: {error}") from error
88
+ semaphore = asyncio.Semaphore(concurrency)
89
+
90
+ async def search(service: DirectoryService) -> list[DiscoveryEvent]:
91
+ async with semaphore:
92
+ try:
93
+ offerings = await _search_service(
94
+ self.factory,
95
+ service,
96
+ request.offerings,
97
+ maximum_offerings,
98
+ )
99
+ return [DiscoveryEvent(service, offering) for offering in offerings]
100
+ except Exception as error:
101
+ return [DiscoveryEvent(service, issue=str(error))]
102
+
103
+ results = await asyncio.gather(*(search(service) for service in services))
104
+ return [event for group in results for event in group]
105
+
106
+
107
+ async def _search_service(
108
+ factory: ServiceClientFactory,
109
+ service: DirectoryService,
110
+ request: OfferingSearchRequest,
111
+ maximum: int,
112
+ ) -> list[Offering]:
113
+ async with factory.create(service) as client:
114
+ options = TraversalOptions(max_items=maximum, max_pages=16)
115
+ if _has_search(request):
116
+ return await client.search_all_offerings(request, Representation.TERSE, options)
117
+ return await client.list_all_offerings(Representation.TERSE, 0, options)
118
+
119
+
120
+ def _has_search(request: OfferingSearchRequest) -> bool:
121
+ return bool(
122
+ request.query
123
+ or request.filters
124
+ or request.include_descendants
125
+ or request.sort
126
+ or request.refinements
127
+ or request.collection_id
128
+ )
129
+
130
+
131
+ def _bounded(value: int, fallback: int, maximum: int, name: str) -> int:
132
+ result = fallback if value == 0 else value
133
+ if not 1 <= result <= maximum:
134
+ raise AgentError(f"{name} must be from 1 through {maximum}")
135
+ return result
@@ -0,0 +1,51 @@
1
+ """Agent-side representation caching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime, timedelta
7
+ from typing import Protocol
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class CacheRecord:
12
+ body: bytes
13
+ etag: str | None
14
+ expires: datetime
15
+ final_url: str
16
+ last_modified: str | None
17
+ status: int
18
+ stored: datetime
19
+
20
+
21
+ class Cache(Protocol):
22
+ def delete(self, key: str) -> None: ...
23
+
24
+ def get(self, key: str) -> CacheRecord | None: ...
25
+
26
+ def set(self, key: str, record: CacheRecord) -> None: ...
27
+
28
+
29
+ class MemoryCache:
30
+ def __init__(self) -> None:
31
+ self._records: dict[str, CacheRecord] = {}
32
+
33
+ def delete(self, key: str) -> None:
34
+ self._records.pop(key, None)
35
+
36
+ def get(self, key: str) -> CacheRecord | None:
37
+ return self._records.get(key)
38
+
39
+ def set(self, key: str, record: CacheRecord) -> None:
40
+ self._records[key] = record
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class CacheFallbacks:
45
+ collection: timedelta = timedelta(hours=1)
46
+ offering: timedelta = timedelta(minutes=5)
47
+ service_document: timedelta = timedelta(hours=4)
48
+
49
+
50
+ def utc_now() -> datetime:
51
+ return datetime.now(UTC)
@@ -0,0 +1,262 @@
1
+ """Effective Service and Collection search capabilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from dataclasses import dataclass, field
7
+ from enum import StrEnum
8
+ from urllib.parse import urljoin, urlsplit
9
+
10
+ from offering_protocol.agent.client import AgentError, ServiceClient
11
+ from offering_protocol.core import (
12
+ Collection,
13
+ FilterDefinition,
14
+ Operation,
15
+ SearchCapabilities,
16
+ SortDefinition,
17
+ parse_filter_definition_page,
18
+ parse_sort_definition_page,
19
+ )
20
+
21
+ _MAXIMUM_CAPABILITY_PAGES = 16
22
+ _MAXIMUM_FILTERS = 1_024
23
+ _MAXIMUM_SORTS = 128
24
+
25
+
26
+ class CapabilityScope(StrEnum):
27
+ COLLECTION = "collection"
28
+ SERVICE = "service"
29
+
30
+
31
+ class CapabilityKind(StrEnum):
32
+ FILTERS = "filters"
33
+ SORTS = "sorts"
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class CapabilityIssue:
38
+ kind: CapabilityKind
39
+ message: str
40
+ scope: CapabilityScope
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class ResolvedSortDefinition:
45
+ definition: SortDefinition
46
+ filters: tuple[FilterDefinition, ...]
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class SearchCapabilityCatalog:
51
+ filters: dict[str, FilterDefinition] = field(default_factory=dict)
52
+ issues: list[CapabilityIssue] = field(default_factory=list)
53
+ sorts: dict[str, ResolvedSortDefinition] = field(default_factory=dict)
54
+
55
+
56
+ async def get_collection_search_capabilities(
57
+ client: ServiceClient, identifier: str
58
+ ) -> SearchCapabilityCatalog:
59
+ collection = await client.get_collection(identifier)
60
+ return await _resolve(client, collection)
61
+
62
+
63
+ async def get_offering_search_capabilities(
64
+ client: ServiceClient, collection_id: str | None
65
+ ) -> SearchCapabilityCatalog:
66
+ collection = await client.get_collection(collection_id) if collection_id else None
67
+ return await _resolve(client, collection)
68
+
69
+
70
+ async def _resolve(client: ServiceClient, collection: Collection | None) -> SearchCapabilityCatalog:
71
+ inspection = await client.inspect()
72
+ result = SearchCapabilityCatalog()
73
+ supports_search = any(
74
+ item.name is Operation.SEARCH_OFFERINGS for item in inspection.document.operations
75
+ )
76
+ if not supports_search:
77
+ if collection is not None and collection.search_capabilities is not None:
78
+ result.issues.append(
79
+ CapabilityIssue(
80
+ CapabilityKind.FILTERS,
81
+ "Collection search capabilities require search-offerings",
82
+ CapabilityScope.COLLECTION,
83
+ )
84
+ )
85
+ return result
86
+ sorts: dict[str, SortDefinition] = {}
87
+ sort_scopes: dict[str, CapabilityScope] = {}
88
+ sources = (
89
+ (inspection.document.search_capabilities, CapabilityScope.SERVICE),
90
+ (
91
+ collection.search_capabilities if collection is not None else None,
92
+ CapabilityScope.COLLECTION,
93
+ ),
94
+ )
95
+ for capabilities, scope in sources:
96
+ if capabilities is None:
97
+ continue
98
+ await _add_filters(client, result, scope, capabilities)
99
+ await _add_sorts(client, result, sorts, sort_scopes, scope, capabilities)
100
+ for identifier, definition in sorts.items():
101
+ filters = tuple(
102
+ result.filters[key.filter_id]
103
+ for key in definition.keys
104
+ if key.filter_id in result.filters
105
+ )
106
+ if len(filters) != len(definition.keys):
107
+ result.issues.append(
108
+ CapabilityIssue(
109
+ CapabilityKind.SORTS,
110
+ f"Sort {identifier} references an unavailable filter",
111
+ sort_scopes[identifier],
112
+ )
113
+ )
114
+ else:
115
+ result.sorts[identifier] = ResolvedSortDefinition(definition, filters)
116
+ return result
117
+
118
+
119
+ async def _add_filters(
120
+ client: ServiceClient,
121
+ result: SearchCapabilityCatalog,
122
+ scope: CapabilityScope,
123
+ capabilities: SearchCapabilities,
124
+ ) -> None:
125
+ if capabilities.filters is None:
126
+ return
127
+ try:
128
+ values = (
129
+ await _load_filters(client, capabilities.filters.linked.href)
130
+ if capabilities.filters.linked is not None
131
+ else capabilities.filters.inline
132
+ )
133
+ except AgentError as error:
134
+ result.issues.append(CapabilityIssue(CapabilityKind.FILTERS, str(error), scope))
135
+ return
136
+ duplicates = _duplicates((item.id for item in values), result.filters)
137
+ for identifier in duplicates:
138
+ result.filters.pop(identifier, None)
139
+ accepted = [item for item in values if item.id not in duplicates]
140
+ if len(result.filters) + len(accepted) > _MAXIMUM_FILTERS:
141
+ result.issues.append(
142
+ CapabilityIssue(
143
+ CapabilityKind.FILTERS,
144
+ "Effective filters exceed 1024 entries",
145
+ scope,
146
+ )
147
+ )
148
+ return
149
+ result.filters.update((item.id, item) for item in accepted)
150
+ _report_duplicates(duplicates, CapabilityKind.FILTERS, scope, result.issues)
151
+
152
+
153
+ async def _add_sorts(
154
+ client: ServiceClient,
155
+ result: SearchCapabilityCatalog,
156
+ target: dict[str, SortDefinition],
157
+ scopes: dict[str, CapabilityScope],
158
+ scope: CapabilityScope,
159
+ capabilities: SearchCapabilities,
160
+ ) -> None:
161
+ if capabilities.sorts is None:
162
+ return
163
+ try:
164
+ values = (
165
+ await _load_sorts(client, capabilities.sorts.linked.href)
166
+ if capabilities.sorts.linked is not None
167
+ else capabilities.sorts.inline
168
+ )
169
+ except AgentError as error:
170
+ result.issues.append(CapabilityIssue(CapabilityKind.SORTS, str(error), scope))
171
+ return
172
+ duplicates = _duplicates((item.id for item in values), target)
173
+ for identifier in duplicates:
174
+ target.pop(identifier, None)
175
+ scopes.pop(identifier, None)
176
+ accepted = [item for item in values if item.id not in duplicates]
177
+ if len(target) + len(accepted) > _MAXIMUM_SORTS:
178
+ result.issues.append(
179
+ CapabilityIssue(CapabilityKind.SORTS, "Effective sorts exceed 128 entries", scope)
180
+ )
181
+ return
182
+ target.update((item.id, item) for item in accepted)
183
+ scopes.update((item.id, scope) for item in accepted)
184
+ _report_duplicates(duplicates, CapabilityKind.SORTS, scope, result.issues)
185
+
186
+
187
+ async def _load_filters(client: ServiceClient, reference: str) -> list[FilterDefinition]:
188
+ values: list[FilterDefinition] = []
189
+ next_reference = reference
190
+ visited: set[str] = set()
191
+ for _ in range(_MAXIMUM_CAPABILITY_PAGES):
192
+ if not next_reference:
193
+ return values
194
+ target = _resolve_reference(next_reference, client.service_origin)
195
+ if target in visited:
196
+ raise AgentError("ODP capability pagination loop detected")
197
+ visited.add(target)
198
+ body = await client._linked_odp(
199
+ target, client._cache_fallbacks.collection, parse_filter_definition_page
200
+ )
201
+ page = parse_filter_definition_page(body)
202
+ values.extend(page.items)
203
+ next_reference = page.next
204
+ if next_reference:
205
+ raise AgentError("ODP capability source exceeded 16 pages")
206
+ return values
207
+
208
+
209
+ async def _load_sorts(client: ServiceClient, reference: str) -> list[SortDefinition]:
210
+ values: list[SortDefinition] = []
211
+ next_reference = reference
212
+ visited: set[str] = set()
213
+ for _ in range(_MAXIMUM_CAPABILITY_PAGES):
214
+ if not next_reference:
215
+ return values
216
+ target = _resolve_reference(next_reference, client.service_origin)
217
+ if target in visited:
218
+ raise AgentError("ODP capability pagination loop detected")
219
+ visited.add(target)
220
+ body = await client._linked_odp(
221
+ target, client._cache_fallbacks.collection, parse_sort_definition_page
222
+ )
223
+ page = parse_sort_definition_page(body)
224
+ values.extend(page.items)
225
+ next_reference = page.next
226
+ if next_reference:
227
+ raise AgentError("ODP capability source exceeded 16 pages")
228
+ return values
229
+
230
+
231
+ def _resolve_reference(reference: str, origin: str) -> str:
232
+ target = urljoin(f"{origin}/", reference)
233
+ parsed = urlsplit(target)
234
+ if parsed.scheme not in {"http", "https"} or parsed.hostname is None:
235
+ raise AgentError("ODP capability reference must use HTTP or HTTPS")
236
+ return target
237
+
238
+
239
+ def _duplicates(values: Iterable[str], existing: Mapping[str, object]) -> set[str]:
240
+ seen: set[str] = set()
241
+ duplicates: set[str] = set()
242
+ for value in values:
243
+ if value in seen or value in existing:
244
+ duplicates.add(value)
245
+ seen.add(value)
246
+ return duplicates
247
+
248
+
249
+ def _report_duplicates(
250
+ duplicates: set[str],
251
+ kind: CapabilityKind,
252
+ scope: CapabilityScope,
253
+ issues: list[CapabilityIssue],
254
+ ) -> None:
255
+ if duplicates:
256
+ issues.append(
257
+ CapabilityIssue(
258
+ kind,
259
+ f"Duplicate {kind.value}: {', '.join(sorted(duplicates))}",
260
+ scope,
261
+ )
262
+ )