Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
70 changes: 68 additions & 2 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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/grpc-web
- 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
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ scratch/
*-test.sh
*.hdf5
*.jsonl
ci/pyodide-e2e/node_modules/
ci/pyodide-e2e/package-lock.json
153 changes: 153 additions & 0 deletions ci/pyodide-e2e/e2e.py
Original file line number Diff line number Diff line change
@@ -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)
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"
}
}
73 changes: 73 additions & 0 deletions ci/pyodide-e2e/run.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Runs the Weaviate Python client e2e suite (e2e.py) inside Pyodide (WASM) under Node.
//
// Usage: node run.mjs <wheels-dir>
// <wheels-dir> 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 <wheels-dir>");
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);
}
7 changes: 7 additions & 0 deletions packages/grpc-web/src/weaviate_grpc_web/_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
12 changes: 12 additions & 0 deletions packages/grpc-web/tests/test_shim_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 21 additions & 0 deletions packages/grpc-web/tests/test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading