schemarouter 0.2.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,656 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from copy import deepcopy
5
+ from typing import Any
6
+ from urllib.parse import quote, urljoin, urlparse
7
+
8
+ import httpx
9
+
10
+ from ..errors import SchemaSourceError
11
+ from ..models import EndpointSpec, FieldSpec, ParameterSpec, ToolCall, ToolSpec
12
+ from .base import AdapterContext, AdapterLoadResult
13
+
14
+ _MAX_DISCOVERY_BYTES = 2 * 1024 * 1024
15
+ _MAX_RESPONSE_BYTES = 16 * 1024 * 1024
16
+ _VERSION_SEGMENT = re.compile(r"^v\d+(?:\.\d+)?$")
17
+ _ENTRY_SEGMENT = re.compile(r"^[A-Za-z0-9._-]+$")
18
+
19
+
20
+ def _slug(value: str) -> str:
21
+ slug = re.sub(r"[^A-Za-z0-9._-]+", "_", value.strip()).strip("_.-").lower()
22
+ return slug or "optimade"
23
+
24
+
25
+ def _safe_base_url(url: str) -> str:
26
+ parsed = urlparse(url)
27
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
28
+ raise SchemaSourceError("OPTIMADE URL must be an absolute http(s) URL")
29
+ if parsed.username or parsed.password:
30
+ raise SchemaSourceError("OPTIMADE URL must not contain credentials")
31
+ if parsed.query or parsed.fragment:
32
+ raise SchemaSourceError("OPTIMADE base URL must not contain query or fragment")
33
+ return url.rstrip("/")
34
+
35
+
36
+ def _candidate_versioned_bases(url: str) -> tuple[str, ...]:
37
+ base = _safe_base_url(url)
38
+ leaf = urlparse(base).path.rstrip("/").rsplit("/", 1)[-1]
39
+ if _VERSION_SEGMENT.fullmatch(leaf):
40
+ return (base,)
41
+ return (f"{base}/v1", base)
42
+
43
+
44
+ def _validate_entry_type(entry_type: str) -> str:
45
+ parts = entry_type.split("/")
46
+ if not parts or any(not part or not _ENTRY_SEGMENT.fullmatch(part) for part in parts):
47
+ raise SchemaSourceError(f"unsafe OPTIMADE entry type: {entry_type!r}")
48
+ return entry_type
49
+
50
+
51
+ async def _bounded_get(
52
+ client: httpx.AsyncClient,
53
+ url: str,
54
+ *,
55
+ headers: dict[str, str] | None,
56
+ params: dict[str, Any] | None = None,
57
+ max_bytes: int,
58
+ max_redirects: int = 5,
59
+ ) -> httpx.Response:
60
+ current = _safe_base_url(url)
61
+ initial = urlparse(current)
62
+ query_params = params
63
+
64
+ for _ in range(max_redirects + 1):
65
+ async with client.stream(
66
+ "GET",
67
+ current,
68
+ headers=headers,
69
+ params=query_params,
70
+ follow_redirects=False,
71
+ ) as response:
72
+ if response.is_redirect:
73
+ location = response.headers.get("location")
74
+ if not location:
75
+ raise SchemaSourceError("OPTIMADE redirect is missing Location")
76
+ target = urljoin(current, location)
77
+ parsed = urlparse(target)
78
+ if (
79
+ parsed.scheme not in {"http", "https"}
80
+ or not parsed.netloc
81
+ or parsed.username
82
+ or parsed.password
83
+ ):
84
+ raise SchemaSourceError("OPTIMADE redirect target is not a safe http(s) URL")
85
+ if (
86
+ parsed.scheme.casefold() != initial.scheme.casefold()
87
+ or parsed.hostname != initial.hostname
88
+ or (parsed.port or (443 if parsed.scheme == "https" else 80))
89
+ != (initial.port or (443 if initial.scheme == "https" else 80))
90
+ ):
91
+ raise SchemaSourceError(
92
+ "cross-origin OPTIMADE redirects are not allowed"
93
+ )
94
+ current = target
95
+ query_params = None
96
+ continue
97
+
98
+ response.raise_for_status()
99
+ content_length = response.headers.get("content-length")
100
+ if content_length is not None:
101
+ try:
102
+ declared_size = int(content_length)
103
+ except ValueError:
104
+ declared_size = None
105
+ if declared_size is not None and declared_size > max_bytes:
106
+ raise SchemaSourceError(
107
+ f"OPTIMADE response exceeds {max_bytes} byte safety limit"
108
+ )
109
+
110
+ chunks: list[bytes] = []
111
+ total = 0
112
+ async for chunk in response.aiter_bytes():
113
+ total += len(chunk)
114
+ if total > max_bytes:
115
+ raise SchemaSourceError(
116
+ f"OPTIMADE response exceeds {max_bytes} byte safety limit"
117
+ )
118
+ chunks.append(chunk)
119
+
120
+ return httpx.Response(
121
+ status_code=response.status_code,
122
+ headers=response.headers,
123
+ content=b"".join(chunks),
124
+ request=response.request,
125
+ )
126
+
127
+ raise SchemaSourceError("OPTIMADE URL exceeded the redirect limit")
128
+
129
+
130
+ def _base_info_attributes(document: Any) -> dict[str, Any] | None:
131
+ if not isinstance(document, dict):
132
+ return None
133
+ data = document.get("data")
134
+ if not isinstance(data, dict) or data.get("type") != "info":
135
+ return None
136
+ attributes = data.get("attributes")
137
+ if not isinstance(attributes, dict):
138
+ return None
139
+ if not isinstance(attributes.get("api_version"), str):
140
+ return None
141
+ if not isinstance(attributes.get("available_endpoints"), list):
142
+ return None
143
+ return attributes
144
+
145
+
146
+ def _entry_info_payload(document: Any, entry_type: str) -> dict[str, Any] | None:
147
+ if not isinstance(document, dict):
148
+ return None
149
+ data = document.get("data")
150
+ if not isinstance(data, dict):
151
+ return None
152
+
153
+ declared_type = data.get("type")
154
+ declared_id = data.get("id")
155
+ if declared_type is not None and declared_type != "info":
156
+ return None
157
+ if declared_id is not None and declared_id != entry_type:
158
+ return None
159
+
160
+ payload = dict(data)
161
+ attributes = data.get("attributes")
162
+ if isinstance(attributes, dict):
163
+ payload.update(attributes)
164
+ payload["_identity_inferred"] = declared_type is None or declared_id is None
165
+ return payload
166
+
167
+
168
+ _OPTIMADE_TO_JSON_TYPE = {
169
+ "string": "string",
170
+ "integer": "integer",
171
+ "float": "number",
172
+ "boolean": "boolean",
173
+ "timestamp": "string",
174
+ "list": "array",
175
+ "dictionary": "object",
176
+ "number": "number",
177
+ "array": "array",
178
+ "object": "object",
179
+ "null": "null",
180
+ }
181
+
182
+
183
+ def _normalize_optimade_schema(value: Any) -> Any:
184
+ if isinstance(value, list):
185
+ return [_normalize_optimade_schema(item) for item in value]
186
+ if not isinstance(value, dict):
187
+ return value
188
+
189
+ normalized = {
190
+ key: _normalize_optimade_schema(item)
191
+ for key, item in value.items()
192
+ if key not in {"title", "description"}
193
+ }
194
+ raw_type = normalized.get("type")
195
+ if isinstance(raw_type, str):
196
+ mapped = _OPTIMADE_TO_JSON_TYPE.get(raw_type)
197
+ if mapped is None:
198
+ normalized.pop("type", None)
199
+ else:
200
+ normalized["type"] = mapped
201
+ if raw_type == "timestamp":
202
+ normalized.setdefault("format", "date-time")
203
+ elif isinstance(raw_type, list):
204
+ mapped_types = [
205
+ _OPTIMADE_TO_JSON_TYPE[item]
206
+ for item in raw_type
207
+ if isinstance(item, str) and item in _OPTIMADE_TO_JSON_TYPE
208
+ ]
209
+ if mapped_types:
210
+ normalized["type"] = list(dict.fromkeys(mapped_types))
211
+ if "timestamp" in raw_type:
212
+ normalized.setdefault("format", "date-time")
213
+ else:
214
+ normalized.pop("type", None)
215
+
216
+ return normalized
217
+
218
+
219
+ def _property_schema(spec: dict[str, Any]) -> dict[str, Any]:
220
+ return _normalize_optimade_schema(deepcopy(spec))
221
+
222
+
223
+ def _field_from_property(name: str, spec: dict[str, Any]) -> FieldSpec:
224
+ description = str(spec.get("description") or spec.get("title") or "")
225
+ unit = spec.get("x-optimade-unit") or spec.get("unit")
226
+ return FieldSpec(
227
+ name=name,
228
+ description=description,
229
+ json_schema=_property_schema(spec),
230
+ aliases=[name.replace("_", " ")],
231
+ unit=str(unit) if unit not in {None, "inapplicable"} else None,
232
+ identifier=False,
233
+ source_type="optimade",
234
+ )
235
+
236
+
237
+ def _output_schema(fields: list[FieldSpec], *, many: bool) -> dict[str, Any]:
238
+ properties = {field.name: deepcopy(field.json_schema) for field in fields}
239
+ item = {
240
+ "type": "object",
241
+ "properties": properties,
242
+ "required": ["id", "type"],
243
+ "additionalProperties": True,
244
+ }
245
+ if many:
246
+ return {"type": "array", "items": item}
247
+ return item
248
+
249
+
250
+ def _search_parameters() -> list[ParameterSpec]:
251
+ return [
252
+ ParameterSpec(
253
+ name="filter",
254
+ description="OPTIMADE filter expression.",
255
+ location="query",
256
+ json_schema={"type": "string"},
257
+ aliases=["where", "query filter"],
258
+ ),
259
+ ParameterSpec(
260
+ name="page_limit",
261
+ description="Maximum number of entries to return.",
262
+ location="query",
263
+ json_schema={"type": "integer", "minimum": 1},
264
+ aliases=["limit", "top"],
265
+ ),
266
+ ParameterSpec(
267
+ name="sort",
268
+ description="Comma-delimited OPTIMADE sort expression.",
269
+ location="query",
270
+ json_schema={"type": "string"},
271
+ ),
272
+ ParameterSpec(
273
+ name="include",
274
+ description="Comma-delimited related resource types to include.",
275
+ location="query",
276
+ json_schema={"type": "string"},
277
+ ),
278
+ ParameterSpec(
279
+ name="page_offset",
280
+ description="Offset-based pagination value.",
281
+ location="query",
282
+ json_schema={"type": "integer", "minimum": 0},
283
+ ),
284
+ ParameterSpec(
285
+ name="page_number",
286
+ description="Page-number pagination value.",
287
+ location="query",
288
+ json_schema={"type": "integer", "minimum": 1},
289
+ ),
290
+ ParameterSpec(
291
+ name="page_cursor",
292
+ description="Cursor-based pagination value.",
293
+ location="query",
294
+ json_schema={"type": "string"},
295
+ ),
296
+ ParameterSpec(
297
+ name="email_address",
298
+ description="Optional contact email sent to the OPTIMADE provider.",
299
+ location="query",
300
+ json_schema={"type": "string"},
301
+ ),
302
+ ]
303
+
304
+
305
+ def _endpoint_token(entry_type: str) -> str:
306
+ return _slug(entry_type.replace("/", "__"))
307
+
308
+
309
+ def _tool_from_discovery(
310
+ *,
311
+ name: str,
312
+ namespace: str | None,
313
+ versioned_base_url: str,
314
+ base_info: dict[str, Any],
315
+ entries: dict[str, dict[str, Any]],
316
+ skipped: list[str],
317
+ ) -> ToolSpec:
318
+ endpoints: list[EndpointSpec] = []
319
+ inferred_identity: list[str] = []
320
+ for entry_type, info in entries.items():
321
+ if bool(info.get("_identity_inferred")):
322
+ inferred_identity.append(entry_type)
323
+ properties = info.get("properties")
324
+ if not isinstance(properties, dict):
325
+ continue
326
+
327
+ output_by_format = info.get("output_fields_by_format")
328
+ json_fields: list[str]
329
+ if isinstance(output_by_format, dict) and isinstance(output_by_format.get("json"), list):
330
+ json_fields = [
331
+ str(field)
332
+ for field in output_by_format["json"]
333
+ if isinstance(field, str)
334
+ ]
335
+ else:
336
+ json_fields = [str(field) for field in properties]
337
+
338
+ fields = [
339
+ FieldSpec(
340
+ name="id",
341
+ description="OPTIMADE entry identifier.",
342
+ json_schema={"type": "string"},
343
+ aliases=["identifier", "entry id"],
344
+ identifier=True,
345
+ source_type="optimade",
346
+ ),
347
+ FieldSpec(
348
+ name="type",
349
+ description="OPTIMADE entry type.",
350
+ json_schema={"type": "string"},
351
+ aliases=["entry type"],
352
+ source_type="optimade",
353
+ ),
354
+ ]
355
+ seen = {"id", "type"}
356
+ for field_name in json_fields:
357
+ if field_name in seen:
358
+ continue
359
+ raw_spec = properties.get(field_name)
360
+ if not isinstance(raw_spec, dict):
361
+ continue
362
+ fields.append(_field_from_property(field_name, raw_spec))
363
+ seen.add(field_name)
364
+
365
+ token = _endpoint_token(entry_type)
366
+ description = str(info.get("description") or f"OPTIMADE {entry_type} entries")
367
+ safe_entry_type = _validate_entry_type(entry_type)
368
+ endpoints.extend(
369
+ [
370
+ EndpointSpec(
371
+ name=f"search_{token}",
372
+ description=f"Search {description}",
373
+ parameters=_search_parameters(),
374
+ output_fields=fields,
375
+ output_schema=_output_schema(fields, many=True),
376
+ method="GET",
377
+ path=f"/{safe_entry_type}",
378
+ read_only=True,
379
+ destructive=False,
380
+ metadata={
381
+ "entry_type": entry_type,
382
+ "mode": "search",
383
+ "field_projection": "response_fields",
384
+ },
385
+ ),
386
+ EndpointSpec(
387
+ name=f"get_{token}",
388
+ description=f"Get one {description}",
389
+ parameters=[
390
+ ParameterSpec(
391
+ name="id",
392
+ description="OPTIMADE entry identifier.",
393
+ required=True,
394
+ location="path",
395
+ json_schema={"type": "string", "minLength": 1},
396
+ aliases=["identifier", "entry id"],
397
+ )
398
+ ],
399
+ output_fields=fields,
400
+ output_schema=_output_schema(fields, many=False),
401
+ method="GET",
402
+ path=f"/{safe_entry_type}/{{id}}",
403
+ read_only=True,
404
+ destructive=False,
405
+ metadata={
406
+ "entry_type": entry_type,
407
+ "mode": "get",
408
+ "field_projection": "response_fields",
409
+ },
410
+ ),
411
+ ]
412
+ )
413
+
414
+ if not endpoints:
415
+ detail = ", ".join(skipped) if skipped else "no entry types were discovered"
416
+ raise SchemaSourceError(
417
+ "OPTIMADE source exposed no usable entry schemas: " + detail
418
+ )
419
+
420
+ return ToolSpec(
421
+ name=name,
422
+ namespace=namespace,
423
+ description="OPTIMADE interoperable materials database",
424
+ endpoints=endpoints,
425
+ source_type="optimade",
426
+ metadata={
427
+ "adapter": "optimade",
428
+ "remote": True,
429
+ "api_version": base_info.get("api_version"),
430
+ "versioned_base_url": versioned_base_url,
431
+ "is_index": bool(base_info.get("is_index", False)),
432
+ "skipped_entry_types": skipped,
433
+ "inferred_entry_info_identity": inferred_identity,
434
+ },
435
+ )
436
+
437
+
438
+ class OPTIMADESourceAdapter:
439
+ kind = "optimade"
440
+ priority = 90
441
+
442
+ async def load(self, context: AdapterContext) -> AdapterLoadResult | None:
443
+ candidates = _candidate_versioned_bases(context.base_url or context.url)
444
+
445
+ owns_client = context.http_client is None
446
+ client = context.http_client or httpx.AsyncClient(
447
+ timeout=context.timeout,
448
+ follow_redirects=False,
449
+ )
450
+ try:
451
+ versioned_base_url: str | None = None
452
+ base_info: dict[str, Any] | None = None
453
+ for candidate in candidates:
454
+ try:
455
+ response = await _bounded_get(
456
+ client,
457
+ f"{candidate}/info",
458
+ headers=context.schema_headers,
459
+ max_bytes=_MAX_DISCOVERY_BYTES,
460
+ )
461
+ document = response.json()
462
+ except SchemaSourceError:
463
+ raise
464
+ except Exception: # noqa: BLE001
465
+ continue
466
+ attributes = _base_info_attributes(document)
467
+ if attributes is not None:
468
+ versioned_base_url = candidate
469
+ base_info = attributes
470
+ break
471
+
472
+ if versioned_base_url is None or base_info is None:
473
+ return None
474
+
475
+ if bool(base_info.get("is_index", False)):
476
+ raise SchemaSourceError(
477
+ "OPTIMADE index meta-databases are discovery catalogs, not executable "
478
+ "entry databases; provide a concrete provider database URL"
479
+ )
480
+
481
+ entry_types_by_format = base_info.get("entry_types_by_format")
482
+ if (
483
+ isinstance(entry_types_by_format, dict)
484
+ and isinstance(entry_types_by_format.get("json"), list)
485
+ ):
486
+ entry_types = [
487
+ str(value)
488
+ for value in entry_types_by_format["json"]
489
+ if isinstance(value, str)
490
+ ]
491
+ else:
492
+ entry_types = [
493
+ str(value)
494
+ for value in base_info.get("available_endpoints", [])
495
+ if isinstance(value, str) and value not in {"info", "links"}
496
+ ]
497
+
498
+ entry_types = list(dict.fromkeys(entry_types))[:32]
499
+ entries: dict[str, dict[str, Any]] = {}
500
+ skipped: list[str] = []
501
+ for entry_type in entry_types:
502
+ try:
503
+ safe_entry_type = _validate_entry_type(entry_type)
504
+ except SchemaSourceError as exc:
505
+ skipped.append(f"{entry_type}:{type(exc).__name__}")
506
+ continue
507
+ try:
508
+ response = await _bounded_get(
509
+ client,
510
+ f"{versioned_base_url}/info/{safe_entry_type}",
511
+ headers=context.schema_headers,
512
+ max_bytes=_MAX_DISCOVERY_BYTES,
513
+ )
514
+ payload = _entry_info_payload(response.json(), entry_type)
515
+ except SchemaSourceError:
516
+ raise
517
+ except Exception as exc: # noqa: BLE001
518
+ skipped.append(f"{entry_type}:{type(exc).__name__}")
519
+ continue
520
+ if payload is None or not isinstance(payload.get("properties"), dict):
521
+ skipped.append(f"{entry_type}:invalid_info")
522
+ continue
523
+ entries[entry_type] = payload
524
+
525
+ parsed = urlparse(versioned_base_url)
526
+ inferred_name = context.name or _slug(parsed.hostname or "optimade")
527
+ tool = _tool_from_discovery(
528
+ name=inferred_name,
529
+ namespace=context.namespace,
530
+ versioned_base_url=versioned_base_url,
531
+ base_info=base_info,
532
+ entries=entries,
533
+ skipped=skipped,
534
+ )
535
+ invoker = OPTIMADERemoteInvoker(
536
+ tool,
537
+ versioned_base_url,
538
+ trusted_headers=context.trusted_headers,
539
+ timeout=context.timeout,
540
+ http_client=context.http_client,
541
+ )
542
+ return AdapterLoadResult(tool=tool, invoker=invoker)
543
+ finally:
544
+ if owns_client:
545
+ await client.aclose()
546
+
547
+
548
+ class OPTIMADERemoteInvoker:
549
+ """Call-aware OPTIMADE invoker that maps planned fields to response_fields."""
550
+
551
+ projects_fields = True
552
+
553
+ def __init__(
554
+ self,
555
+ tool: ToolSpec,
556
+ versioned_base_url: str,
557
+ *,
558
+ trusted_headers: dict[str, str] | None = None,
559
+ timeout: float = 20.0,
560
+ http_client: httpx.AsyncClient | None = None,
561
+ ) -> None:
562
+ self.tool = tool
563
+ self.base_url = _safe_base_url(versioned_base_url)
564
+ self.trusted_headers = dict(trusted_headers or {})
565
+ self.timeout = timeout
566
+ self.http_client = http_client
567
+
568
+ async def invoke_call(self, call: ToolCall) -> Any:
569
+ endpoint = self.tool.endpoint(call.endpoint)
570
+ entry_type = _validate_entry_type(str(endpoint.metadata["entry_type"]))
571
+ mode = str(endpoint.metadata["mode"])
572
+
573
+ arguments = dict(call.arguments)
574
+ query = {
575
+ key: value
576
+ for key, value in arguments.items()
577
+ if key != "id"
578
+ }
579
+ selected_fields = [
580
+ field
581
+ for field in call.fields
582
+ if field not in {"id", "type"}
583
+ ]
584
+ if selected_fields:
585
+ query["response_fields"] = ",".join(selected_fields)
586
+ if mode == "search":
587
+ query.setdefault("page_limit", 20)
588
+ url = f"{self.base_url}/{entry_type}"
589
+ elif mode == "get":
590
+ entry_id = arguments.get("id")
591
+ if not isinstance(entry_id, str) or not entry_id:
592
+ raise RuntimeError("OPTIMADE get endpoint requires a non-empty id")
593
+ url = f"{self.base_url}/{entry_type}/{quote(entry_id, safe='')}"
594
+ else:
595
+ raise RuntimeError(f"unknown OPTIMADE endpoint mode: {mode!r}")
596
+
597
+ owns_client = self.http_client is None
598
+ client = self.http_client or httpx.AsyncClient(
599
+ timeout=self.timeout,
600
+ follow_redirects=False,
601
+ )
602
+ try:
603
+ response = await _bounded_get(
604
+ client,
605
+ url,
606
+ headers=self.trusted_headers,
607
+ params=query or None,
608
+ max_bytes=_MAX_RESPONSE_BYTES,
609
+ )
610
+ payload = response.json()
611
+ finally:
612
+ if owns_client:
613
+ await client.aclose()
614
+
615
+ data = payload.get("data") if isinstance(payload, dict) else None
616
+ if mode == "search":
617
+ if not isinstance(data, list):
618
+ raise RuntimeError("OPTIMADE listing response must contain a data list")
619
+ return [
620
+ self._flatten_entry(item, call.fields)
621
+ for item in data
622
+ ]
623
+
624
+ if not isinstance(data, dict):
625
+ raise RuntimeError("OPTIMADE single-entry response must contain a data object")
626
+ return self._flatten_entry(data, call.fields)
627
+
628
+ @staticmethod
629
+ def _flatten_entry(item: Any, fields: list[str]) -> dict[str, Any]:
630
+ if not isinstance(item, dict):
631
+ raise RuntimeError("OPTIMADE entry must be an object")
632
+ attributes = item.get("attributes")
633
+ if not isinstance(attributes, dict):
634
+ attributes = {}
635
+
636
+ value: dict[str, Any] = {
637
+ "id": item.get("id"),
638
+ "type": item.get("type"),
639
+ **attributes,
640
+ }
641
+ requested = set(fields)
642
+ missing = sorted(
643
+ field
644
+ for field in requested
645
+ if field not in value
646
+ )
647
+ if missing:
648
+ raise RuntimeError(
649
+ "OPTIMADE provider omitted requested response fields: "
650
+ + ", ".join(missing)
651
+ )
652
+ if not requested:
653
+ return value
654
+
655
+ keep = requested | {"id", "type"}
656
+ return {key: value[key] for key in value if key in keep}