vgi-python 0.8.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.
Files changed (124) hide show
  1. vgi/__init__.py +152 -0
  2. vgi/_duckdb.py +62 -0
  3. vgi/_storage_profile.py +132 -0
  4. vgi/_test_fixtures/__init__.py +20 -0
  5. vgi/_test_fixtures/accumulate/__init__.py +19 -0
  6. vgi/_test_fixtures/accumulate/worker.py +762 -0
  7. vgi/_test_fixtures/aggregate/__init__.py +62 -0
  8. vgi/_test_fixtures/aggregate/_common.py +21 -0
  9. vgi/_test_fixtures/aggregate/basic.py +232 -0
  10. vgi/_test_fixtures/aggregate/dynamic.py +409 -0
  11. vgi/_test_fixtures/aggregate/generic.py +86 -0
  12. vgi/_test_fixtures/aggregate/listagg.py +71 -0
  13. vgi/_test_fixtures/aggregate/percentile.py +107 -0
  14. vgi/_test_fixtures/aggregate/streaming.py +192 -0
  15. vgi/_test_fixtures/aggregate/varargs.py +75 -0
  16. vgi/_test_fixtures/aggregate/window.py +380 -0
  17. vgi/_test_fixtures/attach_options.py +308 -0
  18. vgi/_test_fixtures/bad_protocol.py +62 -0
  19. vgi/_test_fixtures/cancellable.py +336 -0
  20. vgi/_test_fixtures/catalog.py +813 -0
  21. vgi/_test_fixtures/http_server.py +394 -0
  22. vgi/_test_fixtures/nest_tensor.py +614 -0
  23. vgi/_test_fixtures/orchard_catalog.py +47 -0
  24. vgi/_test_fixtures/projection_repro/__init__.py +6 -0
  25. vgi/_test_fixtures/projection_repro/worker.py +454 -0
  26. vgi/_test_fixtures/scalar/__init__.py +116 -0
  27. vgi/_test_fixtures/scalar/_common.py +69 -0
  28. vgi/_test_fixtures/scalar/arithmetic.py +321 -0
  29. vgi/_test_fixtures/scalar/binary.py +120 -0
  30. vgi/_test_fixtures/scalar/formatting.py +176 -0
  31. vgi/_test_fixtures/scalar/geo.py +300 -0
  32. vgi/_test_fixtures/scalar/null_handling.py +107 -0
  33. vgi/_test_fixtures/scalar/random_demo.py +171 -0
  34. vgi/_test_fixtures/scalar/settings_secrets.py +102 -0
  35. vgi/_test_fixtures/scalar/type_info.py +219 -0
  36. vgi/_test_fixtures/schema_reconcile/__init__.py +29 -0
  37. vgi/_test_fixtures/schema_reconcile/worker.py +653 -0
  38. vgi/_test_fixtures/simple_writable.py +793 -0
  39. vgi/_test_fixtures/table/__init__.py +221 -0
  40. vgi/_test_fixtures/table/_common.py +162 -0
  41. vgi/_test_fixtures/table/batch_index.py +283 -0
  42. vgi/_test_fixtures/table/batch_index_broken.py +200 -0
  43. vgi/_test_fixtures/table/catalog_scans.py +162 -0
  44. vgi/_test_fixtures/table/filters.py +1005 -0
  45. vgi/_test_fixtures/table/late_materialization.py +249 -0
  46. vgi/_test_fixtures/table/make_series.py +273 -0
  47. vgi/_test_fixtures/table/misc.py +499 -0
  48. vgi/_test_fixtures/table/order_modes.py +164 -0
  49. vgi/_test_fixtures/table/pairs.py +437 -0
  50. vgi/_test_fixtures/table/partition_columns.py +472 -0
  51. vgi/_test_fixtures/table/partition_columns_broken.py +304 -0
  52. vgi/_test_fixtures/table/profiling_example.py +195 -0
  53. vgi/_test_fixtures/table/required_filters.py +234 -0
  54. vgi/_test_fixtures/table/sequence.py +710 -0
  55. vgi/_test_fixtures/table/settings.py +426 -0
  56. vgi/_test_fixtures/table/transaction_storage.py +162 -0
  57. vgi/_test_fixtures/table/tt_pushdown.py +191 -0
  58. vgi/_test_fixtures/table/versioned.py +230 -0
  59. vgi/_test_fixtures/table_in_out.py +1392 -0
  60. vgi/_test_fixtures/versioned.py +155 -0
  61. vgi/_test_fixtures/versioned_tables.py +595 -0
  62. vgi/_test_fixtures/worker.py +1631 -0
  63. vgi/_test_fixtures/writable/__init__.py +8 -0
  64. vgi/_test_fixtures/writable/generic.py +236 -0
  65. vgi/_test_fixtures/writable/table.py +149 -0
  66. vgi/_test_fixtures/writable/worker.py +1148 -0
  67. vgi/aggregate_function.py +607 -0
  68. vgi/argument_spec.py +472 -0
  69. vgi/arguments.py +1747 -0
  70. vgi/auth.py +55 -0
  71. vgi/catalog/__init__.py +88 -0
  72. vgi/catalog/attach_option.py +206 -0
  73. vgi/catalog/catalog_interface.py +2767 -0
  74. vgi/catalog/descriptors.py +870 -0
  75. vgi/catalog/duckdb_statistics.py +377 -0
  76. vgi/catalog/secret_type.py +96 -0
  77. vgi/catalog/setting.py +253 -0
  78. vgi/catalog/storage.py +372 -0
  79. vgi/client/__init__.py +67 -0
  80. vgi/client/catalog_mixin.py +1251 -0
  81. vgi/client/cli.py +582 -0
  82. vgi/client/cli_catalog.py +182 -0
  83. vgi/client/cli_schema.py +270 -0
  84. vgi/client/cli_table.py +907 -0
  85. vgi/client/cli_transaction.py +97 -0
  86. vgi/client/cli_utils.py +441 -0
  87. vgi/client/cli_view.py +303 -0
  88. vgi/client/client.py +2183 -0
  89. vgi/exceptions.py +205 -0
  90. vgi/function.py +245 -0
  91. vgi/function_storage.py +1636 -0
  92. vgi/function_storage_azure_sql.py +922 -0
  93. vgi/function_storage_cf_do.py +740 -0
  94. vgi/http/__init__.py +25 -0
  95. vgi/http/demo_storage.py +212 -0
  96. vgi/http/worker_page.py +1252 -0
  97. vgi/invocation.py +154 -0
  98. vgi/logging_config.py +93 -0
  99. vgi/meta_worker.py +661 -0
  100. vgi/metadata.py +1403 -0
  101. vgi/otel.py +406 -0
  102. vgi/protocol.py +2418 -0
  103. vgi/protocol_version.txt +1 -0
  104. vgi/py.typed +0 -0
  105. vgi/scalar_function.py +1211 -0
  106. vgi/schema_utils.py +234 -0
  107. vgi/secret_protocol.py +124 -0
  108. vgi/secret_service.py +238 -0
  109. vgi/serve.py +769 -0
  110. vgi/table_buffering_function.py +443 -0
  111. vgi/table_filter_pushdown.py +1528 -0
  112. vgi/table_function.py +1130 -0
  113. vgi/table_in_out_function.py +383 -0
  114. vgi/transactor/__init__.py +24 -0
  115. vgi/transactor/_duckdb_compat.py +27 -0
  116. vgi/transactor/client.py +137 -0
  117. vgi/transactor/protocol.py +149 -0
  118. vgi/transactor/server.py +740 -0
  119. vgi/worker.py +4761 -0
  120. vgi_python-0.8.0.dist-info/METADATA +735 -0
  121. vgi_python-0.8.0.dist-info/RECORD +124 -0
  122. vgi_python-0.8.0.dist-info/WHEEL +4 -0
  123. vgi_python-0.8.0.dist-info/entry_points.txt +5 -0
  124. vgi_python-0.8.0.dist-info/licenses/LICENSE +134 -0
vgi/serve.py ADDED
@@ -0,0 +1,769 @@
1
+ # Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
+
3
+ """Zero-boilerplate CLI for serving VGI workers.
4
+
5
+ Loads any Worker by module reference and serves it — stdio by default
6
+ (matching vgi-rpc's ``run_server()``), ``--http`` for cloud deployment.
7
+
8
+ Usage::
9
+
10
+ # Stdio (default) — for subprocess/pipe use by vgi-client or DuckDB
11
+ vgi-serve my_worker.py
12
+ vgi-serve my_app.workers:ProductionWorker
13
+
14
+ # HTTP — for cloud deployment
15
+ vgi-serve my_worker.py --http
16
+ vgi-serve my_worker.py --http --host 0.0.0.0 --port 8080
17
+
18
+ Programmatic API::
19
+
20
+ from vgi.serve import create_app, load_worker_class
21
+
22
+ app = create_app(load_worker_class("my_app:MyWorker"))
23
+ # Use with gunicorn: gunicorn app -w 4 -b 0.0.0.0:8080
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import importlib
29
+ import importlib.util
30
+ import logging
31
+ import os
32
+ import sys
33
+ from collections.abc import Callable
34
+ from types import ModuleType
35
+ from typing import TYPE_CHECKING, Any
36
+
37
+ from vgi.logging_config import LogFormat, LogLevel
38
+
39
+ if TYPE_CHECKING:
40
+ import falcon
41
+ from vgi_rpc.otel import OtelConfig
42
+ from vgi_rpc.rpc import AuthContext
43
+
44
+ from vgi.worker import Worker
45
+
46
+ _logger = logging.getLogger("vgi.serve")
47
+
48
+ __all__ = [
49
+ "create_app",
50
+ "load_worker_class",
51
+ "main",
52
+ ]
53
+
54
+
55
+ def load_worker_class(reference: str) -> type[Worker]:
56
+ """Load a Worker subclass from a module reference string.
57
+
58
+ Accepts several reference formats:
59
+
60
+ - ``module:ClassName`` — import *module* and return *ClassName*
61
+ - ``module`` — import *module* and auto-discover the single Worker subclass
62
+ - ``./path/to/file.py`` or ``path.py`` — load from file path
63
+ - ``./path/to/file.py:ClassName`` — load from file path, return *ClassName*
64
+
65
+ Auto-discovery finds Worker subclasses **defined** in the module (ignores
66
+ imported ones by checking ``__module__``).
67
+
68
+ Args:
69
+ reference: Module reference string.
70
+
71
+ Returns:
72
+ The Worker subclass.
73
+
74
+ Raises:
75
+ SystemExit: If the reference is invalid, module can't be loaded,
76
+ no Worker subclass is found, or multiple are found.
77
+
78
+ """
79
+ from vgi.worker import Worker
80
+
81
+ # Split off class name if present
82
+ class_name: str | None = None
83
+ module_ref: str = reference
84
+
85
+ if ":" in reference:
86
+ head, tail = reference.rsplit(":", 1)
87
+ # A lone Windows drive-letter colon ("C:\path\worker.py") is part
88
+ # of the path, not a module:Class separator.
89
+ if not (len(head) == 1 and head.isalpha() and tail[:1] in ("\\", "/")):
90
+ module_ref, class_name = head, tail
91
+
92
+ # Load the module
93
+ module = _load_module(module_ref)
94
+
95
+ if class_name is not None:
96
+ obj = getattr(module, class_name, None)
97
+ if obj is None:
98
+ sys.stderr.write(f"Error: {class_name!r} not found in {module_ref!r}\n")
99
+ sys.exit(1)
100
+ if not (isinstance(obj, type) and issubclass(obj, Worker) and obj is not Worker):
101
+ sys.stderr.write(f"Error: {class_name!r} in {module_ref!r} is not a Worker subclass\n")
102
+ sys.exit(1)
103
+ return obj
104
+
105
+ # Auto-discover: find Worker subclasses defined in this module
106
+ candidates: list[type[Worker]] = []
107
+ for name in dir(module):
108
+ obj = getattr(module, name)
109
+ if (
110
+ isinstance(obj, type)
111
+ and issubclass(obj, Worker)
112
+ and obj is not Worker
113
+ and obj.__module__ == module.__name__
114
+ ):
115
+ candidates.append(obj)
116
+
117
+ if len(candidates) == 0:
118
+ sys.stderr.write(f"Error: no Worker subclass found in {module_ref!r}\n")
119
+ sys.exit(1)
120
+ if len(candidates) > 1:
121
+ names = ", ".join(c.__name__ for c in candidates)
122
+ sys.stderr.write(
123
+ f"Error: multiple Worker subclasses found in {module_ref!r}: {names}\n"
124
+ f"Specify one with {module_ref}:ClassName\n"
125
+ )
126
+ sys.exit(1)
127
+
128
+ return candidates[0]
129
+
130
+
131
+ def _load_module(module_ref: str) -> ModuleType:
132
+ """Import a module by dotted name or file path."""
133
+ # File path: ends with .py or starts with ./ or /
134
+ if module_ref.endswith(".py") or module_ref.startswith(("./", "/")):
135
+ path = os.path.abspath(module_ref)
136
+ if not os.path.isfile(path):
137
+ sys.stderr.write(f"Error: file not found: {path}\n")
138
+ sys.exit(1)
139
+
140
+ # Derive a module name from the file name
141
+ mod_name = os.path.basename(path).removesuffix(".py")
142
+ spec = importlib.util.spec_from_file_location(mod_name, path)
143
+ if spec is None or spec.loader is None:
144
+ sys.stderr.write(f"Error: could not load module from {path}\n")
145
+ sys.exit(1)
146
+
147
+ module = importlib.util.module_from_spec(spec)
148
+ sys.modules[mod_name] = module
149
+ spec.loader.exec_module(module)
150
+ return module
151
+
152
+ # Dotted module name
153
+ try:
154
+ return importlib.import_module(module_ref)
155
+ except ImportError as exc:
156
+ sys.stderr.write(f"Error: could not import {module_ref!r}: {exc}\n")
157
+ sys.exit(1)
158
+
159
+
160
+ def _make_frontend_redirect(frontend_url: str, prefix: str) -> object:
161
+ """Create a Falcon resource that redirects to the external frontend."""
162
+ import html as _html
163
+
164
+ # Build the redirect HTML — the service URL is injected at request time
165
+ # so it adapts to the actual host/port the server is running on.
166
+ _redirect_template = (
167
+ "<!DOCTYPE html><html><head>"
168
+ '<meta http-equiv="refresh" content="0;url={redirect_url}">'
169
+ "</head><body>"
170
+ 'Redirecting to <a href="{redirect_url}">VGI Frontend</a>...'
171
+ "</body></html>"
172
+ )
173
+ _frontend_base = frontend_url.rstrip("/")
174
+ _prefix = prefix
175
+
176
+ class _FrontendRedirectResource:
177
+ def on_get(self, req: falcon.Request, resp: falcon.Response) -> None:
178
+ scheme = req.forwarded_scheme or req.scheme
179
+ host = req.forwarded_host or req.host
180
+ service_url = f"{scheme}://{host}{_prefix}"
181
+ redirect_url = f"{_frontend_base}?service={service_url}"
182
+ # Pass auth token in URL fragment so the frontend can use it
183
+ # for cross-origin API calls (cookie is bound to this origin).
184
+ token = req.cookies.get("_vgi_auth")
185
+ if token:
186
+ redirect_url += f"#token={token}"
187
+ resp.status = "302 Found"
188
+ resp.set_header("Location", redirect_url)
189
+ resp.set_header("Cache-Control", "no-cache")
190
+ resp.content_type = "text/html; charset=utf-8"
191
+ resp.text = _redirect_template.format(redirect_url=_html.escape(redirect_url))
192
+
193
+ return _FrontendRedirectResource()
194
+
195
+
196
+ def create_app(
197
+ worker_cls: type[Worker],
198
+ *,
199
+ prefix: str = "",
200
+ cors_origins: str = "*",
201
+ describe: bool = True,
202
+ signing_key: bytes | None = None,
203
+ log_level: int = logging.INFO,
204
+ authenticate: Callable[[falcon.Request], AuthContext] | None = None,
205
+ oauth_resource_metadata: Any = None,
206
+ otel_config: OtelConfig | None = None,
207
+ max_stream_response_bytes: int | None = None,
208
+ ) -> falcon.App[Any, Any]:
209
+ """Create a WSGI app for a VGI worker.
210
+
211
+ Returns a standard WSGI app usable with gunicorn, uwsgi, waitress, or
212
+ any WSGI server.
213
+
214
+ Args:
215
+ worker_cls: The Worker subclass to serve.
216
+ prefix: URL prefix for RPC endpoints.
217
+ cors_origins: Allowed CORS origins.
218
+ describe: Enable worker + API description pages.
219
+ signing_key: Shared signing key for state tokens. When ``None``,
220
+ a random per-process key is generated (tokens are invalid
221
+ across workers). Set via ``VGI_SIGNING_KEY`` env var or
222
+ pass explicitly for multi-process deployments.
223
+ log_level: Logging level for the worker instance.
224
+ authenticate: Optional callback that validates each HTTP request
225
+ and returns an AuthContext. When ``None``, all requests are
226
+ anonymous.
227
+ oauth_resource_metadata: Optional OAuthResourceMetadata for
228
+ RFC 9728 discovery endpoint.
229
+ otel_config: Optional OpenTelemetry configuration. When provided,
230
+ instruments the RPC server with tracing and/or metrics.
231
+ max_stream_response_bytes: HTTP-only. When set, producer stream
232
+ responses may pack multiple Arrow batches into a single HTTP
233
+ response up to this byte budget before emitting a continuation
234
+ token. Default ``None`` keeps the current one-batch-per-response
235
+ behaviour.
236
+
237
+ Returns:
238
+ A Falcon WSGI application.
239
+
240
+ """
241
+ try:
242
+ from vgi_rpc.http import make_wsgi_app
243
+ except ImportError:
244
+ sys.stderr.write(
245
+ "Error: HTTP dependencies not installed.\nInstall with: pip install vgi[http] (or: uv sync --extra http)\n"
246
+ )
247
+ sys.exit(1)
248
+
249
+ from vgi_rpc.rpc import RpcServer
250
+
251
+ from vgi.otel import VgiTracer
252
+ from vgi.protocol import VgiProtocol
253
+
254
+ # Resolve the signing key once, here, so the worker (which seals catalog
255
+ # opaque-data envelopes) and the HTTP state-token machinery share the same
256
+ # key. When the operator did not configure VGI_SIGNING_KEY, generate an
257
+ # ephemeral per-process key: sealed values are then valid for the life of
258
+ # this process and clients re-ATTACH after a restart.
259
+ if signing_key is None:
260
+ signing_key = os.urandom(32)
261
+
262
+ worker = worker_cls(quiet=True, log_level=log_level)
263
+ worker._vgi_tracer = VgiTracer.create(otel_config)
264
+ worker._signing_key = signing_key
265
+ from vgi.worker import _get_vgi_version
266
+
267
+ server = RpcServer(VgiProtocol, worker, enable_describe=describe, server_version=_get_vgi_version())
268
+ wsgi_app = make_wsgi_app(
269
+ server,
270
+ prefix=prefix,
271
+ cors_origins=cors_origins,
272
+ token_key=signing_key,
273
+ authenticate=authenticate,
274
+ oauth_resource_metadata=oauth_resource_metadata,
275
+ otel_config=otel_config,
276
+ max_stream_response_bytes=max_stream_response_bytes,
277
+ enable_landing_page=False,
278
+ )
279
+
280
+ # Frontend: either redirect to external CDN or serve pre-rendered worker page
281
+ frontend_url = os.environ.get("VGI_FRONTEND_URL")
282
+ if frontend_url:
283
+ # External frontend — redirect to CDN with ?service= param
284
+ _FrontendRedirectResource = _make_frontend_redirect(frontend_url, prefix)
285
+ wsgi_app.add_route(prefix or "/", _FrontendRedirectResource)
286
+ elif describe:
287
+ from vgi.http.worker_page import WorkerPageResource
288
+
289
+ # Inject PKCE user-info JS if OAuth PKCE is active.
290
+ body_transform = None
291
+ if oauth_resource_metadata is not None and getattr(oauth_resource_metadata, "client_id", None) is not None:
292
+ try:
293
+ from vgi_rpc.http._oauth_pkce import build_user_info_html
294
+
295
+ user_info_html = build_user_info_html(prefix).encode()
296
+
297
+ def body_transform(body: bytes, _html: bytes = user_info_html) -> bytes:
298
+ return body.replace(b"</body>", _html + b"\n</body>")
299
+ except ImportError:
300
+ pass
301
+ wsgi_app.add_route(prefix or "/", WorkerPageResource(worker_cls, prefix, body_transform))
302
+
303
+ return wsgi_app
304
+
305
+
306
+ def main() -> None:
307
+ """CLI entry point for ``vgi-serve``."""
308
+ import typer
309
+
310
+ from vgi.logging_config import configure_worker_logging
311
+
312
+ app = typer.Typer(
313
+ add_completion=False,
314
+ help="Serve a VGI worker. Stdio by default, --http for cloud deployment.",
315
+ )
316
+
317
+ @app.command()
318
+ def serve(
319
+ worker_ref: str = typer.Argument(help="Worker reference: module:Class, module, or ./file.py"),
320
+ # Transport
321
+ http: bool = typer.Option(False, "--http", help="Serve over HTTP instead of stdin/stdout"),
322
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress startup banner (stdio mode)"),
323
+ # Logging
324
+ debug: bool = typer.Option(False, "--debug", help="Enable DEBUG on all vgi + vgi_rpc loggers"),
325
+ log_level: LogLevel = typer.Option(LogLevel.INFO, "--log-level", help="Set log level"), # noqa: B008
326
+ log_logger: list[str] | None = typer.Option( # noqa: B008
327
+ None, "--log-logger", help="Target specific logger(s)"
328
+ ),
329
+ log_format: LogFormat = typer.Option( # noqa: B008
330
+ LogFormat.text, "--log-format", help="Stderr log format"
331
+ ),
332
+ # HTTP-only options
333
+ host: str = typer.Option("0.0.0.0", "--host", help="HTTP bind address"),
334
+ port: int | None = typer.Option(None, "--port", "-p", help="HTTP port (default: $PORT or 8080)"), # noqa: B008
335
+ prefix: str = typer.Option("", "--prefix", help="URL prefix for RPC endpoints"),
336
+ cors_origins: str = typer.Option("*", "--cors-origins", help="Allowed CORS origins"),
337
+ describe: bool = typer.Option( # noqa: B008
338
+ True, "--describe/--no-describe", help="Enable description pages (worker + RPC API)"
339
+ ),
340
+ max_stream_response_bytes: int | None = typer.Option( # noqa: B008
341
+ None,
342
+ "--max-stream-response-bytes",
343
+ help=(
344
+ "HTTP-only. When set, producer-stream responses pack multiple "
345
+ "Arrow batches into a single HTTP body up to this byte budget "
346
+ "before emitting a continuation token. Default: one batch per response."
347
+ ),
348
+ ),
349
+ ) -> None:
350
+ env_debug = os.environ.get("VGI_WORKER_DEBUG", "").lower() in ("1", "true", "yes")
351
+ effective_debug = debug or env_debug
352
+ effective_level = configure_worker_logging(
353
+ debug=effective_debug,
354
+ log_level=log_level,
355
+ log_loggers=log_logger,
356
+ log_format=log_format,
357
+ )
358
+
359
+ # Resolve env var overrides
360
+ describe = _resolve_describe(describe)
361
+ signing_key = _resolve_signing_key()
362
+
363
+ # Initialise Sentry before constructing any RpcServer so that
364
+ # vgi-rpc's auto-attach hook picks up the SDK.
365
+ _maybe_init_sentry()
366
+
367
+ worker_cls = load_worker_class(worker_ref)
368
+
369
+ if http:
370
+ authenticate = _resolve_authenticate()
371
+ oauth_metadata = _resolve_oauth_resource_metadata()
372
+ otel_config = _resolve_otel_config()
373
+ _serve_http(
374
+ worker_cls,
375
+ effective_level=effective_level,
376
+ host=host,
377
+ port=port,
378
+ prefix=prefix,
379
+ cors_origins=cors_origins,
380
+ describe=describe,
381
+ signing_key=signing_key,
382
+ authenticate=authenticate,
383
+ oauth_resource_metadata=oauth_metadata,
384
+ otel_config=otel_config,
385
+ max_stream_response_bytes=max_stream_response_bytes,
386
+ )
387
+ else:
388
+ otel_config = _resolve_otel_config()
389
+ worker_cls(quiet=quiet, log_level=effective_level).run(otel_config=otel_config)
390
+
391
+ app()
392
+
393
+
394
+ def _resolve_signing_key() -> bytes | None:
395
+ """Read ``VGI_SIGNING_KEY`` from the environment."""
396
+ raw = os.environ.get("VGI_SIGNING_KEY")
397
+ if raw:
398
+ return raw.encode()
399
+ return None
400
+
401
+
402
+ def _resolve_describe(cli_value: bool) -> bool:
403
+ """Apply ``VGI_ENABLE_DESCRIBE`` env var override.
404
+
405
+ The env var only takes effect when it is explicitly set. Accepts
406
+ ``1``/``true``/``yes`` (enable) and ``0``/``false``/``no`` (disable),
407
+ case-insensitive. The CLI flag (``--describe`` / ``--no-describe``)
408
+ wins when Typer reports a non-default value, but since we cannot
409
+ distinguish "user passed --describe" from "default True", the env var
410
+ always overrides when present.
411
+ """
412
+ raw = os.environ.get("VGI_ENABLE_DESCRIBE")
413
+ if raw is None:
414
+ return cli_value
415
+ return raw.lower() in ("1", "true", "yes")
416
+
417
+
418
+ def _resolve_authenticate() -> Callable[..., Any] | None:
419
+ """Build an authenticate callback from environment variables.
420
+
421
+ Supported env vars:
422
+
423
+ - ``VGI_BEARER_TOKENS``: comma-separated ``token=principal`` pairs
424
+ for static bearer token auth.
425
+ - ``VGI_JWT_ISSUER`` + ``VGI_JWT_AUDIENCE``: JWT/JWKS auth
426
+ (requires ``vgi[oauth]`` extra). Optional ``VGI_JWT_JWKS_URI``.
427
+ - When both bearer and JWT are set, they are chained (JWT first).
428
+
429
+ Returns:
430
+ An authenticate callback, or None if no auth env vars are set.
431
+
432
+ Raises:
433
+ SystemExit: If env vars are malformed (e.g. bearer token without ``=``,
434
+ JWT issuer without audience).
435
+
436
+ """
437
+ bearer_auth = _resolve_bearer_authenticate()
438
+ jwt_auth = _resolve_jwt_authenticate()
439
+
440
+ if bearer_auth is not None and jwt_auth is not None:
441
+ from vgi_rpc.http import chain_authenticate
442
+
443
+ return chain_authenticate(jwt_auth, bearer_auth)
444
+ return jwt_auth or bearer_auth
445
+
446
+
447
+ def _resolve_bearer_authenticate() -> Callable[..., Any] | None:
448
+ """Build a bearer_authenticate_static callback from VGI_BEARER_TOKENS.
449
+
450
+ Format: ``token=principal`` pairs separated by commas. Each entry is
451
+ split on the *first* ``=`` only, so principals may contain ``=``
452
+ (e.g. base64-encoded values). However, tokens themselves **must not**
453
+ contain ``=`` or ``,`` because those characters are used as delimiters.
454
+ """
455
+ raw = os.environ.get("VGI_BEARER_TOKENS")
456
+ if not raw:
457
+ return None
458
+
459
+ from vgi_rpc.http import bearer_authenticate_static
460
+ from vgi_rpc.rpc import AuthContext
461
+
462
+ tokens: dict[str, AuthContext] = {}
463
+ for entry in raw.split(","):
464
+ entry = entry.strip()
465
+ if not entry:
466
+ continue
467
+ if "=" not in entry:
468
+ sys.stderr.write(
469
+ f"Error: malformed VGI_BEARER_TOKENS entry: {entry!r}\n"
470
+ "Expected format: token=principal (e.g. 'mytoken=alice')\n"
471
+ )
472
+ sys.exit(1)
473
+ token, principal = entry.split("=", 1)
474
+ tokens[token] = AuthContext(principal=principal, authenticated=True, domain="bearer")
475
+
476
+ if not tokens:
477
+ return None
478
+ return bearer_authenticate_static(tokens=tokens)
479
+
480
+
481
+ def _resolve_jwt_authenticate() -> Callable[..., Any] | None:
482
+ """Build a jwt_authenticate callback from VGI_JWT_ISSUER + VGI_JWT_AUDIENCE.
483
+
484
+ ``VGI_JWT_ISSUER`` may be a single issuer URL or a comma-separated list
485
+ for multi-tenant setups (e.g. Microsoft Entra with multiple tenants).
486
+ """
487
+ issuer_raw = os.environ.get("VGI_JWT_ISSUER")
488
+ if not issuer_raw:
489
+ return None
490
+
491
+ issuers = tuple(s.strip() for s in issuer_raw.split(",") if s.strip())
492
+ if not issuers:
493
+ sys.stderr.write("Error: VGI_JWT_ISSUER is set but contains no valid values\n")
494
+ sys.exit(1)
495
+
496
+ audience_raw = os.environ.get("VGI_JWT_AUDIENCE")
497
+ if not audience_raw:
498
+ sys.stderr.write("Error: VGI_JWT_ISSUER is set but VGI_JWT_AUDIENCE is missing\n")
499
+ sys.exit(1)
500
+
501
+ audiences = tuple(s.strip() for s in audience_raw.split(",") if s.strip())
502
+ if not audiences:
503
+ sys.stderr.write("Error: VGI_JWT_AUDIENCE is set but contains no valid values\n")
504
+ sys.exit(1)
505
+
506
+ try:
507
+ from vgi_rpc.http._oauth_jwt import jwt_authenticate
508
+ except ImportError:
509
+ sys.stderr.write(
510
+ "Error: JWT auth requires the oauth extra.\n"
511
+ "Install with: pip install vgi[oauth] (or: uv sync --extra oauth)\n"
512
+ )
513
+ sys.exit(1)
514
+
515
+ jwks_uri = os.environ.get("VGI_JWT_JWKS_URI")
516
+ # Pass a single string when only one issuer (backwards compatible),
517
+ # or a tuple when multiple issuers are configured.
518
+ issuer: str | tuple[str, ...] = issuers[0] if len(issuers) == 1 else issuers
519
+ return jwt_authenticate(issuer=issuer, audience=audiences, jwks_uri=jwks_uri)
520
+
521
+
522
+ def _resolve_oauth_resource_metadata() -> Any:
523
+ """Build OAuthResourceMetadata from environment variables.
524
+
525
+ Supported env vars:
526
+
527
+ - ``VGI_OAUTH_RESOURCE``: canonical resource URL (required to enable).
528
+ - ``VGI_OAUTH_AUTH_SERVERS``: comma-separated authorization server URLs.
529
+ - ``VGI_OAUTH_SCOPES``: comma-separated supported scopes (optional).
530
+ - ``VGI_OAUTH_RESOURCE_NAME``: human-readable name (optional).
531
+ - ``VGI_OAUTH_CLIENT_ID``: client ID for MCP compatibility (optional, URL-safe chars only).
532
+ - ``VGI_OAUTH_DEVICE_CODE_CLIENT_ID``: client ID for device-code flow (optional, URL-safe chars only).
533
+ - ``VGI_OAUTH_DEVICE_CODE_CLIENT_SECRET``: client secret for device-code flow (optional, URL-safe chars only).
534
+ - ``VGI_OAUTH_USE_ID_TOKEN``: when set to ``1``/``true``/``yes``, tells clients
535
+ to use the OIDC ``id_token`` as Bearer instead of the ``access_token``.
536
+
537
+ Returns:
538
+ OAuthResourceMetadata instance, or None if not configured.
539
+
540
+ """
541
+ resource = os.environ.get("VGI_OAUTH_RESOURCE")
542
+ if not resource:
543
+ return None
544
+
545
+ auth_servers_raw = os.environ.get("VGI_OAUTH_AUTH_SERVERS")
546
+ if not auth_servers_raw:
547
+ sys.stderr.write("Error: VGI_OAUTH_RESOURCE is set but VGI_OAUTH_AUTH_SERVERS is missing\n")
548
+ sys.exit(1)
549
+
550
+ try:
551
+ from vgi_rpc.http import OAuthResourceMetadata
552
+ except ImportError:
553
+ sys.stderr.write(
554
+ "Error: OAuth metadata requires the http extra.\n"
555
+ "Install with: pip install vgi[http] (or: uv sync --extra http)\n"
556
+ )
557
+ sys.exit(1)
558
+
559
+ auth_servers = tuple(s.strip() for s in auth_servers_raw.split(",") if s.strip())
560
+ scopes_raw = os.environ.get("VGI_OAUTH_SCOPES")
561
+ scopes = tuple(s.strip() for s in scopes_raw.split(",") if s.strip()) if scopes_raw else ()
562
+ resource_name = os.environ.get("VGI_OAUTH_RESOURCE_NAME")
563
+ client_id = os.environ.get("VGI_OAUTH_CLIENT_ID")
564
+ client_secret = os.environ.get("VGI_OAUTH_CLIENT_SECRET")
565
+ device_code_client_id = os.environ.get("VGI_OAUTH_DEVICE_CODE_CLIENT_ID")
566
+ device_code_client_secret = os.environ.get("VGI_OAUTH_DEVICE_CODE_CLIENT_SECRET")
567
+ use_id_token = os.environ.get("VGI_OAUTH_USE_ID_TOKEN", "").lower() in ("1", "true", "yes")
568
+
569
+ try:
570
+ return OAuthResourceMetadata(
571
+ resource=resource,
572
+ authorization_servers=auth_servers,
573
+ scopes_supported=scopes,
574
+ resource_name=resource_name,
575
+ client_id=client_id,
576
+ client_secret=client_secret,
577
+ device_code_client_id=device_code_client_id,
578
+ device_code_client_secret=device_code_client_secret,
579
+ use_id_token_as_bearer=use_id_token,
580
+ )
581
+ except ValueError as exc:
582
+ sys.stderr.write(f"Error: invalid OAuth config: {exc}\n")
583
+ sys.exit(1)
584
+
585
+
586
+ def _maybe_init_sentry() -> None:
587
+ """Initialise ``sentry_sdk`` from environment when ``SENTRY_DSN`` is set.
588
+
589
+ Reads the standard Sentry env vars (``SENTRY_DSN``, ``SENTRY_ENVIRONMENT``,
590
+ ``SENTRY_RELEASE``, ``SENTRY_TRACES_SAMPLE_RATE``) and calls
591
+ ``sentry_sdk.init()`` so that ``vgi-rpc``'s auto-attach hook in
592
+ ``RpcServer.__init__`` picks up Sentry instrumentation.
593
+
594
+ Silent no-op when ``SENTRY_DSN`` is unset or ``vgi[sentry]`` is not
595
+ installed.
596
+ """
597
+ if not os.environ.get("SENTRY_DSN"):
598
+ return
599
+ try:
600
+ import sentry_sdk
601
+ except ImportError:
602
+ sys.stderr.write(
603
+ "Warning: SENTRY_DSN is set but sentry-sdk is not installed.\n"
604
+ "Install with: pip install vgi[sentry] (or: uv sync --extra sentry)\n"
605
+ )
606
+ return
607
+
608
+ if sentry_sdk.is_initialized():
609
+ return
610
+
611
+ init_kwargs: dict[str, Any] = {}
612
+ environment = os.environ.get("SENTRY_ENVIRONMENT")
613
+ if environment:
614
+ init_kwargs["environment"] = environment
615
+ release = os.environ.get("SENTRY_RELEASE")
616
+ if not release:
617
+ # Fall back to the installed vgi package version so non-deploy runs
618
+ # still get a Sentry release tag (Sentry's UI degrades when release
619
+ # is unset). Production deploys should set SENTRY_RELEASE to a git
620
+ # SHA or tag for commit tracking.
621
+ try:
622
+ from importlib.metadata import PackageNotFoundError, version
623
+
624
+ release = version("vgi-python")
625
+ except PackageNotFoundError:
626
+ release = None
627
+ if release:
628
+ init_kwargs["release"] = release
629
+ sample_raw = os.environ.get("SENTRY_TRACES_SAMPLE_RATE")
630
+ if sample_raw:
631
+ try:
632
+ init_kwargs["traces_sample_rate"] = float(sample_raw)
633
+ except ValueError:
634
+ sys.stderr.write(f"Error: SENTRY_TRACES_SAMPLE_RATE must be a float, got {sample_raw!r}\n")
635
+ sys.exit(1)
636
+ sentry_sdk.init(**init_kwargs)
637
+
638
+
639
+ def _resolve_otel_config() -> Any:
640
+ """Build an ``OtelConfig`` from environment variables.
641
+
642
+ Supported env vars:
643
+
644
+ - ``VGI_OTEL_ENABLED``: enable OTEL (``1``/``true``/``yes``).
645
+ - ``VGI_OTEL_CUSTOM_ATTRIBUTES``: comma-separated ``key=value`` pairs.
646
+ - ``VGI_OTEL_CLAIM_ATTRIBUTES``: comma-separated ``claim_key=span_attr_name`` pairs.
647
+ - ``VGI_OTEL_DISABLE_TRACING``: disable tracing only (``1``/``true``/``yes``).
648
+ - ``VGI_OTEL_DISABLE_METRICS``: disable metrics only (``1``/``true``/``yes``).
649
+
650
+ Returns:
651
+ OtelConfig instance, or None if not enabled.
652
+
653
+ """
654
+ enabled = os.environ.get("VGI_OTEL_ENABLED", "").lower() in ("1", "true", "yes")
655
+ if not enabled:
656
+ return None
657
+
658
+ try:
659
+ from vgi_rpc.otel import OtelConfig
660
+ except ImportError:
661
+ sys.stderr.write(
662
+ "Error: OTEL support requires the otel extra.\n"
663
+ "Install with: pip install vgi[otel] (or: uv sync --extra otel)\n"
664
+ )
665
+ sys.exit(1)
666
+
667
+ custom_attributes: dict[str, str] = {}
668
+ raw_custom = os.environ.get("VGI_OTEL_CUSTOM_ATTRIBUTES", "")
669
+ if raw_custom:
670
+ for entry in raw_custom.split(","):
671
+ entry = entry.strip()
672
+ if not entry:
673
+ continue
674
+ if "=" not in entry:
675
+ sys.stderr.write(
676
+ f"Error: malformed VGI_OTEL_CUSTOM_ATTRIBUTES entry: {entry!r}\n"
677
+ "Expected format: key=value (e.g. 'deployment=prod')\n"
678
+ )
679
+ sys.exit(1)
680
+ key, value = entry.split("=", 1)
681
+ custom_attributes[key.strip()] = value.strip()
682
+
683
+ claim_attributes: dict[str, str] = {}
684
+ raw_claims = os.environ.get("VGI_OTEL_CLAIM_ATTRIBUTES", "")
685
+ if raw_claims:
686
+ for entry in raw_claims.split(","):
687
+ entry = entry.strip()
688
+ if not entry:
689
+ continue
690
+ if "=" not in entry:
691
+ sys.stderr.write(
692
+ f"Error: malformed VGI_OTEL_CLAIM_ATTRIBUTES entry: {entry!r}\n"
693
+ "Expected format: claim_key=span_attr_name (e.g. 'tenant_id=rpc.vgi_rpc.auth.claim.tenant_id')\n"
694
+ )
695
+ sys.exit(1)
696
+ key, value = entry.split("=", 1)
697
+ claim_attributes[key.strip()] = value.strip()
698
+
699
+ disable_tracing = os.environ.get("VGI_OTEL_DISABLE_TRACING", "").lower() in ("1", "true", "yes")
700
+ disable_metrics = os.environ.get("VGI_OTEL_DISABLE_METRICS", "").lower() in ("1", "true", "yes")
701
+
702
+ return OtelConfig(
703
+ enable_tracing=not disable_tracing,
704
+ enable_metrics=not disable_metrics,
705
+ custom_attributes=custom_attributes,
706
+ claim_attributes=claim_attributes,
707
+ )
708
+
709
+
710
+ def _serve_http(
711
+ worker_cls: type[Worker],
712
+ *,
713
+ effective_level: int,
714
+ host: str,
715
+ port: int | None,
716
+ prefix: str,
717
+ cors_origins: str,
718
+ describe: bool,
719
+ signing_key: bytes | None,
720
+ authenticate: Callable[..., Any] | None = None,
721
+ oauth_resource_metadata: Any = None,
722
+ otel_config: Any = None,
723
+ max_stream_response_bytes: int | None = None,
724
+ ) -> None:
725
+ """Start the worker as an HTTP server."""
726
+ import socket
727
+
728
+ try:
729
+ import waitress # type: ignore[import-untyped]
730
+ except ImportError:
731
+ sys.stderr.write(
732
+ "Error: waitress not installed.\nInstall with: pip install vgi[http] (or: uv sync --extra http)\n"
733
+ )
734
+ sys.exit(1)
735
+
736
+ # Port resolution: explicit --port > $PORT env var > 8080
737
+ if port is None:
738
+ env_port = os.environ.get("PORT")
739
+ port = int(env_port) if env_port else 8080
740
+
741
+ if port == 0:
742
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
743
+ s.bind((host, 0))
744
+ port = int(s.getsockname()[1])
745
+
746
+ wsgi_app = create_app(
747
+ worker_cls,
748
+ prefix=prefix,
749
+ cors_origins=cors_origins,
750
+ describe=describe,
751
+ signing_key=signing_key,
752
+ log_level=effective_level,
753
+ authenticate=authenticate,
754
+ oauth_resource_metadata=oauth_resource_metadata,
755
+ otel_config=otel_config,
756
+ max_stream_response_bytes=max_stream_response_bytes,
757
+ )
758
+
759
+ # Machine-readable port for process managers and test harnesses
760
+ print(f"PORT:{port}", flush=True)
761
+ _logger.info("http_server_starting host=%s port=%d prefix=%s", host, port, prefix)
762
+ sys.stderr.write(f"Serving {worker_cls.__name__} on http://{host}:{port}{prefix}\n")
763
+ sys.stderr.flush()
764
+
765
+ waitress.serve(wsgi_app, host=host, port=port, _quiet=True)
766
+
767
+
768
+ if __name__ == "__main__":
769
+ main()