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.
@@ -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,
@@ -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),