vgi-python 0.8.5__py3-none-any.whl → 0.8.7__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.
@@ -752,6 +752,7 @@ class InMemoryCatalog(CatalogInterface):
752
752
  definition: str,
753
753
  on_conflict: OnConflict,
754
754
  parameter_default_values: pa.RecordBatch | None = None,
755
+ arguments_schema: pa.Schema | None = None,
755
756
  ) -> None:
756
757
  """Create a new macro."""
757
758
  schema_data = self._get_schema(attach_opaque_data, schema_name)
@@ -773,6 +774,7 @@ class InMemoryCatalog(CatalogInterface):
773
774
  definition=definition,
774
775
  comment=None,
775
776
  tags={},
777
+ arguments_schema=arguments_schema,
776
778
  )
777
779
  )
778
780
  self._increment_version(attach_opaque_data)
@@ -519,6 +519,10 @@ _EXAMPLE_CATALOG = Catalog(
519
519
  parameters=["x", "y"],
520
520
  definition="x * y",
521
521
  comment="Multiply two values",
522
+ parameter_docs={
523
+ "x": "First factor",
524
+ "y": "Second factor",
525
+ },
522
526
  ),
523
527
  Macro(
524
528
  name="vgi_clamp",
@@ -530,6 +534,11 @@ _EXAMPLE_CATALOG = Catalog(
530
534
  ),
531
535
  definition="GREATEST(lo, LEAST(hi, val))",
532
536
  comment="Clamp a value between lo and hi (defaults: 0..100)",
537
+ parameter_docs={
538
+ "val": "Value to clamp",
539
+ "lo": "Lower bound (inclusive)",
540
+ "hi": "Upper bound (inclusive)",
541
+ },
533
542
  ),
534
543
  Macro(
535
544
  name="vgi_range_table",
@@ -537,6 +546,7 @@ _EXAMPLE_CATALOG = Catalog(
537
546
  parameters=["n"],
538
547
  definition="SELECT * FROM range(n)",
539
548
  comment="Table macro returning range of values",
549
+ parameter_docs={"n": "Number of rows to generate"},
540
550
  ),
541
551
  ],
542
552
  ),
vgi/argument_spec.py CHANGED
@@ -27,6 +27,8 @@ __all__ = [
27
27
  "ArgumentSpec",
28
28
  "argument_specs_to_schema",
29
29
  "extract_argument_specs",
30
+ "macro_arguments_schema",
31
+ "macro_parameter_docs_from_schema",
30
32
  "schema_to_argument_specs",
31
33
  # Metadata constants for parsing schemas
32
34
  "VGI_ARG_KEY",
@@ -280,6 +282,86 @@ def schema_to_argument_specs(schema: pa.Schema) -> list[ArgumentSpec]:
280
282
  return specs
281
283
 
282
284
 
285
+ # =============================================================================
286
+ # Macro Argument Schemas
287
+ # =============================================================================
288
+
289
+
290
+ def macro_arguments_schema(
291
+ parameters: Sequence[str],
292
+ parameter_default_values: pa.RecordBatch | None = None,
293
+ parameter_docs: dict[str, str] | None = None,
294
+ ) -> pa.Schema:
295
+ """Build a macro ``arguments_schema`` describing macro parameters.
296
+
297
+ Mirrors the function ``arguments_schema`` mechanism: one Arrow field per
298
+ macro parameter, in ``parameters`` order, each nullable. The per-parameter
299
+ description is carried via the same ``vgi_doc`` field-metadata key functions
300
+ use (UTF-8, presence-only — the key is omitted entirely when there is no
301
+ doc). A parameter's field type is the type of its default value when one is
302
+ known (from ``parameter_default_values``), else ``pa.null()``.
303
+
304
+ Args:
305
+ parameters: Ordered list of macro parameter names.
306
+ parameter_default_values: Optional one-row ``RecordBatch`` whose columns
307
+ are parameter names with typed default values; used to infer each
308
+ parameter's field type.
309
+ parameter_docs: Optional mapping of parameter name to description. Empty
310
+ or missing descriptions yield no ``vgi_doc`` metadata on the field.
311
+
312
+ Returns:
313
+ Arrow schema with one nullable field per parameter, in order.
314
+
315
+ """
316
+ docs = parameter_docs or {}
317
+
318
+ # Map parameter name -> Arrow type from the typed default values, if any.
319
+ default_types: dict[str, pa.DataType] = {}
320
+ if parameter_default_values is not None:
321
+ for default_field in parameter_default_values.schema:
322
+ default_types[default_field.name] = default_field.type
323
+
324
+ fields: list[pa.Field[Any]] = []
325
+ for name in parameters:
326
+ metadata: dict[bytes, bytes] = {}
327
+ doc = docs.get(name, "")
328
+ if doc:
329
+ metadata[VGI_DOC_KEY] = doc.encode("utf-8")
330
+
331
+ field = pa.field(
332
+ name,
333
+ default_types.get(name, pa.null()),
334
+ nullable=True,
335
+ metadata=metadata if metadata else None,
336
+ )
337
+ fields.append(field)
338
+
339
+ return pa.schema(fields)
340
+
341
+
342
+ def macro_parameter_docs_from_schema(schema: pa.Schema) -> dict[str, str]:
343
+ """Extract per-parameter descriptions from a macro ``arguments_schema``.
344
+
345
+ Inverse of [`macro_arguments_schema`][]'s ``vgi_doc`` handling: reads the
346
+ ``vgi_doc`` field metadata (UTF-8) for each field. Fields without the key
347
+ (undocumented) are omitted from the result.
348
+
349
+ Args:
350
+ schema: A macro ``arguments_schema`` (one field per parameter).
351
+
352
+ Returns:
353
+ Mapping of parameter name to description, for documented parameters only.
354
+
355
+ """
356
+ docs: dict[str, str] = {}
357
+ for field in schema:
358
+ metadata = field.metadata or {}
359
+ doc_bytes = metadata.get(VGI_DOC_KEY)
360
+ if doc_bytes:
361
+ docs[field.name] = doc_bytes.decode("utf-8")
362
+ return docs
363
+
364
+
283
365
  # =============================================================================
284
366
  # Extraction from Function Classes
285
367
  # =============================================================================
@@ -435,6 +435,15 @@ class MacroInfo(CatalogSchemaObject, ArrowSerializableDataclass):
435
435
  names and values are typed defaults. None if no defaults.
436
436
  Serialized as IPC bytes over the wire.
437
437
  definition: The SQL expression (scalar) or query (table).
438
+ arguments_schema: Optional Arrow schema (serialized as IPC bytes) with one
439
+ nullable field per parameter, in ``parameters`` order. Each field's type
440
+ is the parameter's default value type when known (else null), and the
441
+ ``vgi_doc`` field metadata key carries the parameter's description (UTF-8,
442
+ presence-only — omitted when undocumented). Mirrors the per-argument doc
443
+ channel functions expose via ``FunctionInfo.arguments``. None means the
444
+ worker did not supply per-parameter docs (older workers); the extension
445
+ falls back to ``parameters`` for names. Built with
446
+ ``vgi.argument_spec.macro_arguments_schema``.
438
447
 
439
448
  """
440
449
 
@@ -442,6 +451,7 @@ class MacroInfo(CatalogSchemaObject, ArrowSerializableDataclass):
442
451
  parameters: list[str]
443
452
  parameter_default_values: Annotated[pa.RecordBatch | None, ArrowType(pa.binary())] = None
444
453
  definition: str = ""
454
+ arguments_schema: Annotated[pa.Schema | None, ArrowType(pa.binary())] = None
445
455
 
446
456
 
447
457
  class FunctionType(Enum):
@@ -1979,8 +1989,25 @@ class CatalogInterface(ABC):
1979
1989
  definition: str,
1980
1990
  on_conflict: OnConflict,
1981
1991
  parameter_default_values: pa.RecordBatch | None = None,
1992
+ arguments_schema: pa.Schema | None = None,
1982
1993
  ) -> None:
1983
- """Create a new macro with the given definition."""
1994
+ """Create a new macro with the given definition.
1995
+
1996
+ Args:
1997
+ attach_opaque_data: Per-attach catalog session token.
1998
+ transaction_opaque_data: Optional transaction handle.
1999
+ schema_name: Schema to create the macro in.
2000
+ name: Name for the new macro.
2001
+ macro_type: Whether this is a scalar or table macro.
2002
+ parameters: Ordered list of parameter names.
2003
+ definition: SQL expression (scalar) or query (table).
2004
+ on_conflict: Behavior if the macro already exists.
2005
+ parameter_default_values: One-row ``RecordBatch`` with typed defaults.
2006
+ arguments_schema: Optional Arrow schema (one nullable field per
2007
+ parameter, in ``parameters`` order) carrying per-parameter
2008
+ descriptions via the ``vgi_doc`` field metadata key. ``None`` when
2009
+ no per-parameter docs are supplied.
2010
+ """
1984
2011
  raise NotImplementedError("Macro create not implemented.")
1985
2012
 
1986
2013
  def macro_drop(
@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING, Any, Union
18
18
 
19
19
  import pyarrow as pa
20
20
 
21
+ from vgi.argument_spec import macro_arguments_schema
21
22
  from vgi.arguments import Arguments
22
23
  from vgi.catalog.catalog_interface import (
23
24
  AttachOpaqueData,
@@ -695,6 +696,11 @@ class Macro:
695
696
  parameter_default_values: One-row `RecordBatch` where columns are parameter
696
697
  names and values are typed defaults. None if no defaults.
697
698
  Example: pa.RecordBatch.from_pydict({"b": [5]}) for b := 5.
699
+ parameter_docs: Optional mapping of parameter name to a human/agent-facing
700
+ description. Keys must appear in ``parameters``. Descriptions flow over
701
+ the wire via the macro ``arguments_schema``'s ``vgi_doc`` field metadata
702
+ (the same channel functions use), so the DuckDB extension's
703
+ ``vgi_function_arguments()`` can surface them. Empty/default = no docs.
698
704
  definition: SQL expression (scalar) or query (table).
699
705
  comment: Optional macro comment.
700
706
  tags: Optional metadata tags.
@@ -705,12 +711,14 @@ class Macro:
705
711
  macro_type: MacroType
706
712
  parameters: list[str] = field(default_factory=list)
707
713
  parameter_default_values: pa.RecordBatch | None = None
714
+ parameter_docs: dict[str, str] = field(default_factory=dict)
708
715
  definition: str = ""
709
716
  comment: str | None = None
710
717
  tags: dict[str, str] = field(default_factory=dict)
711
718
 
712
719
  def __post_init__(self) -> None:
713
720
  """Validate macro configuration."""
721
+ param_set = set(self.parameters)
714
722
  if self.parameter_default_values is not None:
715
723
  if self.parameter_default_values.num_rows != 1:
716
724
  raise ValueError(
@@ -718,13 +726,19 @@ class Macro:
718
726
  f"got {self.parameter_default_values.num_rows}"
719
727
  )
720
728
  # Validate that default param column names exist in parameters list
721
- param_set = set(self.parameters)
722
729
  for col_name in self.parameter_default_values.schema.names:
723
730
  if col_name not in param_set:
724
731
  raise ValueError(
725
732
  f"Macro '{self.name}': default parameter '{col_name}' not found "
726
733
  f"in parameters list {self.parameters}"
727
734
  )
735
+ # Validate that documented parameter names exist in parameters list
736
+ for doc_name in self.parameter_docs:
737
+ if doc_name not in param_set:
738
+ raise ValueError(
739
+ f"Macro '{self.name}': documented parameter '{doc_name}' not found "
740
+ f"in parameters list {self.parameters}"
741
+ )
728
742
 
729
743
  def to_macro_info(self, schema_name: str) -> MacroInfo:
730
744
  """Convert to [`MacroInfo`][] for catalog response."""
@@ -737,6 +751,11 @@ class Macro:
737
751
  definition=self.definition,
738
752
  comment=self.comment,
739
753
  tags=dict(self.tags),
754
+ arguments_schema=macro_arguments_schema(
755
+ self.parameters,
756
+ self.parameter_default_values,
757
+ self.parameter_docs,
758
+ ),
740
759
  )
741
760
 
742
761
 
@@ -90,12 +90,14 @@ class CatalogClientMixin:
90
90
  Catalog methods spawn ephemeral connections under the hood — for
91
91
  subprocess transport a pooled subprocess worker; for HTTP transport a
92
92
  short-lived ``http_connect`` session reusing the ``Client``'s shared
93
- ``httpx.Client`` (bearer token, headers). Browsing catalogs over HTTP
94
- is the canonical non-DuckDB use case this mixin supports.
93
+ ``httpx.Client`` (bearer token, headers); for TCP transport a short-lived
94
+ ``tcp_connect`` session. Browsing catalogs over HTTP is the canonical
95
+ non-DuckDB use case this mixin supports.
95
96
 
96
- Other attributes expected from ``Client``: ``_transport`` (subprocess vs
97
- http), ``_base_url`` (HTTP base URL), and ``_get_or_create_httpx_client()``
98
- (shared HTTP client factory).
97
+ Other attributes expected from ``Client``: ``_transport`` (subprocess,
98
+ http, or tcp), ``_base_url`` (HTTP base URL), ``_tcp_host`` / ``_tcp_port``
99
+ (TCP endpoint), and ``_get_or_create_httpx_client()`` (shared HTTP client
100
+ factory).
99
101
 
100
102
  Attributes:
101
103
  server_path: Worker shell command used for subprocess transport.
@@ -103,8 +105,10 @@ class CatalogClientMixin:
103
105
 
104
106
  # Type hints for attributes expected from Client
105
107
  server_path: str
106
- _transport: Literal["subprocess", "http"]
108
+ _transport: Literal["subprocess", "http", "tcp"]
107
109
  _base_url: str | None
110
+ _tcp_host: str | None
111
+ _tcp_port: int | None
108
112
  _external_location: Any | None
109
113
 
110
114
  def _get_or_create_httpx_client(self) -> Any: # implemented by Client
@@ -128,7 +132,8 @@ class CatalogClientMixin:
128
132
  A typed `[`VgiProtocol`][]` proxy bound to the active transport.
129
133
  """
130
134
  try:
131
- if getattr(self, "_transport", "subprocess") == "http":
135
+ transport = getattr(self, "_transport", "subprocess")
136
+ if transport == "http":
132
137
  from vgi_rpc.http import http_connect
133
138
 
134
139
  httpx_client = self._get_or_create_httpx_client()
@@ -139,6 +144,17 @@ class CatalogClientMixin:
139
144
  external_location=getattr(self, "_external_location", None),
140
145
  ) as proxy:
141
146
  yield proxy
147
+ elif transport == "tcp":
148
+ from vgi_rpc.rpc import tcp_connect
149
+
150
+ assert self._tcp_host is not None and self._tcp_port is not None
151
+ with tcp_connect(
152
+ VgiProtocol, # type: ignore[type-abstract]
153
+ self._tcp_host,
154
+ self._tcp_port,
155
+ external_location=getattr(self, "_external_location", None),
156
+ ) as proxy:
157
+ yield proxy
142
158
  else:
143
159
  cmd = shlex.split(self.server_path, posix=sys.platform != "win32")
144
160
  with _catalog_pool.connect(VgiProtocol, cmd) as proxy: # type: ignore[type-abstract]
@@ -1196,6 +1212,7 @@ class CatalogClientMixin:
1196
1212
  definition: str,
1197
1213
  on_conflict: OnConflict = OnConflict.ERROR,
1198
1214
  parameter_default_values: pa.RecordBatch | None = None,
1215
+ arguments_schema: pa.Schema | None = None,
1199
1216
  ) -> None:
1200
1217
  """Create a new macro.
1201
1218
 
@@ -1209,6 +1226,10 @@ class CatalogClientMixin:
1209
1226
  definition: SQL expression (scalar) or query (table).
1210
1227
  on_conflict: Behavior if macro already exists.
1211
1228
  parameter_default_values: One-row `RecordBatch` with typed defaults.
1229
+ arguments_schema: Optional Arrow schema (one nullable field per
1230
+ parameter, in ``parameters`` order) carrying per-parameter
1231
+ descriptions via the ``vgi_doc`` field metadata key. Build with
1232
+ ``vgi.argument_spec.macro_arguments_schema``.
1212
1233
 
1213
1234
  """
1214
1235
  with self._catalog_connect() as proxy:
@@ -1222,6 +1243,7 @@ class CatalogClientMixin:
1222
1243
  definition=definition,
1223
1244
  on_conflict=on_conflict,
1224
1245
  parameter_default_values=parameter_default_values,
1246
+ arguments_schema=arguments_schema,
1225
1247
  transaction_opaque_data=transaction_opaque_data,
1226
1248
  )
1227
1249
  )
vgi/client/client.py CHANGED
@@ -197,10 +197,10 @@ _HTTP_TRANSPORT_READY = True
197
197
 
198
198
  @dataclass
199
199
  class WorkerConnection:
200
- """Holds state for a single worker connection (subprocess or HTTP).
200
+ """Holds state for a single worker connection (subprocess, HTTP, or TCP).
201
201
 
202
- Exactly one of {proc+connection, _pool_ctx, _http_ctx} is active per
203
- connection — transport-specific teardown inspects these fields.
202
+ Exactly one of {proc+connection, _pool_ctx, _http_ctx, _tcp_ctx} is active
203
+ per connection — transport-specific teardown inspects these fields.
204
204
 
205
205
  Attributes:
206
206
  proxy: The typed `[`VgiProtocol`][]` proxy used to invoke the worker.
@@ -220,6 +220,8 @@ class WorkerConnection:
220
220
  _pool_ctx: AbstractContextManager[Any] | None = field(default=None, repr=False)
221
221
  # HTTP transport: context manager from vgi_rpc.http.http_connect.
222
222
  _http_ctx: AbstractContextManager[Any] | None = field(default=None, repr=False)
223
+ # TCP transport: context manager from vgi_rpc.rpc.tcp_connect.
224
+ _tcp_ctx: AbstractContextManager[Any] | None = field(default=None, repr=False)
223
225
 
224
226
 
225
227
  class Client(CatalogClientMixin):
@@ -381,8 +383,10 @@ class Client(CatalogClientMixin):
381
383
  attach_opaque_data: bytes | None = None,
382
384
  pool: WorkerPool | None = _default_pool,
383
385
  *,
384
- transport: Literal["subprocess", "http"] = "subprocess",
386
+ transport: Literal["subprocess", "http", "tcp"] = "subprocess",
385
387
  base_url: str | None = None,
388
+ tcp_host: str | None = None,
389
+ tcp_port: int | None = None,
386
390
  bearer_token: str | None = None,
387
391
  httpx_client: Any | None = None,
388
392
  external_location: Any | None = None,
@@ -413,9 +417,14 @@ class Client(CatalogClientMixin):
413
417
  management.
414
418
  transport: Which transport to use. ``"subprocess"`` (default)
415
419
  spawns a local subprocess per worker; ``"http"`` connects to
416
- a running worker via ``vgi_rpc.http.http_connect``.
420
+ a running worker via ``vgi_rpc.http.http_connect``; ``"tcp"``
421
+ connects to a running worker via ``vgi_rpc.rpc.tcp_connect``
422
+ (raw Arrow-IPC framing, no auth/encryption — loopback /
423
+ trusted networks only; use ``Client.from_tcp(...)``).
417
424
  base_url: HTTP-only. Base URL of the running worker, e.g.
418
425
  ``"http://127.0.0.1:8765"``.
426
+ tcp_host: TCP-only. Hostname or IP of the running worker.
427
+ tcp_port: TCP-only. Port of the running worker.
419
428
  bearer_token: HTTP-only. When set, every request carries an
420
429
  ``Authorization: Bearer <token>`` header. Static token
421
430
  support only — no JWT / OAuth flows.
@@ -446,12 +455,21 @@ class Client(CatalogClientMixin):
446
455
  raise ValueError("transport='http' requires base_url")
447
456
  if server_path is not None:
448
457
  raise ValueError("server_path is only meaningful for transport='subprocess'")
458
+ elif transport == "tcp":
459
+ if tcp_host is None or tcp_port is None:
460
+ raise ValueError("transport='tcp' requires tcp_host and tcp_port")
461
+ if server_path is not None:
462
+ raise ValueError("server_path is only meaningful for transport='subprocess'")
463
+ if base_url is not None:
464
+ raise ValueError("base_url is only meaningful for transport='http'")
449
465
  else:
450
466
  raise ValueError(f"unknown transport {transport!r}")
451
467
 
452
468
  self.server_path = server_path or ""
453
469
  self._transport = transport
454
470
  self._base_url = base_url
471
+ self._tcp_host = tcp_host
472
+ self._tcp_port = tcp_port
455
473
  self._bearer_token = bearer_token
456
474
  self._httpx_client = httpx_client
457
475
  # True when ``_get_or_create_httpx_client`` constructed the client and
@@ -460,7 +478,7 @@ class Client(CatalogClientMixin):
460
478
  self._httpx_client_owned = False
461
479
  # Auto-enable pointer-batch resolution for HTTP unless the caller
462
480
  # asked for something different. See ``external_location`` docs above.
463
- if transport == "http" and external_location is None:
481
+ if transport in ("http", "tcp") and external_location is None:
464
482
  from vgi_rpc.external import ExternalLocationConfig
465
483
 
466
484
  external_location = ExternalLocationConfig()
@@ -509,6 +527,34 @@ class Client(CatalogClientMixin):
509
527
  pool=None,
510
528
  )
511
529
 
530
+ @classmethod
531
+ def from_tcp(
532
+ cls,
533
+ host: str,
534
+ port: int,
535
+ *,
536
+ external_location: Any | None = None,
537
+ worker_limit: int | None = None,
538
+ attach_opaque_data: bytes | None = None,
539
+ ) -> Client:
540
+ """Create a `[`Client`][]` bound to a running TCP VGI worker.
541
+
542
+ Connects via ``vgi_rpc.rpc.tcp_connect`` (raw Arrow-IPC framing). The
543
+ framing carries **no authentication or encryption** — only connect to
544
+ trusted endpoints on loopback or a trusted network; use
545
+ ``Client.from_http(...)`` for untrusted networks. Spin up a matching
546
+ worker with ``vgi-fixture-worker --tcp [HOST:]PORT``.
547
+ """
548
+ return cls(
549
+ transport="tcp",
550
+ tcp_host=host,
551
+ tcp_port=port,
552
+ external_location=external_location,
553
+ worker_limit=worker_limit,
554
+ attach_opaque_data=attach_opaque_data,
555
+ pool=None,
556
+ )
557
+
512
558
  def _drain_stderr(self, stderr: IO[bytes]) -> None:
513
559
  """Background thread that continuously reads stderr.
514
560
 
@@ -577,8 +623,40 @@ class Client(CatalogClientMixin):
577
623
  """
578
624
  if self._transport == "http":
579
625
  return self._spawn_http_connection(worker_index)
626
+ if self._transport == "tcp":
627
+ return self._spawn_tcp_connection(worker_index)
580
628
  return self._spawn_subprocess_connection(worker_index)
581
629
 
630
+ def _spawn_tcp_connection(self, worker_index: int) -> WorkerConnection:
631
+ """Connect to a running TCP worker via ``vgi_rpc.rpc.tcp_connect``.
632
+
633
+ Raw Arrow-IPC framing with no auth/encryption — see ``from_tcp``.
634
+ Multiple ``worker_index`` values open independent TCP connections to
635
+ the same ``host:port``.
636
+ """
637
+ from vgi_rpc.rpc import tcp_connect
638
+
639
+ assert self._tcp_host is not None and self._tcp_port is not None # enforced in __init__
640
+ ctx: AbstractContextManager[VgiProtocol] = tcp_connect(
641
+ VgiProtocol, # type: ignore[type-abstract]
642
+ self._tcp_host,
643
+ self._tcp_port,
644
+ on_log=self._on_worker_log,
645
+ external_location=self._external_location,
646
+ )
647
+ proxy = ctx.__enter__()
648
+ _logger.debug(
649
+ "tcp_connection_opened worker_index=%s host=%s port=%s",
650
+ worker_index,
651
+ self._tcp_host,
652
+ self._tcp_port,
653
+ )
654
+ return WorkerConnection(
655
+ proxy=proxy,
656
+ worker_index=worker_index,
657
+ _tcp_ctx=ctx,
658
+ )
659
+
582
660
  def _spawn_http_connection(self, worker_index: int) -> WorkerConnection:
583
661
  """Connect to a remote HTTP worker via ``vgi_rpc.http.http_connect``.
584
662
 
@@ -724,6 +802,12 @@ class Client(CatalogClientMixin):
724
802
  _logger.debug("http_connection_closed worker_index=%s", worker.worker_index)
725
803
  return 0
726
804
 
805
+ if worker._tcp_ctx is not None:
806
+ # TCP transport — close the RPC proxy (and its socket).
807
+ worker._tcp_ctx.__exit__(None, None, None)
808
+ _logger.debug("tcp_connection_closed worker_index=%s", worker.worker_index)
809
+ return 0
810
+
727
811
  if worker._pool_ctx is not None:
728
812
  # Return to pool — pool handles subprocess lifecycle
729
813
  worker._pool_ctx.__exit__(None, None, None)
@@ -805,6 +889,8 @@ class Client(CatalogClientMixin):
805
889
  id_repr: Any = self._primary.proc.pid
806
890
  elif self._primary._http_ctx is not None:
807
891
  id_repr = f"http({self._base_url})"
892
+ elif self._primary._tcp_ctx is not None:
893
+ id_repr = f"tcp({self._tcp_host}:{self._tcp_port})"
808
894
  else:
809
895
  id_repr = "pooled"
810
896
  _logger.debug("server_started id=%s", id_repr)
vgi/protocol.py CHANGED
@@ -562,6 +562,13 @@ class MacroCreateRequest(ArrowSerializableDataclass):
562
562
  ERROR/IGNORE/REPLACE).
563
563
  parameter_default_values: One-row `RecordBatch` where column names are
564
564
  parameter names and values are typed defaults; ``None`` if no defaults.
565
+ arguments_schema: Optional Arrow schema (serialized as IPC bytes) with one
566
+ nullable field per parameter, in ``parameters`` order. Each field's type
567
+ is the parameter's default value type when known (else null), and the
568
+ ``vgi_doc`` field metadata key carries the parameter's description (UTF-8,
569
+ presence-only — omitted when undocumented). Mirrors the per-argument doc
570
+ channel functions expose. ``None`` when no per-parameter docs are
571
+ supplied. Built with ``vgi.argument_spec.macro_arguments_schema``.
565
572
  transaction_opaque_data: Opaque DuckDB transaction handle (bytes); ``None``
566
573
  when not inside a transaction.
567
574
  """
@@ -574,6 +581,7 @@ class MacroCreateRequest(ArrowSerializableDataclass):
574
581
  definition: str
575
582
  on_conflict: OnConflict
576
583
  parameter_default_values: Annotated[pa.RecordBatch | None, ArrowType(pa.binary())] = None
584
+ arguments_schema: Annotated[pa.Schema | None, ArrowType(pa.binary())] = None
577
585
  transaction_opaque_data: bytes | None = None
578
586
 
579
587
 
vgi/worker.py CHANGED
@@ -1276,10 +1276,25 @@ class Worker:
1276
1276
  "--unix",
1277
1277
  help="Bind to this AF_UNIX socket path instead of stdin/stdout (mutex with --http).",
1278
1278
  ),
1279
+ # TCP launcher contract — mutually exclusive with --http/--unix.
1280
+ # Accepts ``[HOST:]PORT``; host defaults to loopback (127.0.0.1)
1281
+ # and ``PORT`` may be 0 to auto-select a free port. After binding
1282
+ # the worker prints TCP:<host>:<port> to stdout and self-shuts-down
1283
+ # after --idle-timeout seconds with zero connected clients. The raw
1284
+ # framing carries no auth/encryption — bind loopback only; use
1285
+ # --http for untrusted networks.
1286
+ tcp: str | None = typer.Option(
1287
+ None,
1288
+ "--tcp",
1289
+ help=(
1290
+ "Bind a TCP socket ([HOST:]PORT, host defaults to 127.0.0.1, PORT 0 "
1291
+ "auto-selects) instead of stdin/stdout (mutex with --http/--unix)."
1292
+ ),
1293
+ ),
1279
1294
  idle_timeout: float = typer.Option(
1280
1295
  300.0,
1281
1296
  "--idle-timeout",
1282
- help="Self-shutdown after N seconds idle when serving --unix.",
1297
+ help="Self-shutdown after N seconds idle when serving --unix/--tcp.",
1283
1298
  ),
1284
1299
  http_threads: int | None = typer.Option( # noqa: B008
1285
1300
  None,
@@ -1300,8 +1315,8 @@ class Worker:
1300
1315
  log_format=log_format,
1301
1316
  )
1302
1317
 
1303
- if http and unix is not None:
1304
- raise typer.BadParameter("--http and --unix are mutually exclusive")
1318
+ if sum(x for x in (http, unix is not None, tcp is not None)) > 1:
1319
+ raise typer.BadParameter("--http, --unix, and --tcp are mutually exclusive")
1305
1320
 
1306
1321
  if http:
1307
1322
  from vgi.serve import (
@@ -1358,6 +1373,47 @@ class Worker:
1358
1373
  idle_timeout=effective_idle,
1359
1374
  on_bound=_emit,
1360
1375
  )
1376
+ elif tcp is not None:
1377
+ # TCP launcher path. Bind to [HOST:]PORT, print
1378
+ # TCP:<host>:<port> on stdout (mirrors run_server's
1379
+ # cross-language discovery contract), idle-shutdown after
1380
+ # idle_timeout seconds.
1381
+ from vgi_rpc.rpc import serve_tcp
1382
+
1383
+ from vgi.serve import _maybe_init_sentry, _resolve_otel_config
1384
+
1385
+ if ":" in tcp:
1386
+ host_part, _, port_part = tcp.rpartition(":")
1387
+ tcp_host = host_part or "127.0.0.1"
1388
+ else:
1389
+ tcp_host, port_part = "127.0.0.1", tcp
1390
+ try:
1391
+ tcp_port = int(port_part)
1392
+ except ValueError:
1393
+ raise typer.BadParameter(f"--tcp expects [HOST:]PORT, got {tcp!r}") from None
1394
+
1395
+ _maybe_init_sentry()
1396
+ otel_config = _resolve_otel_config()
1397
+ worker = cls(quiet=quiet, log_level=effective_level)
1398
+ server = RpcServer(cls.protocol_class, worker, server_version=_get_vgi_version())
1399
+ if otel_config is not None:
1400
+ from vgi_rpc.otel import instrument_server
1401
+
1402
+ instrument_server(server, otel_config)
1403
+ worker._vgi_tracer = VgiTracer.create(otel_config)
1404
+ effective_idle = idle_timeout if idle_timeout > 0 else None
1405
+
1406
+ def _emit_tcp(bound_host: str, bound_port: int) -> None:
1407
+ print(f"TCP:{bound_host}:{bound_port}", flush=True)
1408
+
1409
+ serve_tcp(
1410
+ server,
1411
+ tcp_host,
1412
+ tcp_port,
1413
+ threaded=True,
1414
+ idle_timeout=effective_idle,
1415
+ on_bound=_emit_tcp,
1416
+ )
1361
1417
  else:
1362
1418
  from vgi.serve import _maybe_init_sentry, _resolve_otel_config
1363
1419
 
@@ -4438,6 +4494,7 @@ class Worker:
4438
4494
  definition=request.definition,
4439
4495
  on_conflict=request.on_conflict,
4440
4496
  parameter_default_values=request.parameter_default_values,
4497
+ arguments_schema=request.arguments_schema,
4441
4498
  )
4442
4499
 
4443
4500
  def catalog_macro_drop(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vgi-python
3
- Version: 0.8.5
3
+ Version: 0.8.7
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
@@ -162,7 +162,7 @@ Requires-Dist: httpx>=0.24
162
162
  Requires-Dist: platformdirs
163
163
  Requires-Dist: pyarrow
164
164
  Requires-Dist: typer>=0.9
165
- Requires-Dist: vgi-rpc>=0.20.5
165
+ Requires-Dist: vgi-rpc>=0.21.0
166
166
  Provides-Extra: azure
167
167
  Requires-Dist: azure-identity>=1.16.0; extra == 'azure'
168
168
  Requires-Dist: pymssql>=2.3.0; extra == 'azure'
@@ -2,7 +2,7 @@ vgi/__init__.py,sha256=PRtFvXxhHEbY_0KVhyXIbGigZDYKHzMS-4gp0p6IJSQ,3414
2
2
  vgi/_duckdb.py,sha256=YB5D7N3Bwg_xP6X8a5QlumtlAovSej1A1Go5XlNGVko,2162
3
3
  vgi/_storage_profile.py,sha256=VkTsXojuE0tHEzurmteQSAiL1vI3CZSgYkL6D_h8GvE,5061
4
4
  vgi/aggregate_function.py,sha256=vn9TjQEHxAKJl_xzQOzdj5TY_6LplcZjv06JkQMnUyo,25184
5
- vgi/argument_spec.py,sha256=FFtCNVXhUnEvft51HICOVA7jGJilDW0CHYeyuYexGsg,18142
5
+ vgi/argument_spec.py,sha256=i5VTJSVV5L-gqtyqXOJA_foLMRDo36WcMbcCjvnLyL0,21210
6
6
  vgi/arguments.py,sha256=02tIMGIR_cRS73u-bsgpFERxhje-rnTokjBgGsm9pQA,67019
7
7
  vgi/auth.py,sha256=3HD2zM-Mt0Ie-_HT5RorpND1OusUw2CPROjPNs7rgbo,1478
8
8
  vgi/exceptions.py,sha256=oX_sZc9xGWi7Xf8cJQf89fX19i3ocDEj_V_76GINgBQ,7294
@@ -15,7 +15,7 @@ vgi/logging_config.py,sha256=zQdDuKL-bXwu0n6Pjyqga5SVIyQ25egL2xhV5hbv66k,3100
15
15
  vgi/meta_worker.py,sha256=kG5USvnV58S7Wj_RR3W_xM0QlRhaN__m1zwCkA95QOo,29546
16
16
  vgi/metadata.py,sha256=VTa625ang6lPgocY4voTOJ7IqET4gsKI4DS07Y53LiM,57914
17
17
  vgi/otel.py,sha256=JlUDnk2c0UXSo_j1PJdS67Y--t55sXPg054ADuvHANc,15452
18
- vgi/protocol.py,sha256=cPTTk6QoawU_hfiRX_Lx_f5x8n9Dyb7BlYcgi9oKt48,116099
18
+ vgi/protocol.py,sha256=-SXv0vIj49wQ3uwwsnnm16yRp-5EweZpKNHwZgtEF5M,116761
19
19
  vgi/protocol_version.txt,sha256=WYVJhIUxBN9cNT4vaBoV_HkkdC-aLkaMKa8kjc5FzgM,6
20
20
  vgi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
21
  vgi/scalar_function.py,sha256=lPHidmcAo_AtItsGqU4wPJhWfa0r_vAjfY6ZjyaW2BQ,47888
@@ -27,13 +27,13 @@ vgi/table_buffering_function.py,sha256=O5TcIdqk8-YViCQCkb8bOevO-zhe3IoyzLgwXDO3C
27
27
  vgi/table_filter_pushdown.py,sha256=zak3AuXLVw51vXew93i81fRReG1H8yr4XxCfRnnISj4,63961
28
28
  vgi/table_function.py,sha256=_Ni1deyPaabnQJDQz9CUL4fXy79r_OVyN6ZuWTZ1lMQ,52215
29
29
  vgi/table_in_out_function.py,sha256=Ouv02ubP-XsRdByIR0ZSagi6VbZfQ8G1e5QWpqlLqvY,15508
30
- vgi/worker.py,sha256=SGZsOpER_Aew6CDhyi6I8jchigTJJuL7pyV-E-Rz100,195818
30
+ vgi/worker.py,sha256=t5OKx5mGU91zeV6ErOifeTQmOw16fTOc18jOSDbTDaA,198588
31
31
  vgi/_test_fixtures/__init__.py,sha256=xQq-QQLk8USQ6TZHsPiPcZldNNUdcJ2gToXMPGzScLI,444
32
32
  vgi/_test_fixtures/attach_options.py,sha256=YtNk1krDdKLnNVtX9yGkgS8XyzdOI0oXtNDbAV73Phg,11091
33
33
  vgi/_test_fixtures/bad_enum.py,sha256=lIVemVWTM1JVOHut47Q71Czw-sEiMjfFiyZ15NDaWAk,2910
34
34
  vgi/_test_fixtures/bad_protocol.py,sha256=L8kxafWZ-4Sd73F6EpCRo1kWVC-09qzof6OYRXazS_8,2495
35
35
  vgi/_test_fixtures/cancellable.py,sha256=hetTRLyT4kkazPNcxAdfzj0pYzEmegMksamJbR0w3Ho,12151
36
- vgi/_test_fixtures/catalog.py,sha256=CL6E52_vKr_kgGckT0vQO65uUWBSP518Ulfl8nmSey8,28004
36
+ vgi/_test_fixtures/catalog.py,sha256=DuC9r0efwuG-uPdjFFGNMN-Egi2BP9bvDpc1f3uFffM,28106
37
37
  vgi/_test_fixtures/http_server.py,sha256=GevITVo61H1Y8K6zWUJj5guB5Y_H6QFfgVtfzRB0EAA,16095
38
38
  vgi/_test_fixtures/nest_tensor.py,sha256=UjjW9UwipYG3ehQpZOm4hdzsafMyvVc0ZKJCrqiZTPU,24471
39
39
  vgi/_test_fixtures/orchard_catalog.py,sha256=xMknth9FsVs8-y6vQyIZqDfUhJ-susjpMSzOOhaZyxM,1638
@@ -41,7 +41,7 @@ vgi/_test_fixtures/simple_writable.py,sha256=CGmDBUY8kthauEm8eJN2lV72cJ9_TRxOAQC
41
41
  vgi/_test_fixtures/table_in_out.py,sha256=7QckA3NJhYAYuSctcwZLul5yOM2V3KWvLuG_33K0B_w,50459
42
42
  vgi/_test_fixtures/versioned.py,sha256=Itm-x_Zt9WDwLGT4Dl4VzU5GtFF4HkcaJEqg9ErB8As,5784
43
43
  vgi/_test_fixtures/versioned_tables.py,sha256=KRllGGRrwH8JUtqH-tLHT1JL09rKN-EcEYZVeQdbaLs,22112
44
- vgi/_test_fixtures/worker.py,sha256=5G3shpiPoKWV_DP28UN1LYNp1wIJ2Q-TB8mJ3qok2q4,71230
44
+ vgi/_test_fixtures/worker.py,sha256=m7y5C9meuiOR2iXKnpu7prlq8FX_Zx_b4_Y6b9cNN0I,71676
45
45
  vgi/_test_fixtures/accumulate/__init__.py,sha256=4hYT8jqRoVHSjV9TB7v0Z1CMJtdLuPaDWSz4J2fvMDs,868
46
46
  vgi/_test_fixtures/accumulate/worker.py,sha256=yal9m-GjKNKUdLOLtwkCyFkeHVv_nnpUjh8amwueT48,30163
47
47
  vgi/_test_fixtures/aggregate/__init__.py,sha256=tjCVKdCuHlIAZL7uDi-o_q82oMieXsAyoKExesr-7a0,2156
@@ -98,14 +98,14 @@ vgi/_test_fixtures/writable/worker.py,sha256=nH8KSZqyKv1gqdKn-_OFVh3h02FbyE0NFQ2
98
98
  vgi/catalog/__init__.py,sha256=SCpeP2TtPfhWhwaqK5qGmmtHCM2z-EL66OoCkyefy7c,2064
99
99
  vgi/catalog/_descriptor_spec.py,sha256=iMqId2fSBqlZ9HU6ct5AkYUAxtRGG_MVTLClDC1WPUs,7673
100
100
  vgi/catalog/attach_option.py,sha256=nLGxsfAFR_-NqDrw7v18dtkNNNQIYLr3fuhpVw4XhFc,1739
101
- vgi/catalog/catalog_interface.py,sha256=aCMSpYczcEb11TmOFdaGTN_F_yOywyR2go1Z6vC_0zY,120231
102
- vgi/catalog/descriptors.py,sha256=oH-Ld02yQ5oUJb7J9sqGYwhZwaWzZiL3ZPtublmKXzc,39366
101
+ vgi/catalog/catalog_interface.py,sha256=J6eNB-TMIjakKp7wAPKGLubpjM0YwGeUteaYb3PF5dk,121950
102
+ vgi/catalog/descriptors.py,sha256=GrVrFwmyG7GVT4tF3yT7lr-ZcS-qrxFpnFHKw7khTBU,40437
103
103
  vgi/catalog/duckdb_statistics.py,sha256=fARQupgq0fD46rDgxDXvdCFsgs-rvCOHhls7WXHmnFY,15615
104
104
  vgi/catalog/secret_type.py,sha256=MKtAypBa3xXyr-NC5CHjdX1R00JEnuMtvgboFWC2T9o,3336
105
105
  vgi/catalog/setting.py,sha256=06QfgaAR-0BKflIJ0du6PGqA5BPMdrkwr6u7f4nddII,1846
106
106
  vgi/catalog/storage.py,sha256=DhngzywbhQUfLF7NjFyiPp4Sw2ovOhcUXAuW3rkBQD8,11935
107
107
  vgi/client/__init__.py,sha256=6LPHqlcNFy77apDNXUntgOVLhuXfpJyZd6TI7R7QfbY,2122
108
- vgi/client/catalog_mixin.py,sha256=PfpdV8KX1R2wkkMG7ffeSkbD9nO7o9ij60a_ApGI45E,45509
108
+ vgi/client/catalog_mixin.py,sha256=fH_D756rzQZHi6UKbeVbWCIScVUqcGw8Dfp8nD36dao,46600
109
109
  vgi/client/cli.py,sha256=KfoqIiMXiUNAdYxRp-O6BOi3FA1IsesSBb_XeZGWFSo,22287
110
110
  vgi/client/cli_catalog.py,sha256=twjlhHGl7n2mNN4jc7DnDRQr-Zj9KLAF2eATIQnITHQ,5577
111
111
  vgi/client/cli_schema.py,sha256=FTkf1moDn_dWFMPDxJqaNFhc2oyrLEutP8xC60EbPIk,7556
@@ -113,7 +113,7 @@ vgi/client/cli_table.py,sha256=hxASTkLZy69z4qoYhpIqrV9ZmZdPLKIzhmHy-hB9qqE,26145
113
113
  vgi/client/cli_transaction.py,sha256=TDW0ZrBy8XM_eYSkYk5ewEsnozfzDJOlopd66Fm7OOE,3142
114
114
  vgi/client/cli_utils.py,sha256=kilyfxRgFdW63IYQU6xsVls1wIcRjR-ZP63qu0FdXLI,16223
115
115
  vgi/client/cli_view.py,sha256=p2wiJAuaEv-8qEgWxiNKgzA9qLdYZ87XCJwkxx5c-YU,8353
116
- vgi/client/client.py,sha256=M-P3v1Xi5aroTl7Zdtx_tdMT4VjWhTZKiqAog9BDsb4,93582
116
+ vgi/client/client.py,sha256=Pb5qLfydDEnrTW-yCUDSGkerGJesCeHQQbFNTZpxN24,97270
117
117
  vgi/http/__init__.py,sha256=hlOwOVcoZqmEKVGk7ganOdr5ryRSpEhLBF9sRD7BkYc,608
118
118
  vgi/http/demo_storage.py,sha256=830s-H61thozC3eEuqdnwCpYFfvoJRq9vjyuXa5OURo,8769
119
119
  vgi/http/worker_page.py,sha256=Eq70-hPfG2PIuhFjEDMf-BxF7Fdjcb4dN2SploH2jWE,54577
@@ -122,8 +122,8 @@ vgi/transactor/_duckdb_compat.py,sha256=sXVZ9JLKAQyGR1BjWczSwdQEavtr-TcZPoVZZnTr
122
122
  vgi/transactor/client.py,sha256=7DTeMksogsw6ANjQjGOPpKYrV76rg4_kGjktMJf54jg,4486
123
123
  vgi/transactor/protocol.py,sha256=Mtmll3CdrLFL1B4NY4NZUTO_yi3PT0qhvMQnzapuBWU,4780
124
124
  vgi/transactor/server.py,sha256=WpIqjzy2Mebw17Jui4-w7vyGEo9pD-pEZJG-3Ob1Sk8,29705
125
- vgi_python-0.8.5.dist-info/METADATA,sha256=1dqjWdLcM1MrxAIF8CVYkGA8YCaZF3T5pamDdUYdIj0,24725
126
- vgi_python-0.8.5.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
127
- vgi_python-0.8.5.dist-info/entry_points.txt,sha256=3Kz1vgodw3pOL_xjtSyDB55-ZRy-U2X-X_Bdr582x0Q,165
128
- vgi_python-0.8.5.dist-info/licenses/LICENSE,sha256=pbJb4zZasP6n5ifEV81wFu017TarjydaYVmGbHcehtY,6103
129
- vgi_python-0.8.5.dist-info/RECORD,,
125
+ vgi_python-0.8.7.dist-info/METADATA,sha256=tNHKhPZbF3jkRYNokolq5dGV1pj1iLcqN2AedbfdK2I,24725
126
+ vgi_python-0.8.7.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
127
+ vgi_python-0.8.7.dist-info/entry_points.txt,sha256=3Kz1vgodw3pOL_xjtSyDB55-ZRy-U2X-X_Bdr582x0Q,165
128
+ vgi_python-0.8.7.dist-info/licenses/LICENSE,sha256=pbJb4zZasP6n5ifEV81wFu017TarjydaYVmGbHcehtY,6103
129
+ vgi_python-0.8.7.dist-info/RECORD,,