vgi-python 0.40.0__py3-none-any.whl → 0.41.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.
@@ -42,7 +42,9 @@ at least once (tables, a view, scalar / aggregate / table functions, scalar and
42
42
  table macros), split over two schemas (``main`` and ``extra``), so RPC
43
43
  accounting in ``vgi/test/sql/integration/catalog/catalog_contents*.test`` is
44
44
  deterministic. Served by ``vgi-fixture-worker`` and ``vgi-fixture-http``
45
- (MetaWorker), so the same tests run over every transport. Their functions
45
+ (MetaWorker), so the same tests run over every transport. Every other SDK's
46
+ fixture worker serves these six catalogs too, and the same ``.test`` files run
47
+ against each of them, so the semantics here are the cross-SDK contract. Their functions
46
48
  re-use the example catalog's classes (``main.double`` ...), so every worker
47
49
  here sets ``route_unattached_calls = False``: they are reachable only through
48
50
  their own attach, and an unattached ``main.double`` still routes to the
vgi/client/__init__.py CHANGED
@@ -45,7 +45,7 @@ from vgi.client.aggregate import (
45
45
  AggregateSession,
46
46
  AggregateStreamingSession,
47
47
  )
48
- from vgi.client.catalog_mixin import CatalogClientMixin, CatalogContents
48
+ from vgi.client.catalog_mixin import CatalogClientMixin, CatalogContents, CatalogSnapshot
49
49
  from vgi.client.client import Client, ClientError, ResumableTableScan, ResumeUnsupported
50
50
 
51
51
  if TYPE_CHECKING:
@@ -57,6 +57,7 @@ __all__ = [
57
57
  "AggregateStreamingSession",
58
58
  "CatalogClientMixin",
59
59
  "CatalogContents",
60
+ "CatalogSnapshot",
60
61
  "Client",
61
62
  "ClientError",
62
63
  "OutputWriter",
@@ -42,7 +42,7 @@ import shlex
42
42
  import sys
43
43
  from collections.abc import Iterator, Sequence
44
44
  from contextlib import contextmanager
45
- from dataclasses import dataclass
45
+ from dataclasses import dataclass, replace
46
46
  from typing import Any, Literal, overload
47
47
 
48
48
  import pyarrow as pa
@@ -129,6 +129,55 @@ class CatalogContents:
129
129
  schemas: list[SchemaContentsInfo]
130
130
 
131
131
 
132
+ @dataclass(frozen=True)
133
+ class CatalogSnapshot:
134
+ """A whole catalog — every schema and everything in it — from [`CatalogClientMixin.load_catalog`][].
135
+
136
+ Hold on to it and pass it back as ``previous`` to revalidate: when it came
137
+ from ``catalog_contents`` with an etag, the next load sends
138
+ ``if_none_match`` and a ``not_modified`` answer returns this same content
139
+ without re-downloading it.
140
+
141
+ Attributes:
142
+ schemas: One [`SchemaContentsInfo`][] per schema, parents first.
143
+ catalog_version: The version the snapshot was taken at, or ``None``
144
+ when it was assembled from the per-schema RPCs (which carry none).
145
+ etag: The ``catalog_contents`` validator, or ``None`` when the worker
146
+ does not revalidate or the snapshot did not come from
147
+ ``catalog_contents``.
148
+ source: ``"catalog_contents"`` (one RPC) or ``"per_schema"``
149
+ (``catalog_schemas`` plus the per-schema ``catalog_schema_contents_*``
150
+ RPCs — the worker did not advertise ``catalog_contents``, the load
151
+ ran inside a transaction, or ``catalog_contents`` failed).
152
+ not_modified: ``True`` when this is ``previous`` confirmed current by a
153
+ ``not_modified`` answer.
154
+ fallback_reason: Why ``catalog_contents`` was advertised but not used
155
+ (its error message, or a protocol violation), else ``None``.
156
+ """
157
+
158
+ schemas: list[SchemaContentsInfo]
159
+ catalog_version: int | None
160
+ etag: str | None
161
+ source: Literal["catalog_contents", "per_schema"]
162
+ not_modified: bool = False
163
+ fallback_reason: str | None = None
164
+
165
+
166
+ # ``SchemaInfo.estimated_object_count`` key for each per-schema kind; a count of
167
+ # exactly 0 is a hard "none of this kind" guarantee, so the RPC is skipped (the
168
+ # same rule the DuckDB extension and ``CatalogInterface.catalog_contents`` apply).
169
+ _PER_SCHEMA_KINDS: tuple[tuple[str, SchemaObjectType, str], ...] = (
170
+ ("tables", SchemaObjectType.TABLE, "table"),
171
+ ("views", SchemaObjectType.VIEW, "view"),
172
+ ("scalar_functions", SchemaObjectType.SCALAR_FUNCTION, "scalar_function"),
173
+ ("aggregate_functions", SchemaObjectType.AGGREGATE_FUNCTION, "aggregate_function"),
174
+ ("table_functions", SchemaObjectType.TABLE_FUNCTION, "table_function"),
175
+ ("scalar_macros", SchemaObjectType.SCALAR_MACRO, "macro"),
176
+ ("table_macros", SchemaObjectType.TABLE_MACRO, "macro"),
177
+ ("indexes", SchemaObjectType.INDEX, "index"),
178
+ )
179
+
180
+
132
181
  class CatalogClientError(Exception):
133
182
  """Error raised by catalog operations."""
134
183
 
@@ -492,13 +541,123 @@ class CatalogClientMixin:
492
541
  one [`SchemaContentsInfo`][] per schema, parents first.
493
542
 
494
543
  """
544
+ # Decode inside the connection block so a malformed item surfaces as
545
+ # CatalogClientError (which load_catalog falls back on), not a bare
546
+ # decode error.
495
547
  with self._catalog_connect() as proxy:
496
548
  response = proxy.catalog_contents(attach_opaque_data=attach_opaque_data, if_none_match=if_none_match)
497
- return CatalogContents(
498
- catalog_version=response.catalog_version,
499
- etag=response.etag,
500
- not_modified=response.not_modified,
501
- schemas=[_decode_schema_contents(entry) for entry in response.schemas],
549
+ return CatalogContents(
550
+ catalog_version=response.catalog_version,
551
+ etag=response.etag,
552
+ not_modified=response.not_modified,
553
+ schemas=[_decode_schema_contents(entry) for entry in response.schemas],
554
+ )
555
+
556
+ def load_catalog(
557
+ self,
558
+ *,
559
+ attach: CatalogAttachResult,
560
+ previous: CatalogSnapshot | None = None,
561
+ transaction_opaque_data: TransactionOpaqueData | None = None,
562
+ ) -> CatalogSnapshot:
563
+ """Load every schema and all of its contents, in as few RPCs as the worker allows.
564
+
565
+ This is the client's whole-catalog enumeration. When the attach result
566
+ advertises ``supports_catalog_contents`` it is one ``catalog_contents``
567
+ call; otherwise — or inside a transaction, or when that call fails — it
568
+ is ``catalog_schemas`` plus the per-schema ``catalog_schema_contents_*``
569
+ RPCs (skipping a kind whose ``estimated_object_count`` is exactly 0).
570
+ ``catalog_contents`` is never sent to a worker that did not advertise it.
571
+
572
+ Revalidation: pass the snapshot from a previous load as ``previous``.
573
+ If it came from ``catalog_contents`` with an etag, the call sends
574
+ ``if_none_match``; a ``not_modified`` answer returns ``previous``'s
575
+ content (with ``not_modified=True`` and the current version), and a full
576
+ answer replaces it. A snapshot older than ``previous`` (a lagging
577
+ replica) is retried once, then the per-schema RPCs are used.
578
+
579
+ Args:
580
+ attach: The result of [`catalog_attach`][vgi.client.CatalogClientMixin.catalog_attach]
581
+ for this catalog.
582
+ previous: A snapshot already held, to revalidate instead of reload.
583
+ transaction_opaque_data: Load inside this transaction. ``catalog_contents``
584
+ returns only the committed catalog, so a transactional load always
585
+ uses the (transaction-aware) per-schema RPCs.
586
+
587
+ Returns:
588
+ A [`CatalogSnapshot`][]. ``source`` says which path served it and
589
+ ``fallback_reason`` why an advertised ``catalog_contents`` was not used.
590
+ """
591
+ attach_opaque_data = attach.attach_opaque_data
592
+ if not attach.supports_catalog_contents or transaction_opaque_data is not None:
593
+ return self._load_catalog_per_schema(attach_opaque_data, transaction_opaque_data, None)
594
+
595
+ if_none_match = previous.etag if previous is not None and previous.source == "catalog_contents" else None
596
+ known_version = previous.catalog_version if previous is not None else None
597
+ reason: str
598
+ for _attempt in range(2):
599
+ try:
600
+ contents = self.contents(attach_opaque_data=attach_opaque_data, if_none_match=if_none_match)
601
+ except CatalogClientError as e:
602
+ reason = str(e)
603
+ break
604
+ if contents.not_modified:
605
+ if previous is None or if_none_match is None or contents.schemas:
606
+ reason = "catalog_contents answered not_modified to a request it could not match"
607
+ break
608
+ return replace(
609
+ previous,
610
+ catalog_version=contents.catalog_version,
611
+ etag=contents.etag if contents.etag is not None else previous.etag,
612
+ not_modified=True,
613
+ )
614
+ # Versions are monotonic within a session; 0 means "unknown".
615
+ if known_version and contents.catalog_version and contents.catalog_version < known_version:
616
+ reason = (
617
+ f"catalog_contents returned version {contents.catalog_version}, "
618
+ f"older than the known version {known_version}"
619
+ )
620
+ continue
621
+ return CatalogSnapshot(
622
+ schemas=contents.schemas,
623
+ catalog_version=contents.catalog_version,
624
+ etag=contents.etag,
625
+ source="catalog_contents",
626
+ )
627
+ return self._load_catalog_per_schema(attach_opaque_data, None, reason)
628
+
629
+ def _load_catalog_per_schema(
630
+ self,
631
+ attach_opaque_data: AttachOpaqueData,
632
+ transaction_opaque_data: TransactionOpaqueData | None,
633
+ fallback_reason: str | None,
634
+ ) -> CatalogSnapshot:
635
+ """Assemble a snapshot from ``catalog_schemas`` and the per-schema RPCs."""
636
+ result: list[SchemaContentsInfo] = []
637
+ for schema in self.schemas(
638
+ attach_opaque_data=attach_opaque_data, transaction_opaque_data=transaction_opaque_data
639
+ ):
640
+ counts = schema.estimated_object_count or {}
641
+ kinds: dict[str, list[Any]] = {}
642
+ for attr, object_type, count_key in _PER_SCHEMA_KINDS:
643
+ if counts.get(count_key, 1) == 0:
644
+ kinds[attr] = []
645
+ continue
646
+ kinds[attr] = list(
647
+ self.schema_contents(
648
+ attach_opaque_data=attach_opaque_data,
649
+ transaction_opaque_data=transaction_opaque_data,
650
+ path=list(schema.path),
651
+ type=object_type,
652
+ )
653
+ )
654
+ result.append(SchemaContentsInfo(schema=schema, **kinds))
655
+ return CatalogSnapshot(
656
+ schemas=result,
657
+ catalog_version=None,
658
+ etag=None,
659
+ source="per_schema",
660
+ fallback_reason=fallback_reason,
502
661
  )
503
662
 
504
663
  def schema_get(
vgi/client/cli_catalog.py CHANGED
@@ -11,6 +11,7 @@ catalog
11
11
  ├── create <name> # Create a new catalog
12
12
  ├── drop <name> # Drop a catalog
13
13
  ├── version # Get catalog version (--attach-opaque-data or --catalog)
14
+ ├── contents # Every schema and its contents (catalog_contents when advertised)
14
15
  ├── schema # Schema operations
15
16
  │ ├── list/get/create/drop/contents
16
17
  ├── table # Table operations
@@ -163,6 +164,52 @@ def catalog_version(
163
164
  output_json({"version": version, "attach_opaque_data": bytes_to_hex(resolved_attach_opaque_data)})
164
165
 
165
166
 
167
+ @catalog.command("contents")
168
+ @click.option("--catalog", "catalog_name", required=True, help="Catalog name to attach")
169
+ @click.option("--attach-options", default="{}", help="Attach options as JSON")
170
+ @click.option("--worker", "-w", required=True, help="VGI worker command")
171
+ def catalog_contents(catalog_name: str, attach_options: str, worker: str) -> None:
172
+ """List every schema and the names of everything in it.
173
+
174
+ Uses one ``catalog_contents`` call when the catalog advertises it, else
175
+ ``catalog_schemas`` plus the per-schema RPCs (see ``Client.load_catalog``).
176
+ """
177
+ client = Client(worker)
178
+ attach = client.catalog_attach(
179
+ name=catalog_name,
180
+ options=parse_json_option(attach_options, "--attach-options"),
181
+ data_version_spec=None,
182
+ implementation_version=None,
183
+ )
184
+ snapshot = client.load_catalog(attach=attach)
185
+ kinds = (
186
+ "tables",
187
+ "views",
188
+ "scalar_functions",
189
+ "aggregate_functions",
190
+ "table_functions",
191
+ "scalar_macros",
192
+ "table_macros",
193
+ "indexes",
194
+ )
195
+ output_json(
196
+ {
197
+ "source": snapshot.source,
198
+ "catalog_version": snapshot.catalog_version,
199
+ "etag": snapshot.etag,
200
+ "fallback_reason": snapshot.fallback_reason,
201
+ "schemas": [
202
+ {
203
+ "path": list(entry.schema.path),
204
+ "comment": entry.schema.comment,
205
+ **{kind: [item.name for item in getattr(entry, kind)] for kind in kinds},
206
+ }
207
+ for entry in snapshot.schemas
208
+ ],
209
+ }
210
+ )
211
+
212
+
166
213
  # Add nested subcommand groups
167
214
  catalog.add_command(schema)
168
215
  catalog.add_command(table)
vgi/client/cli_utils.py CHANGED
@@ -389,6 +389,7 @@ def catalog_attach_result_to_dict(result: CatalogAttachResult) -> dict[str, Any]
389
389
  "catalog_version": result.catalog_version,
390
390
  "attach_opaque_data_required": result.attach_opaque_data_required,
391
391
  "default_schema": result.default_schema,
392
+ "supports_catalog_contents": result.supports_catalog_contents,
392
393
  "settings": [bytes_to_hex(s) for s in result.settings],
393
394
  "resolved_data_version": result.resolved_data_version,
394
395
  "resolved_implementation_version": result.resolved_implementation_version,
vgi/client/client.py CHANGED
@@ -54,6 +54,7 @@ Key methods
54
54
  client.catalog_attach(...) — open a catalog
55
55
  client.schemas(...) — list schemas
56
56
  client.schema_contents(...) — list tables/views/functions/macros
57
+ client.load_catalog(...) — whole catalog (catalog_contents when advertised)
57
58
  client.scalar_function(...) — invoke a scalar
58
59
  client.table_function(...) — invoke a table function
59
60
  client.table_function_plan(...) — plan a table function into named, redeemable splits
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vgi-python
3
- Version: 0.40.0
3
+ Version: 0.41.0
4
4
  Summary: Vector Gateway Interface - Connect DuckDB to external programs via Apache Arrow
5
5
  Project-URL: Homepage, https://query.farm
6
6
  Project-URL: Repository, https://github.com/Query-farm/vgi-python
@@ -45,7 +45,7 @@ vgi/_test_fixtures/bad_enum.py,sha256=b8l-afmXgGR6iqgIUiVeqHu0C0F8Of8ahEmdQGtqd1
45
45
  vgi/_test_fixtures/bad_protocol.py,sha256=fPU7rpXEgBxRtPHfuCa0O2AHNRRbZDllLfydHsAKPEg,2992
46
46
  vgi/_test_fixtures/cancellable.py,sha256=hetTRLyT4kkazPNcxAdfzj0pYzEmegMksamJbR0w3Ho,12151
47
47
  vgi/_test_fixtures/catalog.py,sha256=zjBKBgg4TKoDQ9SYIFj7sx6bOL3_aXHMJHSzTgk1fdA,28526
48
- vgi/_test_fixtures/catalog_contents.py,sha256=Hvt98vLMhdWwk9FvdtPx5GoZJ1ZLgiCFBA2NLEJohVY,11765
48
+ vgi/_test_fixtures/catalog_contents.py,sha256=C9vxHil2kmsqoELiTq2jZINBOwONJ2KIIYrreNMEN6c,11934
49
49
  vgi/_test_fixtures/companion.py,sha256=Ya_rg2J4tXOYC7zBFOjTTh-4AYuRhJN5BT4tVZULQ-Q,6037
50
50
  vgi/_test_fixtures/copy_from.py,sha256=YAWametIhrKE_K_kRwx37QpKMY0yBWAordE5-cNVn4M,6134
51
51
  vgi/_test_fixtures/copy_to.py,sha256=fFHxPYVd0wfAfMEvpeBRijqUasBeoAGREY3kHmm_oK8,9667
@@ -133,17 +133,17 @@ vgi/catalog/duckdb_statistics.py,sha256=Bzw4UGO-zdKrontaCcRV2palO8G3XLCWh-AhZozv
133
133
  vgi/catalog/secret_type.py,sha256=1DoEMyQun9K5z53817rUuhG1YpHSEAsazEmubkRLVL0,3745
134
134
  vgi/catalog/setting.py,sha256=06QfgaAR-0BKflIJ0du6PGqA5BPMdrkwr6u7f4nddII,1846
135
135
  vgi/catalog/storage.py,sha256=DhngzywbhQUfLF7NjFyiPp4Sw2ovOhcUXAuW3rkBQD8,11935
136
- vgi/client/__init__.py,sha256=SXS_95QN2c68ssxEWwSvS7yfyq_2ORtDpf5YqSEwC0w,2507
136
+ vgi/client/__init__.py,sha256=QOZGVkJWOfB8r4jjiwfU2q8Q97eWkLTnT6BK1jPZ2RE,2547
137
137
  vgi/client/aggregate.py,sha256=EAaI-4d1mnNjGORZvLf3wfVuu8RXBTFGVbMek0DeGDg,38482
138
- vgi/client/catalog_mixin.py,sha256=ft6S0vfhvG77qvXixF-7g5jjfDE9ClwL0TM2h0mvv_w,61691
138
+ vgi/client/catalog_mixin.py,sha256=CMUl4AUDCFDerRLc6mieZau1JCx9CIAbG_hIRHsZRJs,69620
139
139
  vgi/client/cli.py,sha256=nahuRCjday0z74Gk9zxOxIOJX9n6fHrP7-CGwJoyTzU,22754
140
- vgi/client/cli_catalog.py,sha256=twjlhHGl7n2mNN4jc7DnDRQr-Zj9KLAF2eATIQnITHQ,5577
140
+ vgi/client/cli_catalog.py,sha256=RVhSJLFKSBfVwLfEb_GLT8vnuliOwU6FzlsVUF7Rpsw,7308
141
141
  vgi/client/cli_schema.py,sha256=9G2vZiaAyqINItcDCoARdM04qNlDyJ3IXw3SuA6jgU8,7818
142
142
  vgi/client/cli_table.py,sha256=H1xrKZF5yYmSe55YSrLWXGIXPmuGuzQcncEBvqL43zs,26498
143
143
  vgi/client/cli_transaction.py,sha256=TDW0ZrBy8XM_eYSkYk5ewEsnozfzDJOlopd66Fm7OOE,3142
144
- vgi/client/cli_utils.py,sha256=_JoNiVu12NV4Pg2b1wtWFt0L0uIWJ9p9mBIfIu-QIuM,17387
144
+ vgi/client/cli_utils.py,sha256=ykh7XK-yzcW6WGED2_DhPLIVPSyVG3KUdllV5ztBicw,17458
145
145
  vgi/client/cli_view.py,sha256=0qhxoS91-zFMjq8V_Fb0PNW-YDxERu8-zPpxSFEE-G8,8490
146
- vgi/client/client.py,sha256=cvBQHzM2Z2lamC-fkBAvZD4HuRxB03aUkEKFVo_E9GU,167312
146
+ vgi/client/client.py,sha256=GSkcVXQzI0VCmeUf7WY4kR0ZqFtYmeb6EkenarBvX5c,167399
147
147
  vgi/client/errors.py,sha256=cSN65cyQMk3crwZCz1IdPErK3mtlEkTNpu-nuQUDOQE,1633
148
148
  vgi/http/__init__.py,sha256=hlOwOVcoZqmEKVGk7ganOdr5ryRSpEhLBF9sRD7BkYc,608
149
149
  vgi/http/demo_storage.py,sha256=830s-H61thozC3eEuqdnwCpYFfvoJRq9vjyuXa5OURo,8769
@@ -156,8 +156,8 @@ vgi/transactor/_duckdb_compat.py,sha256=sXVZ9JLKAQyGR1BjWczSwdQEavtr-TcZPoVZZnTr
156
156
  vgi/transactor/client.py,sha256=7DTeMksogsw6ANjQjGOPpKYrV76rg4_kGjktMJf54jg,4486
157
157
  vgi/transactor/protocol.py,sha256=v59IvrKnuvwOXvn_HEcBAbCzDVcx0akgKN0R1mChXg0,5034
158
158
  vgi/transactor/server.py,sha256=nzsZQxJZdgappdYX8okrFIrjFtUbdM_Hhskl1fZ2nDY,32553
159
- vgi_python-0.40.0.dist-info/METADATA,sha256=lw7UWSpVsGOISgdZ3GYe4Y0cQoL59BlTUsqiXlstBng,25953
160
- vgi_python-0.40.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
161
- vgi_python-0.40.0.dist-info/entry_points.txt,sha256=3Kz1vgodw3pOL_xjtSyDB55-ZRy-U2X-X_Bdr582x0Q,165
162
- vgi_python-0.40.0.dist-info/licenses/LICENSE,sha256=pbJb4zZasP6n5ifEV81wFu017TarjydaYVmGbHcehtY,6103
163
- vgi_python-0.40.0.dist-info/RECORD,,
159
+ vgi_python-0.41.0.dist-info/METADATA,sha256=cYzmCmk6poHPPaMQ9OiIidOEntlJdzlD_Nr-RLbjoP4,25953
160
+ vgi_python-0.41.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
161
+ vgi_python-0.41.0.dist-info/entry_points.txt,sha256=3Kz1vgodw3pOL_xjtSyDB55-ZRy-U2X-X_Bdr582x0Q,165
162
+ vgi_python-0.41.0.dist-info/licenses/LICENSE,sha256=pbJb4zZasP6n5ifEV81wFu017TarjydaYVmGbHcehtY,6103
163
+ vgi_python-0.41.0.dist-info/RECORD,,