Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8aa867a
feat(grpc-web): add Pyodide/WASM grpc-web transport
g-despot Jun 9, 2026
99f5356
feat(grpc-web): support grpc-web on the REST host:port via a base-pat…
g-despot Jun 9, 2026
fe208c0
fix(grpc-web): surface transport/parse failures as AioRpcError, enfor…
g-despot Jun 9, 2026
833bb59
test(grpc-web): skip get_version unit tests when grpcio/protobuf are …
g-despot Jun 9, 2026
0f2f6da
fix(grpc-web): guard grpc-web mode (shim+async required), tighten tim…
g-despot Jun 9, 2026
3669363
feat(grpc-web): route httpx REST calls over fetch under Emscripten
g-despot Jun 10, 2026
b8ac294
fix: make the async client safe under WASM/Pyodide
g-despot Jun 11, 2026
0d685a4
fix(grpc-web): harden the fetch transport and error reporting
g-despot Jun 11, 2026
c8f321a
chore: gitignore all egg-info dirs
g-despot Jun 11, 2026
4c5c940
Merge branch 'main' into feat/grpc-web-wasm-transport
g-despot Jun 22, 2026
573768e
Merge remote-tracking branch 'origin/main' into nakama/03144b4b-1b9c-…
g-despot Aug 14, 2026
6797d74
ci: run and lint packages/grpc-web in the main workflow
g-despot Aug 14, 2026
6367217
fix(grpc-web): treat a missing grpc-status trailer as INTERNAL, not s…
g-despot Aug 14, 2026
838387b
test: pin the grpcio fallback version against the vendored stub gates
g-despot Aug 14, 2026
412f879
fix(wasm): reject sync client construction under Emscripten with the …
g-despot Aug 14, 2026
275680f
test(pyodide): add an in-Pyodide e2e suite driven by a Node runner
g-despot Aug 14, 2026
114b5b9
ci: run the Pyodide (WASM) e2e suite against core-native grpc-web
g-despot Aug 14, 2026
38f359b
Merge pull request #2136 from weaviate/feat/grpc-web-review-fixes-pyo…
g-despot Aug 17, 2026
493af9d
feat(wasm): bootstrap the grpc-web shim from a bare 'import weaviate'
g-despot Aug 17, 2026
619ee06
refactor(web): rename the companion package to weaviate-client-web
g-despot Aug 17, 2026
df96466
fix(grpc-web): decide the response on HTTP status, not frame parsing
g-despot Aug 17, 2026
a7da8c2
fix: surface failures that previously vanished
g-despot Aug 17, 2026
524a27a
ci: gate publishing on the grpc-web and pyodide jobs
g-despot Aug 17, 2026
a344c89
fix(web): always run REST over the package fetch transport under Emsc…
g-despot Aug 17, 2026
e9c81be
fix(web): harden grpc-web framing, deadline encoding and diagnostics
g-despot Aug 17, 2026
1ffa2a0
docs(web): describe the core-native grpc-web setup first
g-despot Aug 17, 2026
b950e39
fix(connect): back off token refresh, await the refresher on close, s…
g-despot Aug 17, 2026
c099eb3
fix(batch): keep the caller's exception on stream exit; sync/async pa…
g-despot Aug 17, 2026
8a18975
ci: type-check and flake8 packages/web
g-despot Aug 17, 2026
1943edb
Merge pull request #2137 from weaviate/feat/grpc-web-review-fixes-round2
g-despot Aug 18, 2026
7b51604
feat(grpc-web): route gRPC over grpc-web automatically under WebAssembly
g-despot Aug 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 70 additions & 4 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ 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/web
- name: "Ruff format"
run: ruff format --diff weaviate test mock_tests integration
run: ruff format --diff weaviate test mock_tests integration packages/web
- name: "Flake 8"
run: flake8 weaviate test mock_tests integration
run: flake8 weaviate test mock_tests integration packages/web
- name: "Check release for pypi"
run: |
python -m build
Expand Down Expand Up @@ -105,6 +105,72 @@ 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/web
- name: Run grpc-web package tests
run: pytest packages/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/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
Expand Down Expand Up @@ -352,7 +418,7 @@ jobs:

build-and-publish:
name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
needs: [integration-tests, unit-tests, lint-and-format, type-checking, test-package, proto-test]
needs: [integration-tests, unit-tests, lint-and-format, type-checking, test-package, proto-test, grpc-web-tests, pyodide-e2e]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
venv
.idea
dist/
weaviate_client.egg-info
*.egg-info/
**/__pycache__
tmp
build/
Expand All @@ -27,3 +27,5 @@ scratch/
*-test.sh
*.hdf5
*.jsonl
ci/pyodide-e2e/node_modules/
ci/pyodide-e2e/package-lock.json
209 changes: 209 additions & 0 deletions ci/pyodide-e2e/e2e.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
"""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 uuid
import warnings

import weaviate_client_web # bootstraps the grpc shim + fetch transport under Emscripten

import grpc
import httpx
import weaviate
import weaviate.classes as wvc
from weaviate.classes.config import DataType, Property, ReferenceProperty
from weaviate.classes.data import DataReference
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. Under Emscripten the
# connect helpers route gRPC there themselves — nothing here selects it.
GRPC_WEB_PREFIX = "/v1/grpc-web"


def ok(step: str) -> None:
print(f"OK {step}", flush=True)


async def main() -> None:
assert weaviate_client_web.is_installed(), "grpc shim did not install under Emscripten"
assert getattr(grpc, "__weaviate_client_web_shim__", False), (
"sys.modules['grpc'] is not the shim"
)
# REST must run through the package's own fetch transport, not Pyodide's bundled
# httpx transport (which cannot read the null body of HEAD / 204 responses).
assert weaviate_client_web.is_fetch_transport_installed(), "fetch transport not installed"
assert getattr(
httpx.AsyncHTTPTransport.handle_async_request, "__weaviate_fetch_shim__", False
), "httpx.AsyncHTTPTransport is not the package's fetch transport"
ok("self-check: package fetch transport is the active httpx transport")

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,
)
params = client._connection._connection_params
assert params._grpc_web_path_prefix == GRPC_WEB_PREFIX, params
assert params._grpc_target == f"{host}:{port}", params
ok("connect helper routed gRPC onto the REST endpoint under /v1/grpc-web")

# 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),
],
references=[ReferenceProperty(name="related", target_collection=COLL)],
)
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")

# REST calls answered without a body (HEAD 204/404, PATCH/DELETE 204) and the
# batch-references path, which reads httpx's response.elapsed.
first, second, last = ret.uuids[0], ret.uuids[1], ret.uuids[49]
assert await coll.data.exists(first) is True
assert await coll.data.exists(uuid.uuid4()) is False
ok("data.exists (HEAD 204 / 404) = True / False")

await coll.data.update(uuid=first, properties={"title": "article 0 (updated)"})
obj = await coll.query.fetch_object_by_id(first)
assert obj is not None and obj.properties["title"] == "article 0 (updated)", obj
ok("data.update (PATCH 204) -> fetch_object_by_id sees the update")

refs = await coll.data.reference_add_many(
[
DataReference(
from_property="related", from_uuid=ret.uuids[i], to_uuid=ret.uuids[i + 1]
)
for i in range(5)
]
)
assert not refs.has_errors, f"reference_add_many errors: {refs.errors}"
assert refs.elapsed_seconds >= 0, refs
ok("data.reference_add_many (REST /batch/references) = 5")

await coll.data.reference_delete(from_uuid=first, from_property="related", to=second)
ok("data.reference_delete (DELETE 204)")

assert await coll.data.delete_by_id(last) is True
assert await coll.data.exists(last) is False
# deleting a missing object answers 204 or 404 depending on the server topology;
# either way it is a body-less response the transport must handle
assert isinstance(await coll.data.delete_by_id(last), bool)
ok("data.delete_by_id (DELETE 204; repeat -> 204/404) = True, then bool")

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}")

assert await mt.tenants.exists("t1") is True
assert await mt.tenants.exists("t404") is False
ok("tenants.exists (HEAD 200 / 404) = True / False")

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), str(e)
ok("error mapping: nonexistent collection -> WeaviateQueryError names the collection")

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)
8 changes: 8 additions & 0 deletions ci/pyodide-e2e/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading