vgi-python 0.45.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.
- vgi/_test_fixtures/catalog_publication.py +107 -0
- vgi/_test_fixtures/narrow_bind/worker.py +0 -1
- vgi/_test_fixtures/projection_repro/worker.py +0 -1
- vgi/_test_fixtures/schema_reconcile/worker.py +0 -1
- vgi/_test_fixtures/writable/worker.py +0 -1
- vgi/catalog/catalog_interface.py +13 -6
- vgi/catalog/contents_codec.py +223 -0
- vgi/catalog/contract.py +22 -0
- vgi/catalog/descriptors.py +3 -1
- vgi/catalog/snapshot.py +680 -0
- vgi/client/catalog_mixin.py +7 -24
- vgi/client/client.py +14 -16
- vgi/protocol.py +21 -27
- vgi/protocol_version.txt +1 -1
- vgi/worker.py +116 -72
- {vgi_python-0.45.0.dist-info → vgi_python-0.46.0.dist-info}/METADATA +3 -1
- {vgi_python-0.45.0.dist-info → vgi_python-0.46.0.dist-info}/RECORD +20 -16
- {vgi_python-0.45.0.dist-info → vgi_python-0.46.0.dist-info}/WHEEL +0 -0
- {vgi_python-0.45.0.dist-info → vgi_python-0.46.0.dist-info}/entry_points.txt +0 -0
- {vgi_python-0.45.0.dist-info → vgi_python-0.46.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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()
|
|
@@ -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/catalog/catalog_interface.py
CHANGED
|
@@ -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()
|
vgi/catalog/contract.py
ADDED
|
@@ -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()
|
vgi/catalog/descriptors.py
CHANGED
|
@@ -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),
|