vgi-python 0.44.0__py3-none-any.whl → 0.46.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.
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env python3
2
+ """Serve standard fixtures with contents_probe published through a local HTTP object store.
3
+
4
+ Run with the normal fixture worker arguments (stdio, --http, --unix). Object
5
+ storage lives for this worker's lifetime; durable S3 restart coverage lives in
6
+ tests/catalog/test_catalog_publication.py. Set VGI_CATALOG_PUBLICATION_PORT to
7
+ inspect /stats from a separate process. Only use this local fixture in tests.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import sys
15
+ import threading
16
+ import uuid
17
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
+ from typing import Any
19
+
20
+ import pyarrow as pa
21
+ from vgi_rpc.external import ClientExternalConfig, Compression
22
+
23
+ from vgi._test_fixtures.catalog_contents import ContentsProbeCatalog
24
+ from vgi.catalog.snapshot import CatalogSnapshotPolicy, MemoryCatalogPublicationRepository, SnapshotIdentity
25
+
26
+
27
+ class _Storage:
28
+ def __init__(self) -> None:
29
+ self.objects: dict[str, tuple[bytes, str | None]] = {}
30
+ self.gets = 0
31
+ self.uploads = 0
32
+ self.base = ""
33
+
34
+ def upload(self, data: bytes, schema: pa.Schema, *, content_encoding: str | None = None) -> str:
35
+ del schema
36
+ key = "/" + uuid.uuid4().hex
37
+ self.objects[key] = data, content_encoding
38
+ self.uploads += 1
39
+ return self.base + key
40
+
41
+
42
+ def main() -> None:
43
+ """Configure publication before invoking the ordinary fixture CLI."""
44
+ storage = _Storage()
45
+
46
+ class Handler(BaseHTTPRequestHandler):
47
+ def do_OPTIONS(self) -> None:
48
+ self.send_response(204)
49
+ self.send_header("Access-Control-Allow-Origin", "*")
50
+ self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
51
+ self.send_header("Access-Control-Allow-Headers", "X-VGI-Accept-Encoding, Content-Type")
52
+ self.send_header("Cross-Origin-Resource-Policy", "cross-origin")
53
+ self.end_headers()
54
+
55
+ def do_GET(self) -> None:
56
+ if self.path == "/stats":
57
+ data = json.dumps({"uploads": storage.uploads, "object_gets": storage.gets}).encode()
58
+ encoding = None
59
+ elif self.path in storage.objects:
60
+ storage.gets += 1
61
+ data, encoding = storage.objects[self.path]
62
+ else:
63
+ self.send_error(404)
64
+ return
65
+ self.send_response(200)
66
+ self.send_header("Access-Control-Allow-Origin", "*")
67
+ self.send_header("Cross-Origin-Resource-Policy", "cross-origin")
68
+ self.send_header("Content-Length", str(len(data)))
69
+ if encoding:
70
+ self.send_header("Content-Encoding", encoding)
71
+ self.end_headers()
72
+ self.wfile.write(data)
73
+
74
+ def log_message(self, *args: Any) -> None:
75
+ pass
76
+
77
+ server = ThreadingHTTPServer(("127.0.0.1", int(os.environ.get("VGI_CATALOG_PUBLICATION_PORT", "0"))), Handler)
78
+ storage.base = f"http://127.0.0.1:{server.server_port}"
79
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
80
+ thread.start()
81
+ config = ClientExternalConfig(max_retries=0, url_validator=None)
82
+ ContentsProbeCatalog.catalog_snapshot_policy = CatalogSnapshotPolicy(
83
+ delivery="published",
84
+ repository=MemoryCatalogPublicationRepository(storage, profile="fixture", stable_urls=True),
85
+ identity=lambda request: SnapshotIdentity(
86
+ catalog_namespace="contents_probe",
87
+ source_revision="fixture-2.2",
88
+ visibility_scope="public-fixture",
89
+ catalog_version=request.catalog_version,
90
+ ),
91
+ compression=Compression(algorithm="gzip"),
92
+ fetch_config=config,
93
+ )
94
+ print(f"Catalog publication fixture storage: {storage.base}", file=sys.stderr)
95
+ try:
96
+ from vgi._test_fixtures.worker import main as fixture_main
97
+
98
+ fixture_main()
99
+ finally:
100
+ server.shutdown()
101
+ server.server_close()
102
+ thread.join()
103
+ config.fetch_config.close()
104
+
105
+
106
+ if __name__ == "__main__":
107
+ main()
@@ -193,7 +193,7 @@ def main() -> None:
193
193
  "--identity",
194
194
  envvar="VGI_FIXTURE_IDENTITY",
195
195
  help=(
196
- "Host vgi_rpc.Identity.v1 with the vgi-rpc conformance fixture policy and its "
196
+ "Host vgi_rpc.Identity.v2 with the vgi-rpc conformance fixture policy and its "
197
197
  "spoofable X-Conformance-Principal header authentication. Test-only; for "
198
198
  "vgi-rpc-test-hosted --identity."
199
199
  ),
@@ -1,6 +1,6 @@
1
1
  # Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
2
 
3
- """Opt the HTTP fixture into ``vgi_rpc.Identity.v1`` with the conformance policy.
3
+ """Opt the HTTP fixture into ``vgi_rpc.Identity.v2`` with the conformance policy.
4
4
 
5
5
  Enabled by ``vgi-fixture-http --identity`` (or ``VGI_FIXTURE_IDENTITY=1``) so
6
6
  the cross-SDK hosted-protocols group can run its identity cases with
@@ -168,7 +168,6 @@ class NarrowBindCatalog(ReadOnlyCatalogInterface):
168
168
  for i, info in enumerate(infos):
169
169
  if info.path == ["main"]:
170
170
  infos[i] = SchemaInfo(
171
- attach_opaque_data=info.attach_opaque_data,
172
171
  path=list(info.path),
173
172
  comment=info.comment,
174
173
  tags=info.tags,
@@ -381,7 +381,6 @@ class ProjReproCatalog(ReadOnlyCatalogInterface):
381
381
  for i, info in enumerate(infos):
382
382
  if info.path == ["main"]:
383
383
  infos[i] = SchemaInfo(
384
- attach_opaque_data=info.attach_opaque_data,
385
384
  path=list(info.path),
386
385
  comment=info.comment,
387
386
  tags=info.tags,
@@ -544,7 +544,6 @@ class SchemaReconcileCatalog(ReadOnlyCatalogInterface):
544
544
  for i, info in enumerate(infos):
545
545
  if info.path == [_SCHEMA_NAME]:
546
546
  infos[i] = SchemaInfo(
547
- attach_opaque_data=info.attach_opaque_data,
548
547
  path=list(info.path),
549
548
  comment=info.comment,
550
549
  tags=info.tags,
@@ -566,7 +566,6 @@ class WritableCatalog(ReadOnlyCatalogInterface):
566
566
  # default of 1 (unknown / eager-load), which restores correct lookups.
567
567
  static_schemas = [
568
568
  SchemaInfo(
569
- attach_opaque_data=s.attach_opaque_data,
570
569
  path=list(s.path),
571
570
  comment=s.comment,
572
571
  tags=s.tags,
vgi/attach_ticket.py CHANGED
@@ -3,7 +3,7 @@
3
3
  r"""Attach tickets: a user's ATTACH, sealed so a runner can replay it later as that user.
4
4
 
5
5
  A ticket is the *what* half of an unattended session; a sealed grant
6
- (``vgi_rpc.Identity.v1`` ``issue_grant``) is the *who*. While the user is
6
+ (``vgi_rpc.Identity.v2`` ``issue_grant``) is the *who*. While the user is
7
7
  attached and logged in, the client asks the worker to seal the options it
8
8
  attached with -- secret ones included -- into a ticket only this worker can
9
9
  open. Later any runner holding the user's grant attaches with the single option
@@ -43,7 +43,6 @@ from __future__ import annotations
43
43
 
44
44
  import base64
45
45
  import dataclasses
46
- import math
47
46
  import os
48
47
  import re
49
48
  import secrets
@@ -51,6 +50,7 @@ import struct
51
50
  import sys
52
51
  import time
53
52
  from collections.abc import Mapping, Sequence
53
+ from datetime import UTC, datetime
54
54
  from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Protocol
55
55
 
56
56
  import pyarrow as pa
@@ -553,13 +553,20 @@ class AttachTicket(ArrowSerializableDataclass):
553
553
 
554
554
  Attributes:
555
555
  ticket: ``vgia1.`` text. Not a credential, but never logged.
556
- expires_at: Unix seconds after which the worker refuses it; ``+inf``
557
- when the worker sets no maximum lifetime.
556
+ expires_at: The UTC instant after which the worker refuses it, carried
557
+ as ``timestamp[us, tz="UTC"]``; ``None`` (null on the wire) when
558
+ the worker sets no maximum lifetime. Protocol ``1.0.0`` carried a
559
+ float64 of unix seconds with ``+inf`` for "no expiry".
558
560
 
559
561
  """
560
562
 
561
563
  ticket: str
562
- expires_at: float
564
+ expires_at: Annotated[datetime | None, ArrowType(pa.timestamp("us", tz="UTC"))]
565
+
566
+ def __post_init__(self) -> None:
567
+ """Refuse a naive ``expires_at``, which has no defined instant."""
568
+ if self.expires_at is not None and (self.expires_at.tzinfo is None or self.expires_at.utcoffset() is None):
569
+ raise ValueError("AttachTicket.expires_at must be timezone-aware (UTC) or None")
563
570
 
564
571
 
565
572
  class AttachTickets(Protocol):
@@ -571,7 +578,7 @@ class AttachTickets(Protocol):
571
578
  """
572
579
 
573
580
  protocol_name: ClassVar[str] = ATTACH_TICKETS_PROTOCOL_NAME
574
- protocol_version: ClassVar[str] = "1.0.0"
581
+ protocol_version: ClassVar[str] = "2.0.0"
575
582
 
576
583
  def seal_attach(self, request: SealAttachRequest) -> AttachTicket:
577
584
  """Seal the caller's attach of ``request.catalog_name`` into a ticket.
@@ -730,4 +737,6 @@ class AttachTicketsImpl:
730
737
  )
731
738
  except ValueError as exc:
732
739
  raise _invalid_request("seal_attach request is invalid", [("request", str(exc))]) from exc
733
- return AttachTicket(ticket=token, expires_at=math.inf if expires_at == 0 else float(expires_at))
740
+ return AttachTicket(
741
+ ticket=token, expires_at=None if expires_at == 0 else datetime.fromtimestamp(expires_at, tz=UTC)
742
+ )
vgi/auth.py CHANGED
@@ -24,7 +24,7 @@ from typing import TYPE_CHECKING, Any
24
24
 
25
25
  from vgi_rpc.rpc import AuthContext, CallContext
26
26
 
27
- # ``vgi_rpc.Identity.v1`` lives at the RPC layer as of vgi-rpc 0.46.0 -- it was
27
+ # ``vgi_rpc.Identity.v2`` lives at the RPC layer as of vgi-rpc 0.46.0 -- it was
28
28
  # an HTTP JSON route (``POST {prefix}/__introspect_token__``) through 0.45.x,
29
29
  # which meant it existed on exactly one transport. So this is no longer gated
30
30
  # on the ``http`` extra. Still not re-exported by ``vgi_rpc.rpc`` itself, so
@@ -10,7 +10,7 @@ import dataclasses
10
10
  import threading
11
11
  from abc import ABC, abstractmethod
12
12
  from collections.abc import Mapping, Sequence
13
- from dataclasses import dataclass, field
13
+ from dataclasses import InitVar, dataclass, field
14
14
  from datetime import datetime
15
15
  from enum import Enum
16
16
  from typing import (
@@ -32,6 +32,7 @@ if TYPE_CHECKING:
32
32
  from vgi.catalog.descriptors import Catalog, Index, Macro, Schema, Table, View
33
33
  from vgi.catalog.secret_type import SecretTypeSpec
34
34
  from vgi.catalog.setting import SettingSpec
35
+ from vgi.catalog.snapshot import CatalogSnapshotPolicy
35
36
 
36
37
  import pyarrow as pa
37
38
  from vgi_rpc import ArrowSerializableDataclass, ArrowType
@@ -361,8 +362,10 @@ class SchemaInfo(CatalogObject, ArrowSerializableDataclass):
361
362
  """Information about a schema in a catalog.
362
363
 
363
364
  Attributes:
364
- attach_opaque_data: The unique id for the attached catalog.
365
365
  path: Raw identifier components from the outermost schema to this schema.
366
+ attach_opaque_data: Deprecated constructor-only compatibility argument.
367
+ Discarded and never serialized; attachment state belongs to
368
+ ``CatalogAttachResult`` and subsequent request envelopes.
366
369
  estimated_object_count: Approximate population per object kind, keyed by
367
370
  the same names the C++ extension uses for its set-cache
368
371
  instrumentation: ``"table"``, ``"view"``, ``"scalar_function"``,
@@ -388,8 +391,9 @@ class SchemaInfo(CatalogObject, ArrowSerializableDataclass):
388
391
  current view does not.
389
392
  """
390
393
 
391
- attach_opaque_data: AttachOpaqueData
392
394
  path: SchemaPath
395
+ # Constructor-only compatibility for existing catalog authors; never metadata.
396
+ attach_opaque_data: InitVar[AttachOpaqueData | None] = field(default=None, kw_only=True)
393
397
  estimated_object_count: dict[str, int] | None = None
394
398
 
395
399
 
@@ -800,11 +804,11 @@ class FunctionInfo(CatalogSchemaObject, ArrowSerializableDataclass):
800
804
 
801
805
  function_type: FunctionType
802
806
 
803
- arguments: SerializedSchema
807
+ arguments: Annotated[SerializedSchema, ArrowType(pa.dictionary(pa.int32(), pa.binary()))]
804
808
 
805
- output_schema: SerializedSchema
809
+ output_schema: Annotated[SerializedSchema, ArrowType(pa.dictionary(pa.int32(), pa.binary()))]
806
810
 
807
- parameter_default_values: Annotated[pa.RecordBatch | None, ArrowType(pa.binary())] = None
811
+ parameter_default_values: Annotated[pa.RecordBatch | None, ArrowType(pa.dictionary(pa.int32(), pa.binary()))] = None
808
812
 
809
813
  stability: FunctionStability | None = None
810
814
  null_handling: NullHandling | None = None
@@ -1754,6 +1758,9 @@ class CatalogInterface(ABC):
1754
1758
  #: ``False``.
1755
1759
  catalog_contents_attach_independent: ClassVar[bool] = False
1756
1760
 
1761
+ #: Optional framework publication policy; ordinary delivery when unset.
1762
+ catalog_snapshot_policy: ClassVar["CatalogSnapshotPolicy | None"] = None
1763
+
1757
1764
  @property
1758
1765
  def interface_feature_flags(self) -> set[str]:
1759
1766
  """Get the feature flags supported by this [`CatalogInterface`][].
@@ -0,0 +1,223 @@
1
+ """Catalog-wide metadata collections for the 2.2 wire contract.
2
+
3
+ The author-facing inventory stays grouped by schema. Collection encoders reuse
4
+ the ordinary record conversion, and readers consume every row of every batch.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ from collections.abc import Iterable, Sequence
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ import pyarrow as pa
14
+ from vgi_rpc import ArrowSerializableDataclass
15
+ from vgi_rpc.utils import new_ipc_stream
16
+
17
+ from vgi.catalog.catalog_interface import (
18
+ FunctionInfo,
19
+ FunctionType,
20
+ IndexInfo,
21
+ MacroInfo,
22
+ MacroType,
23
+ SchemaContentsInfo,
24
+ SchemaInfo,
25
+ TableInfo,
26
+ ViewInfo,
27
+ )
28
+ from vgi.schema_path import schema_path_key
29
+
30
+ if TYPE_CHECKING:
31
+ from vgi.protocol import CatalogContentsResponse
32
+
33
+ CATEGORY_TYPES: dict[str, type[Any]] = {
34
+ "schemas": SchemaInfo,
35
+ "tables": TableInfo,
36
+ "views": ViewInfo,
37
+ "functions": FunctionInfo,
38
+ "macros": MacroInfo,
39
+ "indexes": IndexInfo,
40
+ }
41
+
42
+
43
+ def encode_collection[T: ArrowSerializableDataclass](
44
+ record_type: type[T], records: Iterable[T | bytes], *, batch_size: int = 1024
45
+ ) -> bytes | None:
46
+ """Encode records directly, accepting legacy singleton IPC bytes as an adapter.
47
+
48
+ Each batch builds its own dictionaries; IPC dictionary replacements are
49
+ supported. Raw IPC byte strings are never concatenated.
50
+ """
51
+ from vgi.protocol import _with_sorted_maps
52
+
53
+ if batch_size <= 0:
54
+ raise ValueError("batch_size must be positive")
55
+ sink = pa.BufferOutputStream()
56
+ rows: list[dict[str, object]] = []
57
+ count = 0
58
+ with new_ipc_stream(sink, record_type.ARROW_SCHEMA) as writer:
59
+ for record in records:
60
+ if isinstance(record, bytes):
61
+ decoded = decode_collection(record_type, record)
62
+ if len(decoded) != 1:
63
+ raise ValueError("legacy catalog item must contain exactly one row")
64
+ record = decoded[0]
65
+ if not isinstance(record, record_type):
66
+ raise TypeError(f"expected {record_type.__name__} catalog record")
67
+ rows.append(_with_sorted_maps(record)._to_row_dict())
68
+ count += 1
69
+ if len(rows) >= batch_size:
70
+ writer.write_batch(pa.RecordBatch.from_pylist(rows, schema=record_type.ARROW_SCHEMA))
71
+ rows.clear()
72
+ if rows:
73
+ writer.write_batch(pa.RecordBatch.from_pylist(rows, schema=record_type.ARROW_SCHEMA))
74
+ return sink.getvalue().to_pybytes() if count else None
75
+
76
+
77
+ def decode_collection[T: ArrowSerializableDataclass](record_type: type[T], data: bytes | None) -> list[T]:
78
+ """Validate one typed collection and decode all rows, including later batches."""
79
+ if data is None:
80
+ return []
81
+ result: list[T] = []
82
+ source = pa.BufferReader(data)
83
+ with pa.ipc.open_stream(source) as reader:
84
+ if not reader.schema.equals(record_type.ARROW_SCHEMA, check_metadata=False):
85
+ raise ValueError(f"catalog {record_type.__name__} collection has an incompatible schema")
86
+ for batch in reader:
87
+ batch.validate(full=True)
88
+ for column, field in zip(batch.columns, batch.schema, strict=True):
89
+ for value in column.to_pylist():
90
+ _validate_nullability(value, field, record_type.__name__)
91
+ result.extend(record_type.deserialize_from_batch(batch.slice(i, 1)) for i in range(batch.num_rows))
92
+ if source.tell() != len(data):
93
+ raise ValueError("catalog collection has trailing bytes after its IPC stream")
94
+ return result
95
+
96
+
97
+ def _validate_nullability(value: Any, field: pa.Field[pa.DataType], path: str) -> None:
98
+ if value is None:
99
+ if not field.nullable:
100
+ raise ValueError(f"catalog {path}.{field.name} contains nulls")
101
+ return
102
+ dtype = field.type
103
+ if pa.types.is_dictionary(dtype):
104
+ return
105
+ if pa.types.is_struct(dtype):
106
+ for child in dtype:
107
+ _validate_nullability(value.get(child.name), child, path + "." + field.name)
108
+ elif pa.types.is_list(dtype) or pa.types.is_large_list(dtype) or pa.types.is_fixed_size_list(dtype):
109
+ for item in value:
110
+ _validate_nullability(item, dtype.value_field, path + "." + field.name)
111
+ elif pa.types.is_map(dtype):
112
+ for key, item in value:
113
+ _validate_nullability(key, dtype.key_field, path + "." + field.name)
114
+ _validate_nullability(item, dtype.item_field, path + "." + field.name)
115
+
116
+
117
+ def encode_inventory(infos: Sequence[SchemaContentsInfo]) -> dict[str, bytes | None]:
118
+ """Validate schema membership and encode all six categories across schemas."""
119
+ ordered = sorted(infos, key=lambda c: len(c.schema.path))
120
+ paths = [schema_path_key(c.schema.path) for c in ordered]
121
+ if len(paths) != len(set(paths)):
122
+ raise ValueError("catalog_contents returned duplicate schema paths")
123
+ for path in paths:
124
+ if len(path) > 1 and path[:-1] not in paths:
125
+ raise ValueError(f"catalog_contents returned schema path {list(path)!r} without its parent")
126
+ grouped: dict[str, list[Any]] = {kind: [] for kind in CATEGORY_TYPES}
127
+ source_kinds = {
128
+ "tables": "tables",
129
+ "views": "views",
130
+ "indexes": "indexes",
131
+ "scalar_functions": "functions",
132
+ "aggregate_functions": "functions",
133
+ "table_functions": "functions",
134
+ "scalar_macros": "macros",
135
+ "table_macros": "macros",
136
+ }
137
+ for info in ordered:
138
+ grouped["schemas"].append(info.schema)
139
+ for source, target in source_kinds.items():
140
+ for item in getattr(info, source):
141
+ if isinstance(item, bytes):
142
+ decoded = decode_collection(CATEGORY_TYPES[target], item)
143
+ if len(decoded) != 1:
144
+ raise ValueError("legacy catalog item must contain exactly one row")
145
+ item = decoded[0]
146
+ expected_types: dict[str, set[Any]] = {
147
+ "scalar_functions": {FunctionType.SCALAR},
148
+ "aggregate_functions": {FunctionType.AGGREGATE},
149
+ "table_functions": {FunctionType.TABLE, FunctionType.TABLE_BUFFERING},
150
+ "scalar_macros": {MacroType.SCALAR},
151
+ "table_macros": {MacroType.TABLE},
152
+ }
153
+ expected = expected_types.get(source)
154
+ actual = getattr(item, "function_type", getattr(item, "macro_type", None))
155
+ if expected is not None and actual not in expected:
156
+ raise ValueError(f"catalog_contents {source} item has the wrong subtype")
157
+ if schema_path_key(item.schema_path) != schema_path_key(info.schema.path):
158
+ raise ValueError(f"catalog_contents {source} item belongs to a different schema")
159
+ grouped[target].append(item)
160
+ return {kind: encode_collection(cls, grouped[kind]) for kind, cls in CATEGORY_TYPES.items()}
161
+
162
+
163
+ def decode_inventory(
164
+ response: CatalogContentsResponse, *, if_none_match: str | None = None
165
+ ) -> list[SchemaContentsInfo]:
166
+ """Validate the response and restore the SDK's grouped inventory."""
167
+ if response.not_modified:
168
+ if response.etag is None or response.etag != if_none_match:
169
+ raise ValueError("catalog_contents not_modified requires a matching non-null ETag")
170
+ if any(getattr(response, kind) is not None for kind in CATEGORY_TYPES):
171
+ raise ValueError("catalog_contents not_modified must have null categories")
172
+ return []
173
+ schemas = decode_collection(SchemaInfo, response.schemas)
174
+ result: dict[tuple[str, ...], SchemaContentsInfo] = {}
175
+ for schema in schemas:
176
+ path = schema_path_key(schema.path)
177
+ if path in result:
178
+ raise ValueError("catalog_contents returned duplicate schema paths")
179
+ if len(path) > 1 and path[:-1] not in result:
180
+ raise ValueError("catalog_contents schema parent must precede its child")
181
+ result[path] = SchemaContentsInfo(
182
+ schema=schema,
183
+ tables=[],
184
+ views=[],
185
+ scalar_functions=[],
186
+ aggregate_functions=[],
187
+ table_functions=[],
188
+ scalar_macros=[],
189
+ table_macros=[],
190
+ indexes=[],
191
+ )
192
+ for category, cls in CATEGORY_TYPES.items():
193
+ if category == "schemas":
194
+ continue
195
+ for item in decode_collection(cls, getattr(response, category)):
196
+ path = schema_path_key(item.schema_path)
197
+ if path not in result:
198
+ raise ValueError(f"catalog_contents {category} references an unknown schema")
199
+ destination = category
200
+ if category == "functions":
201
+ destination = {
202
+ FunctionType.SCALAR: "scalar_functions",
203
+ FunctionType.AGGREGATE: "aggregate_functions",
204
+ FunctionType.TABLE: "table_functions",
205
+ FunctionType.TABLE_BUFFERING: "table_functions",
206
+ }[item.function_type]
207
+ elif category == "macros":
208
+ destination = {MacroType.SCALAR: "scalar_macros", MacroType.TABLE: "table_macros"}[item.macro_type]
209
+ getattr(result[path], destination).append(item)
210
+ return list(result.values())
211
+
212
+
213
+ def contents_digest(categories: dict[str, bytes | None]) -> str:
214
+ """Hash category identity and complete stream bytes with unambiguous lengths."""
215
+ digest = hashlib.sha256(b"vgi.catalog.contents.2.2\0")
216
+ for kind in CATEGORY_TYPES:
217
+ data = categories[kind]
218
+ digest.update(kind.encode() + b"\0")
219
+ digest.update(b"\0" if data is None else b"\1")
220
+ if data is not None:
221
+ digest.update(len(data).to_bytes(8, "little"))
222
+ digest.update(data)
223
+ return digest.hexdigest()
@@ -0,0 +1,22 @@
1
+ """Canonical fingerprint of the nested catalog wire contract for all SDKs."""
2
+
3
+ import hashlib
4
+ from functools import lru_cache
5
+
6
+ from vgi_rpc.rpc import rpc_methods
7
+ from vgi_rpc.rpc._protocol_hash import canonical_json, compute_protocol_hash
8
+ from vgi_rpc.rpc._type_tokens import schema_tokens
9
+
10
+ from vgi.catalog.contents_codec import CATEGORY_TYPES
11
+ from vgi.protocol import CatalogContentsResponse, VgiProtocol
12
+
13
+
14
+ @lru_cache(maxsize=1)
15
+ def catalog_contract_hash() -> str:
16
+ """Include nested binary schemas, which the RPC method hash alone cannot see."""
17
+ description = {
18
+ "rpc": compute_protocol_hash(VgiProtocol.protocol_name, rpc_methods(VgiProtocol)),
19
+ "response": schema_tokens(CatalogContentsResponse.ARROW_SCHEMA),
20
+ "categories": {kind: schema_tokens(cls.ARROW_SCHEMA) for kind, cls in CATEGORY_TYPES.items()},
21
+ }
22
+ return hashlib.sha256(b"vgi.catalog.contract.v1|" + canonical_json(description)).hexdigest()
@@ -899,6 +899,9 @@ class Schema:
899
899
  def to_schema_info(self, attach_opaque_data: AttachOpaqueData) -> SchemaInfo:
900
900
  """Convert to [`SchemaInfo`][] for catalog response.
901
901
 
902
+ ``attach_opaque_data`` is accepted for compatibility and discarded;
903
+ schema metadata does not contain attachment state.
904
+
902
905
  Populates ``estimated_object_count`` from the declared population so
903
906
  the C++ extension's eager-load gate can choose between bulk
904
907
  ``LoadEntries`` and per-name single-entry RPCs without an extra round
@@ -923,7 +926,6 @@ class Schema:
923
926
  for func in self.functions:
924
927
  function_counts[func.get_metadata().function_type] += 1
925
928
  return SchemaInfo(
926
- attach_opaque_data=attach_opaque_data,
927
929
  path=list(self.path),
928
930
  comment=self.comment,
929
931
  tags=dict(self.tags),