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.
- vgi/__init__.py +152 -0
- vgi/_duckdb.py +62 -0
- vgi/_storage_profile.py +132 -0
- vgi/_test_fixtures/__init__.py +20 -0
- vgi/_test_fixtures/accumulate/__init__.py +19 -0
- vgi/_test_fixtures/accumulate/worker.py +762 -0
- vgi/_test_fixtures/aggregate/__init__.py +62 -0
- vgi/_test_fixtures/aggregate/_common.py +21 -0
- vgi/_test_fixtures/aggregate/basic.py +232 -0
- vgi/_test_fixtures/aggregate/dynamic.py +409 -0
- vgi/_test_fixtures/aggregate/generic.py +86 -0
- vgi/_test_fixtures/aggregate/listagg.py +71 -0
- vgi/_test_fixtures/aggregate/percentile.py +107 -0
- vgi/_test_fixtures/aggregate/streaming.py +192 -0
- vgi/_test_fixtures/aggregate/varargs.py +75 -0
- vgi/_test_fixtures/aggregate/window.py +380 -0
- vgi/_test_fixtures/attach_options.py +308 -0
- vgi/_test_fixtures/bad_protocol.py +62 -0
- vgi/_test_fixtures/cancellable.py +336 -0
- vgi/_test_fixtures/catalog.py +813 -0
- vgi/_test_fixtures/http_server.py +394 -0
- vgi/_test_fixtures/nest_tensor.py +614 -0
- vgi/_test_fixtures/orchard_catalog.py +47 -0
- vgi/_test_fixtures/projection_repro/__init__.py +6 -0
- vgi/_test_fixtures/projection_repro/worker.py +454 -0
- vgi/_test_fixtures/scalar/__init__.py +116 -0
- vgi/_test_fixtures/scalar/_common.py +69 -0
- vgi/_test_fixtures/scalar/arithmetic.py +321 -0
- vgi/_test_fixtures/scalar/binary.py +120 -0
- vgi/_test_fixtures/scalar/formatting.py +176 -0
- vgi/_test_fixtures/scalar/geo.py +300 -0
- vgi/_test_fixtures/scalar/null_handling.py +107 -0
- vgi/_test_fixtures/scalar/random_demo.py +171 -0
- vgi/_test_fixtures/scalar/settings_secrets.py +102 -0
- vgi/_test_fixtures/scalar/type_info.py +219 -0
- vgi/_test_fixtures/schema_reconcile/__init__.py +29 -0
- vgi/_test_fixtures/schema_reconcile/worker.py +653 -0
- vgi/_test_fixtures/simple_writable.py +793 -0
- vgi/_test_fixtures/table/__init__.py +221 -0
- vgi/_test_fixtures/table/_common.py +162 -0
- vgi/_test_fixtures/table/batch_index.py +283 -0
- vgi/_test_fixtures/table/batch_index_broken.py +200 -0
- vgi/_test_fixtures/table/catalog_scans.py +162 -0
- vgi/_test_fixtures/table/filters.py +1005 -0
- vgi/_test_fixtures/table/late_materialization.py +249 -0
- vgi/_test_fixtures/table/make_series.py +273 -0
- vgi/_test_fixtures/table/misc.py +499 -0
- vgi/_test_fixtures/table/order_modes.py +164 -0
- vgi/_test_fixtures/table/pairs.py +437 -0
- vgi/_test_fixtures/table/partition_columns.py +472 -0
- vgi/_test_fixtures/table/partition_columns_broken.py +304 -0
- vgi/_test_fixtures/table/profiling_example.py +195 -0
- vgi/_test_fixtures/table/required_filters.py +234 -0
- vgi/_test_fixtures/table/sequence.py +710 -0
- vgi/_test_fixtures/table/settings.py +426 -0
- vgi/_test_fixtures/table/transaction_storage.py +162 -0
- vgi/_test_fixtures/table/tt_pushdown.py +191 -0
- vgi/_test_fixtures/table/versioned.py +230 -0
- vgi/_test_fixtures/table_in_out.py +1392 -0
- vgi/_test_fixtures/versioned.py +155 -0
- vgi/_test_fixtures/versioned_tables.py +595 -0
- vgi/_test_fixtures/worker.py +1631 -0
- vgi/_test_fixtures/writable/__init__.py +8 -0
- vgi/_test_fixtures/writable/generic.py +236 -0
- vgi/_test_fixtures/writable/table.py +149 -0
- vgi/_test_fixtures/writable/worker.py +1148 -0
- vgi/aggregate_function.py +607 -0
- vgi/argument_spec.py +472 -0
- vgi/arguments.py +1747 -0
- vgi/auth.py +55 -0
- vgi/catalog/__init__.py +88 -0
- vgi/catalog/attach_option.py +206 -0
- vgi/catalog/catalog_interface.py +2767 -0
- vgi/catalog/descriptors.py +870 -0
- vgi/catalog/duckdb_statistics.py +377 -0
- vgi/catalog/secret_type.py +96 -0
- vgi/catalog/setting.py +253 -0
- vgi/catalog/storage.py +372 -0
- vgi/client/__init__.py +67 -0
- vgi/client/catalog_mixin.py +1251 -0
- vgi/client/cli.py +582 -0
- vgi/client/cli_catalog.py +182 -0
- vgi/client/cli_schema.py +270 -0
- vgi/client/cli_table.py +907 -0
- vgi/client/cli_transaction.py +97 -0
- vgi/client/cli_utils.py +441 -0
- vgi/client/cli_view.py +303 -0
- vgi/client/client.py +2183 -0
- vgi/exceptions.py +205 -0
- vgi/function.py +245 -0
- vgi/function_storage.py +1636 -0
- vgi/function_storage_azure_sql.py +922 -0
- vgi/function_storage_cf_do.py +740 -0
- vgi/http/__init__.py +25 -0
- vgi/http/demo_storage.py +212 -0
- vgi/http/worker_page.py +1252 -0
- vgi/invocation.py +154 -0
- vgi/logging_config.py +93 -0
- vgi/meta_worker.py +661 -0
- vgi/metadata.py +1403 -0
- vgi/otel.py +406 -0
- vgi/protocol.py +2418 -0
- vgi/protocol_version.txt +1 -0
- vgi/py.typed +0 -0
- vgi/scalar_function.py +1211 -0
- vgi/schema_utils.py +234 -0
- vgi/secret_protocol.py +124 -0
- vgi/secret_service.py +238 -0
- vgi/serve.py +769 -0
- vgi/table_buffering_function.py +443 -0
- vgi/table_filter_pushdown.py +1528 -0
- vgi/table_function.py +1130 -0
- vgi/table_in_out_function.py +383 -0
- vgi/transactor/__init__.py +24 -0
- vgi/transactor/_duckdb_compat.py +27 -0
- vgi/transactor/client.py +137 -0
- vgi/transactor/protocol.py +149 -0
- vgi/transactor/server.py +740 -0
- vgi/worker.py +4761 -0
- vgi_python-0.8.0.dist-info/METADATA +735 -0
- vgi_python-0.8.0.dist-info/RECORD +124 -0
- vgi_python-0.8.0.dist-info/WHEEL +4 -0
- vgi_python-0.8.0.dist-info/entry_points.txt +5 -0
- 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()
|