From 6797d7478c8a1185c465dd222597d2a571b46218 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:29:11 +0200 Subject: [PATCH 1/6] ci: run and lint packages/grpc-web in the main workflow --- .github/workflows/main.yaml | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 489ff9504..e2004af1c 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -45,9 +45,9 @@ jobs: cache: 'pip' # caching pip dependencies - run: pip install -r requirements-devel.txt - name: "Ruff lint" - run: ruff check weaviate test mock_tests integration + run: ruff check weaviate test mock_tests integration packages/grpc-web - name: "Ruff format" - run: ruff format --diff weaviate test mock_tests integration + run: ruff format --diff weaviate test mock_tests integration packages/grpc-web - name: "Flake 8" run: flake8 weaviate test mock_tests integration - name: "Check release for pypi" @@ -105,6 +105,26 @@ jobs: name: coverage-report-${{ matrix.folder }} path: coverage-${{ matrix.folder }}.xml + grpc-web-tests: + name: Run gRPC-Web Package Tests + runs-on: ubuntu-latest + timeout-minutes: 5 + strategy: + fail-fast: false + matrix: + version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ matrix.version }} + cache: 'pip' # caching pip dependencies + - run: | + pip install -r requirements-test.txt -r requirements-devel.txt + pip install -e . -e packages/grpc-web + - name: Run grpc-web package tests + run: pytest packages/grpc-web/tests + proto-test: name: Run importing protos test runs-on: ubuntu-latest From 6367217e07b0390e3c896240ca515fb681d818a5 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:30:02 +0200 Subject: [PATCH 2/6] fix(grpc-web): treat a missing grpc-status trailer as INTERNAL, not success Per the grpc-web contract every unary response must carry a grpc-status (trailer frame or header). A proxy that dropped the trailer frame previously read as OK and returned the first message frame. --- .../src/weaviate_grpc_web/_channel.py | 7 +++++++ packages/grpc-web/tests/test_transport.py | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/packages/grpc-web/src/weaviate_grpc_web/_channel.py b/packages/grpc-web/src/weaviate_grpc_web/_channel.py index e2dcb4dd7..aef41241f 100644 --- a/packages/grpc-web/src/weaviate_grpc_web/_channel.py +++ b/packages/grpc-web/src/weaviate_grpc_web/_channel.py @@ -246,6 +246,13 @@ def _handle_response( code=_status_from_http(http_status), details=f"HTTP {http_status} from grpc-web endpoint", ) + if messages: + # Every grpc-web unary response must carry a grpc-status (trailer frame + # or header); a proxy that drops the trailer must not read as success. + raise AioRpcError( + code=StatusCode.INTERNAL, + details="grpc-web response missing grpc-status trailers", + ) code = StatusCode.OK else: code = status_from_int(int(raw_status)) diff --git a/packages/grpc-web/tests/test_transport.py b/packages/grpc-web/tests/test_transport.py index bcbee6ed7..90268c02a 100644 --- a/packages/grpc-web/tests/test_transport.py +++ b/packages/grpc-web/tests/test_transport.py @@ -197,6 +197,27 @@ def test_empty_ok_response_with_grpc_status_has_no_cors_hint(): assert "Access-Control-Expose-Headers" not in str(excinfo.value.details()) +def test_message_frame_without_grpc_status_is_internal_not_success(): + # HTTP 200 with a valid message frame but no grpc-status anywhere (e.g. a proxy + # dropped the trailer frame) must be an error, never a fabricated success + channel = _channel(FakeSender(status=200, headers={}, body=_frame(b"reply-bytes"))) + mc = channel.unary_unary("/svc/M", lambda x: x, lambda b: b) + with pytest.raises(AioRpcError) as excinfo: + asyncio.run(mc(b"q")) + assert excinfo.value.code() is StatusCode.INTERNAL + assert "missing grpc-status" in str(excinfo.value.details()) + + +def test_message_frame_with_grpc_status_header_still_succeeds(): + # trailers-only-in-headers responses (grpc-status as an HTTP header, no trailer + # frame) remain valid per the grpc-web contract + channel = _channel( + FakeSender(status=200, headers={"grpc-status": "0"}, body=_frame(b"reply-bytes")) + ) + mc = channel.unary_unary("/svc/M", lambda x: x, lambda b: b) + assert asyncio.run(mc(b"q")) == b"reply-bytes" + + def test_stream_stream_error_recommends_insert_many_only(): # batch.dynamic()/fixed_size()/rate_limit() do not exist on the async client (the # only one supported under WASM), so the error must not recommend them From 838387b89948704948c558ab7a1e681277fd2d1b Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:32:03 +0200 Subject: [PATCH 3/6] test: pin the grpcio fallback version against the vendored stub gates Fails on drift in either direction: the Emscripten fallback must pass every vendored *_pb2_grpc.py version gate, and the grpc-web shim's FAKE_GRPC_VERSION must equal weaviate.proto.v1._GRPCIO_FALLBACK_VERSION. --- packages/grpc-web/tests/test_shim_install.py | 12 +++++ proto_test/test_proto.py | 46 ++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/packages/grpc-web/tests/test_shim_install.py b/packages/grpc-web/tests/test_shim_install.py index c950da902..0661a46b5 100644 --- a/packages/grpc-web/tests/test_shim_install.py +++ b/packages/grpc-web/tests/test_shim_install.py @@ -109,3 +109,15 @@ async def main(): ) assert result.returncode == 0, result.stderr assert "OK" in result.stdout + + +def test_fake_grpc_version_matches_base_fallback(): + # In-process on purpose: nothing here installs the shim, we only compare the two + # copies of the pinned version. The shim advertises FAKE_GRPC_VERSION as + # grpc.__version__ and the base package falls back to _GRPCIO_FALLBACK_VERSION + # under Emscripten — the vendored stubs' version gates see both, so they must + # never drift apart. + from weaviate.proto.v1 import _GRPCIO_FALLBACK_VERSION + from weaviate_grpc_web._shim import FAKE_GRPC_VERSION + + assert FAKE_GRPC_VERSION == _GRPCIO_FALLBACK_VERSION diff --git a/proto_test/test_proto.py b/proto_test/test_proto.py index 54dd89a15..723429da4 100644 --- a/proto_test/test_proto.py +++ b/proto_test/test_proto.py @@ -1,4 +1,6 @@ import importlib +import pathlib +import re from importlib.metadata import PackageNotFoundError, version as metadata_version import pytest @@ -98,3 +100,47 @@ def test_get_version_passthrough_when_installed(monkeypatch): monkeypatch.setattr(mod, "metadata_version", lambda pkg: "1.2.3") assert str(mod.get_version("grpcio")) == "1.2.3" assert str(mod.get_version("protobuf")) == "1.2.3" + + +@pytest.mark.skipif( + _INCOMPATIBLE_GRPC_PB, + reason="weaviate.proto.v1 cannot be imported with an incompatible grpcio/protobuf " + "pair (CI version-gate matrix); the gate is covered by test_proto_import and the " + "fallback is exercised in every compatible cell", +) +def test_grpcio_fallback_version_passes_every_vendored_stub_gate(): + """The Emscripten fallback version must satisfy every vendored stub's version gate. + + Under Pyodide ``get_version("grpcio")`` returns ``_GRPCIO_FALLBACK_VERSION`` and the + shim reports it as ``grpc.__version__``, so every vendored ``*_pb2_grpc.py`` whose + import-time gate (``first_version_is_lower``) rejects it would break at import. If + the protos are regenerated with a newer grpcio-tools, this fails until the fallback + (and the grpc-web shim's ``FAKE_GRPC_VERSION``) is bumped to match. + """ + try: + from grpc._utilities import first_version_is_lower + except ImportError: + pytest.skip( + "grpc._utilities.first_version_is_lower is unavailable in this grpcio; " + "newer matrix cells run the comparison" + ) + + fallback = importlib.import_module("weaviate.proto.v1")._GRPCIO_FALLBACK_VERSION + proto_root = pathlib.Path(__file__).resolve().parents[1] / "weaviate" / "proto" / "v1" + stub_files = sorted(proto_root.glob("*/v1/*_pb2_grpc.py")) + assert stub_files, "no vendored *_pb2_grpc.py stubs found" + + gate_pattern = re.compile(r"^GRPC_GENERATED_VERSION = '([^']+)'", re.MULTILINE) + gated = 0 + for stub in stub_files: + match = gate_pattern.search(stub.read_text()) + if match is None: + continue # older codegen (e.g. v4216) emits no version gate + gated += 1 + generated = match.group(1) + assert not first_version_is_lower(fallback, generated), ( + f"{stub.relative_to(proto_root)} requires grpcio>={generated} but " + f"_GRPCIO_FALLBACK_VERSION is {fallback}; bump the fallback (and the " + "grpc-web shim's FAKE_GRPC_VERSION) to match the regenerated stubs" + ) + assert gated > 0, "no stub carried a GRPC_GENERATED_VERSION gate; check the extraction regex" From 412f879395d80e8649c5219c70285f9773e217c6 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:34:57 +0200 Subject: [PATCH 4/6] fix(wasm): reject sync client construction under Emscripten with the async-only error With PyPI httpx the sync REST path previously failed first with an opaque WeaviateStartUpError ConnectError; the shim's clear async-only guidance was only reachable at open_connection_grpc. Raise it at ConnectionSync construction instead. --- test/test_wasm_compat.py | 19 +++++++++++++++++++ weaviate/connect/v4.py | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/test/test_wasm_compat.py b/test/test_wasm_compat.py index dc3355bc9..56ddd17ca 100644 --- a/test/test_wasm_compat.py +++ b/test/test_wasm_compat.py @@ -10,6 +10,8 @@ import pytest from httpx import ConnectError, ReadTimeout +from weaviate import WeaviateAsyncClient, WeaviateClient +from weaviate.connect.base import ConnectionParams from weaviate.connect.v4 import _ConnectionBase, _exc_detail from weaviate.embedded import _EmbeddedBase from weaviate.exceptions import ( @@ -33,6 +35,23 @@ def test_embedded_platform_check_passes_on_supported_platforms() -> None: _EmbeddedBase.check_supported_platform() # must not raise on this dev platform +def test_sync_client_construction_raises_async_only_under_emscripten(monkeypatch) -> None: + # without the guard the sync client constructs fine and the first REST call fails + # with an opaque ConnectError; the clear async-only error must win, at construction + monkeypatch.setattr(sys, "platform", "emscripten") + with pytest.raises(WeaviateStartUpError, match="async client"): + WeaviateClient(connection_params=ConnectionParams.from_url("http://localhost:8080", 50051)) + + +def test_async_client_construction_allowed_under_emscripten(monkeypatch) -> None: + # the async client is the supported one under WASM — the guard must not catch it + monkeypatch.setattr(sys, "platform", "emscripten") + client = WeaviateAsyncClient( + connection_params=ConnectionParams.from_url("http://localhost:8080", 50051) + ) + assert client is not None + + def _handle_exceptions(e: Exception, error_msg: str = "") -> None: conn = object.__new__(_ConnectionBase) # keep the bare instance's __del__ quiet (it checks these for unclosed connections) diff --git a/weaviate/connect/v4.py b/weaviate/connect/v4.py index 8ff05816b..f80c2385c 100644 --- a/weaviate/connect/v4.py +++ b/weaviate/connect/v4.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import sys import time from copy import copy from dataclasses import dataclass, field @@ -985,6 +986,23 @@ def resp(res: Response) -> Optional[Dict[str, Any]]: class ConnectionSync(_ConnectionBase): """Connection class used to communicate to a weaviate instance.""" + def __init__(self, *args: Any, **kwargs: Any) -> None: + if sys.platform == "emscripten": + # Fail at construction with the async-only message; otherwise the first + # REST call surfaces an opaque ConnectError long before the grpc-web + # shim's own sync guard is reached (wording mirrors the shim's + # _ASYNC_ONLY_MESSAGE). Pre-set the attributes __del__ reads so the + # never-initialized instance is collected quietly. + self._client = None + self._grpc_channel = None + raise WeaviateStartUpError( + "The synchronous client is not supported under WebAssembly/Pyodide. " + "Use an async client (weaviate.use_async_with_local / " + "use_async_with_weaviate_cloud / use_async_with_custom, or " + "WeaviateAsyncClient) instead." + ) + super().__init__(*args, **kwargs) + def connect(self, force: bool = False) -> None: if self._connected and not force: return None From 275680f0b4d37b493ad8d2a045def7ea51b13094 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:56:37 +0200 Subject: [PATCH 5/6] test(pyodide): add an in-Pyodide e2e suite driven by a Node runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run.mjs loads a pinned Pyodide (314.0.4, CPython 3.14) under Node, micropip-installs the two locally built pure wheels (grpcio skipped via its emscripten marker; pydantic_core/cryptography come from the Pyodide distribution — pydantic_core has no wasm wheel on PyPI, which also rules out the 0.28.x line whose bundled pydantic 2.10.6 predates our >=2.12 pin), then awaits e2e.py against Weaviate's core-native /v1/grpc-web endpoint via grpc_path_prefix: connect with live init checks, insert_many, queries/filters/aggregations, multi-tenancy with per-tenant batch insert/delete, error mapping, and the batch.stream()/ experimental() fail-fast. anyio is installed explicitly: Pyodide's httpx recipe drops it but authlib imports it directly. --- .gitignore | 2 + ci/pyodide-e2e/e2e.py | 153 ++++++++++++++++++++++++++++++++++++ ci/pyodide-e2e/package.json | 8 ++ ci/pyodide-e2e/run.mjs | 73 +++++++++++++++++ 4 files changed, 236 insertions(+) create mode 100644 ci/pyodide-e2e/e2e.py create mode 100644 ci/pyodide-e2e/package.json create mode 100644 ci/pyodide-e2e/run.mjs diff --git a/.gitignore b/.gitignore index bc3a48c66..395b51d6c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,5 @@ scratch/ *-test.sh *.hdf5 *.jsonl +ci/pyodide-e2e/node_modules/ +ci/pyodide-e2e/package-lock.json diff --git a/ci/pyodide-e2e/e2e.py b/ci/pyodide-e2e/e2e.py new file mode 100644 index 000000000..43b99a903 --- /dev/null +++ b/ci/pyodide-e2e/e2e.py @@ -0,0 +1,153 @@ +"""In-Pyodide e2e for the Weaviate client over core-native grpc-web. + +Executed by ``run.mjs`` inside Pyodide under Node: the runner runs this module's code +(imports below install the grpc shim + fetch transport) and then awaits ``main()`` on +Pyodide's event loop. Plain asserts with one ``OK`` line per step so CI logs are +diagnosable; any failure exits nonzero. + +Deliberately not covered: browser/CORS behaviour (this runs under Node, no CORS layer) +and OIDC auth flows (anonymous access only). +""" + +import os +import warnings + +import weaviate_grpc_web # bootstraps the grpc shim + fetch transport under Emscripten + +import grpc +import weaviate +import weaviate.classes as wvc +from weaviate.classes.config import DataType, Property +from weaviate.classes.query import Filter +from weaviate.classes.tenants import Tenant +from weaviate.exceptions import WeaviateBatchStreamError, WeaviateQueryError + +COLL = "PyodideE2E" +MT_COLL = "PyodideE2ETenants" +# Weaviate core serves grpc-web natively on the REST port under this prefix +# (default-on since 1.38.3), so no proxy sits between the client and the server. +GRPC_WEB_PREFIX = "/v1/grpc-web" + + +def ok(step: str) -> None: + print(f"OK {step}", flush=True) + + +async def main() -> None: + assert weaviate_grpc_web.is_installed(), "grpc shim did not install under Emscripten" + assert getattr(grpc, "__weaviate_grpc_web_shim__", False), "sys.modules['grpc'] is not the shim" + + host = os.environ.get("WEAVIATE_HOST", "localhost") + port = int(os.environ.get("WEAVIATE_PORT", "8090")) + client = weaviate.use_async_with_custom( + http_host=host, + http_port=port, + http_secure=False, + grpc_host=host, + grpc_port=port, + grpc_secure=False, + grpc_path_prefix=GRPC_WEB_PREFIX, + ) + # No skip_init_checks: connect() performs the gRPC health check over grpc-web. + await client.connect() + ok("connect (health check over grpc-web)") + + try: + for name in (COLL, MT_COLL): + if await client.collections.exists(name): + await client.collections.delete(name) + + await client.collections.create( + COLL, + vector_config=wvc.config.Configure.Vectors.self_provided(), + properties=[ + Property(name="title", data_type=DataType.TEXT), + Property(name="idx", data_type=DataType.INT), + ], + ) + ok("collections.create") + + coll = client.collections.get(COLL) + ret = await coll.data.insert_many([{"title": f"article {i}", "idx": i} for i in range(50)]) + assert not ret.has_errors and len(ret.uuids) == 50, f"insert_many errors: {ret.errors}" + ok("insert_many (BatchObjects) = 50") + + res = await coll.query.fetch_objects(limit=100) + assert len(res.objects) == 50, f"fetch_objects got {len(res.objects)}" + ok("query.fetch_objects = 50") + + res = await coll.query.bm25("article", limit=5) + assert len(res.objects) == 5, f"bm25 got {len(res.objects)}" + ok("query.bm25 limit=5 = 5") + + res = await coll.query.fetch_objects( + filters=Filter.by_property("idx").less_than(10), limit=100 + ) + assert len(res.objects) == 10, f"filtered fetch_objects got {len(res.objects)}" + ok("query.fetch_objects filtered idx<10 = 10") + + agg = await coll.aggregate.over_all(total_count=True) + assert agg.total_count == 50, f"aggregate total_count {agg.total_count}" + agg = await coll.aggregate.over_all( + return_metrics=[wvc.query.Metrics("idx").integer(minimum=True, maximum=True)] + ) + idx = agg.properties["idx"] + assert idx.minimum == 0 and idx.maximum == 49, agg.properties + ok("aggregate count=50 min=0 max=49") + + await client.collections.create( + MT_COLL, + vector_config=wvc.config.Configure.Vectors.self_provided(), + properties=[Property(name="title", data_type=DataType.TEXT)], + multi_tenancy_config=wvc.config.Configure.multi_tenancy(enabled=True), + ) + mt = client.collections.get(MT_COLL) + await mt.tenants.create([Tenant(name="t1"), Tenant(name="t2")]) + tenants = await mt.tenants.get() + assert set(tenants.keys()) == {"t1", "t2"}, f"TenantsGet: {set(tenants.keys())}" + ok("multi-tenant create + TenantsGet = {t1, t2}") + + t1 = mt.with_tenant("t1") + ret = await t1.data.insert_many([{"title": f"tenant doc {i}"} for i in range(10)]) + assert not ret.has_errors and len(ret.uuids) == 10, f"tenant insert_many: {ret.errors}" + agg = await t1.aggregate.over_all(total_count=True) + assert agg.total_count == 10, f"tenant aggregate {agg.total_count}" + ok("per-tenant insert_many = 10, aggregate = 10") + + dm = await t1.data.delete_many(where=Filter.by_property("title").like("tenant*")) + assert dm.successful == 10, f"delete_many successful={dm.successful}" + agg = await t1.aggregate.over_all(total_count=True) + assert agg.total_count == 0, f"post-delete aggregate {agg.total_count}" + ok("per-tenant delete_many (BatchDelete) = 10 -> aggregate = 0") + + try: + await client.collections.get("DoesNotExistXyz").query.fetch_objects(limit=1) + raise AssertionError("expected WeaviateQueryError for nonexistent collection") + except WeaviateQueryError as e: + assert "DoesNotExistXyz" in str(e) or "not" in str(e).lower(), str(e) + ok("error mapping: nonexistent collection -> WeaviateQueryError") + + try: + async with client.batch.stream() as batch: + await batch.add_object(collection=COLL, properties={"title": "x", "idx": 999}) + raise AssertionError("batch.stream() did not raise under grpc-web") + except WeaviateBatchStreamError as e: + assert "grpc-web" in str(e) and "insert_many" in str(e), str(e) + ok("batch.stream() -> WeaviateBatchStreamError (clear message)") + + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + async with client.batch.experimental() as batch: + await batch.add_object(collection=COLL, properties={"title": "x", "idx": 999}) + raise AssertionError("batch.experimental() did not raise under grpc-web") + except WeaviateBatchStreamError: + ok("batch.experimental() -> WeaviateBatchStreamError") + + for name in (COLL, MT_COLL): + await client.collections.delete(name) + ok("cleanup") + finally: + await client.close() + + print("PYODIDE E2E: ALL STEPS OK", flush=True) diff --git a/ci/pyodide-e2e/package.json b/ci/pyodide-e2e/package.json new file mode 100644 index 000000000..fbf78f303 --- /dev/null +++ b/ci/pyodide-e2e/package.json @@ -0,0 +1,8 @@ +{ + "name": "weaviate-pyodide-e2e", + "private": true, + "description": "Runs the weaviate-client e2e suite inside Pyodide (WASM) under Node", + "dependencies": { + "pyodide": "314.0.4" + } +} diff --git a/ci/pyodide-e2e/run.mjs b/ci/pyodide-e2e/run.mjs new file mode 100644 index 000000000..01f95d968 --- /dev/null +++ b/ci/pyodide-e2e/run.mjs @@ -0,0 +1,73 @@ +// Runs the Weaviate Python client e2e suite (e2e.py) inside Pyodide (WASM) under Node. +// +// Usage: node run.mjs +// must contain exactly the two locally-built pure wheels: +// weaviate_client-*.whl and weaviate_python_grpc_web-*.whl. +// Env: WEAVIATE_HOST (default localhost), WEAVIATE_PORT (default 8090). +// +// The pinned `pyodide` npm package fixes the interpreter (the 314.x line bundles +// CPython 3.14), so there is no Python version matrix here. micropip installs the two +// local wheels; transitive deps resolve from the Pyodide distribution +// (pydantic/pydantic_core/cryptography ship wasm builds there — pydantic_core has no +// wasm wheel on PyPI) or from PyPI as pure wheels (protobuf), and the base client's +// `grpcio; sys_platform != "emscripten"` marker correctly skips grpcio. +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { loadPyodide } from "pyodide"; + +if (!process.argv[2]) { + console.error("usage: node run.mjs "); + process.exit(2); +} +const wheelsDir = resolve(process.argv[2]); +const here = dirname(fileURLToPath(import.meta.url)); + +const wheels = readdirSync(wheelsDir) + .filter((f) => f.endsWith(".whl")) + .sort(); // installs weaviate_client before weaviate_python_grpc_web, which depends on it +const prefixes = ["weaviate_client-", "weaviate_python_grpc_web-"]; +if ( + wheels.length !== 2 || + !prefixes.every((p) => wheels.some((w) => w.startsWith(p))) +) { + console.error( + `expected exactly one weaviate_client-*.whl and one weaviate_python_grpc_web-*.whl in ${wheelsDir}, found: ${JSON.stringify(wheels)}`, + ); + process.exit(2); +} + +const pyodide = await loadPyodide({ + env: { + WEAVIATE_HOST: process.env.WEAVIATE_HOST ?? "localhost", + WEAVIATE_PORT: process.env.WEAVIATE_PORT ?? "8090", + }, +}); +console.log( + `pyodide ${pyodide.version} / python ${pyodide.runPython("import sys; sys.version.split()[0]")}`, +); + +await pyodide.loadPackage("micropip"); +const micropip = pyodide.pyimport("micropip"); +// Pyodide's bundled httpx recipe drops httpx's anyio dependency (its fetch-based +// transport needs no sockets), but authlib's httpx_client imports anyio directly — +// without this, `import weaviate` fails with ModuleNotFoundError. +await micropip.install("anyio"); + +pyodide.FS.mkdirTree("/wheels"); +pyodide.mountNodeFS("/wheels", wheelsDir); +for (const wheel of wheels) { + console.log(`micropip install ${wheel}`); + await micropip.install(`emfs:/wheels/${wheel}`); +} + +// Define e2e.py's globals (imports run here, installing the grpc shim), then await +// main() on Pyodide's event loop — asyncio.run() cannot be used inside Pyodide. +pyodide.runPython(readFileSync(resolve(here, "e2e.py"), "utf8")); +try { + await pyodide.runPythonAsync("await main()"); +} catch (err) { + console.error(err); + process.exit(1); +} From 114b5b9265e3c7e14c38c5dce766d02fb0660938 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:56:53 +0200 Subject: [PATCH 6/6] ci: run the Pyodide (WASM) e2e suite against core-native grpc-web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New pyodide-e2e job beside grpc-web-tests: build both pure wheels, start the async-tests Weaviate (WEAVIATE_139) from the existing ci/docker-compose-async.yml, and run ci/pyodide-e2e/run.mjs under Node 22. No Python matrix — the pinned Pyodide bundle fixes the interpreter. No Envoy/browser: core serves grpc-web natively on the REST port under /v1/grpc-web (default-on since 1.38.3). --- .github/workflows/main.yaml | 46 +++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index e2004af1c..fb0149a47 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -125,6 +125,52 @@ jobs: - name: Run grpc-web package tests run: pytest packages/grpc-web/tests + pyodide-e2e: + name: Run Pyodide (WASM) e2e Tests + runs-on: ubuntu-latest + timeout-minutes: 15 + # No Python matrix: the pinned Pyodide bundle fixes the interpreter (see + # ci/pyodide-e2e/package.json for the exact pin). + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + fetch-tags: true + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + cache: 'pip' # caching pip dependencies + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: "22" + - name: Login to Docker Hub + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + if: ${{ !github.event.pull_request.head.repo.fork && github.triggering_actor != 'dependabot[bot]' }} + with: + username: ${{secrets.DOCKER_USERNAME}} + password: ${{secrets.DOCKER_PASSWORD}} + - name: Build pure wheels (base client + grpc-web) + run: | + pip install build + python -m build --wheel --outdir dist . + python -m build --wheel --outdir dist packages/grpc-web + - name: start weaviate + run: | + source ./ci/compose.sh + export WEAVIATE_VERSION=$WEAVIATE_139 + docker compose -f ci/docker-compose-async.yml up -d + wait "http://localhost:8090" + - name: Run the e2e suite inside Pyodide under Node + env: + WEAVIATE_HOST: localhost + WEAVIATE_PORT: "8090" + run: | + npm install --prefix ci/pyodide-e2e + node ci/pyodide-e2e/run.mjs dist + - name: stop weaviate + if: always() + run: docker compose -f ci/docker-compose-async.yml down --remove-orphans + proto-test: name: Run importing protos test runs-on: ubuntu-latest