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
66 changes: 39 additions & 27 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -105,28 +105,8 @@ 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
name: Run Pyodide (WASM) Unit + e2e Tests
runs-on: ubuntu-latest
timeout-minutes: 15
# No Python matrix: the pinned Pyodide bundle fixes the interpreter (see
Expand Down Expand Up @@ -154,6 +134,22 @@ jobs:
pip install build
python -m build --wheel --outdir dist .
python -m build --wheel --outdir dist packages/web
- name: Assert both wheels carry the same version
# weaviate-client-web is versioned in lockstep with weaviate-client: both derive
# from the same git tag via setuptools_scm.
run: |
base=$(basename dist/weaviate_client-*.whl); base=${base#weaviate_client-}; base=${base%%-*}
web=$(basename dist/weaviate_client_web-*.whl); web=${web#weaviate_client_web-}; web=${web%%-*}
echo "weaviate-client=$base weaviate-client-web=$web"
test "$base" = "$web"
- name: Run the unit suite inside Pyodide under Node
# The grpc-web package imports pyodide at module scope, so its unit tests only
# run here. Needs no running Weaviate. The JSPI flag lets pytest's synchronous
# runner await async tests via stack switching; without it the run fails
# loudly at startup (never a false green).
run: |
npm install --prefix ci/pyodide-e2e
node --experimental-wasm-jspi ci/pyodide-e2e/units.mjs dist
- name: start weaviate
run: |
source ./ci/compose.sh
Expand Down Expand Up @@ -361,8 +357,10 @@ jobs:
cache: 'pip' # caching pip dependencies
- name: Install dependencies
run: pip install -r requirements-test.txt -r requirements-devel.txt
- name: Build a binary wheel
run: python -m build
- name: Build binary wheels (base client + grpc-web companion)
run: |
python -m build
python -m build --wheel --outdir dist packages/web
- name: Create Wheel Artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
Expand Down Expand Up @@ -418,7 +416,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, grpc-web-tests, pyodide-e2e]
needs: [integration-tests, unit-tests, lint-and-format, type-checking, test-package, proto-test, pyodide-e2e]
Comment thread
g-despot marked this conversation as resolved.
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
Expand All @@ -433,9 +431,23 @@ jobs:
cache: 'pip' # caching pip dependencies
- name: Install dependencies
run: pip install -r requirements-devel.txt
- name: Build a binary wheel
run: python -m build
- name: Publish distribution 📦 to PyPI on new tags
- name: Build distributions (base client + grpc-web companion)
# The companion is wheel-only: its setup.py resolves the lockstep version from
# the repository's git tags, which an unpacked sdist would not have — and
# micropip consumes wheels only anyway.
run: |
python -m build
python -m build --wheel --outdir dist packages/web
- name: Assert both packages carry the same version
# weaviate-client-web pins weaviate-client==<its own version> at build time, so
# a base-only release would leave the [grpc-web] extra unresolvable. This gate
# runs on the artifacts that are actually uploaded.
run: |
base=$(basename dist/weaviate_client-*.whl); base=${base#weaviate_client-}; base=${base%%-*}
web=$(basename dist/weaviate_client_web-*.whl); web=${web#weaviate_client_web-}; web=${web%%-*}
echo "weaviate-client=$base weaviate-client-web=$web"
test "$base" = "$web"
- name: Publish distributions 📦 to PyPI on new tags
if: startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
with:
Expand Down
136 changes: 136 additions & 0 deletions ci/pyodide-e2e/units.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Runs the weaviate_client_web unit suite (packages/web/tests) with pytest inside
// Pyodide (WASM) under Node, plus a bootstrap scenario in a fresh interpreter. No
// running Weaviate is needed — everything is driven through fake senders / a fake
// pyfetch.
//
// Usage: node --experimental-wasm-jspi units.mjs <wheels-dir>
// <wheels-dir> must contain exactly the two locally-built pure wheels:
// weaviate_client-*.whl and weaviate_client_web-*.whl (same layout as run.mjs).
//
// The JSPI flag is required: pytest's runner is synchronous, so async tests execute
// through run_until_complete, which needs stack switching (enableRunUntilComplete +
// a callPromising() entrypoint). Without the flag the run fails loudly at startup —
// it can never produce a false green.
//
// The package imports pyodide at module scope, so this harness is the only place its
// unit tests can run. The base client's hook logic (missing companion, broken
// companion, grpc-present fall-through) is covered by subprocess tests in
// test/test_wasm_compat.py on CPython; the bootstrap scenario below covers the one
// path that needs real Pyodide, micropip and the wheels.
import { readdirSync } 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 units.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_client_web, which depends on it
const prefixes = ["weaviate_client-", "weaviate_client_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_client_web-*.whl in ${wheelsDir}, found: ${JSON.stringify(wheels)}`,
);
process.exit(2);
}

// Fresh interpreter with micropip ready and the wheels dir mounted.
async function freshPyodide() {
const pyodide = await loadPyodide({ enableRunUntilComplete: true });
await pyodide.loadPackage("micropip");
const micropip = pyodide.pyimport("micropip");
pyodide.FS.mkdirTree("/wheels");
pyodide.mountNodeFS("/wheels", wheelsDir);
for (const wheel of wheels) {
await micropip.install(`emfs:/wheels/${wheel}`);
}
return pyodide;
}

// --- bootstrap scenario: needs a clean import state, so its own interpreter --------

{
const pyodide = await freshPyodide();
try {
pyodide.runPython(`
import sys
assert "weaviate_client_web" not in sys.modules
import weaviate # the ONLY weaviate-side import: must bootstrap the companion
assert "weaviate_client_web" in sys.modules, "hook did not import the companion"
import weaviate_client_web
import grpc
import httpx
assert weaviate_client_web.is_installed()
assert weaviate_client_web.is_fetch_transport_installed()
assert getattr(grpc, "__weaviate_client_web_shim__", False) is True
assert getattr(
httpx.AsyncHTTPTransport.handle_async_request, "__weaviate_fetch_shim__", False
) is True
`);
console.log("OK scenario: bare 'import weaviate' bootstraps the companion");
} catch (err) {
console.error("FAIL scenario: bare 'import weaviate' bootstraps the companion");
console.error(err);
process.exit(1);
}
}

// --- the pytest suite --------------------------------------------------------------

const testsDir = resolve(here, "../../packages/web/tests");
const pyodide = await freshPyodide();
console.log(
`pyodide ${pyodide.version} / python ${pyodide.runPython("import sys; sys.version.split()[0]")}`,
);
const micropip = pyodide.pyimport("micropip");
// A metadata-coherent pair: pytest-asyncio 0.25.3 declares pytest<9,>=8.2. Its
// run_until_complete-based execution stack-switches correctly under JSPI, unlike the
// asyncio.Runner-based pytest-asyncio 1.x, whose async tests fail here. (The Pyodide
// distribution bundles pytest 9 next to pytest-asyncio 0.25.3, contradicting that
// constraint — micropip tolerates it, but there is no reason to depend on its
// leniency, so both are pinned from PyPI.)
await micropip.install(["pytest==8.4.2", "pytest-asyncio==0.25.3"]);
pyodide.FS.mkdirTree("/units");
pyodide.mountNodeFS("/units", testsDir);

// pytest.main is synchronous; entering through callPromising() lets the async tests
// stack-switch (run_until_complete) instead of failing with "Cannot stack switch".
const runPytest = pyodide.runPython(`
import sys
sys.dont_write_bytecode = True # /units is the host checkout: no __pycache__ in it

import pytest

def _run():
return int(pytest.main([
"-v",
"-p", "no:cacheprovider", # no .pytest_cache in the host checkout either
"-o", "asyncio_mode=auto",
"-o", "asyncio_default_fixture_loop_scope=function",
"/units",
]))

_run
`);
let exitCode;
try {
exitCode = await runPytest.callPromising();
} catch (err) {
console.error(err);
process.exit(1);
}
// Any nonzero pytest exit code fails the run — including 5, "no tests collected".
console.log(`pytest exit code: ${exitCode}`);
// The interpreters loaded above keep live handles on the Node event loop, so the
// process does not exit on its own.
process.exit(exitCode === 0 ? 0 : 1);
49 changes: 40 additions & 9 deletions packages/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,26 @@ Requires Weaviate ≥ 1.38.3 (the first release to serve grpc-web natively) or a
transcoder in front of an older server. Pyodide ≥ 0.27 recommended; verified on
Pyodide 314.0.4 (CPython 3.14).

## Installation

Install through the base client's `grpc-web` extra:

```python
import micropip
await micropip.install("weaviate-client[grpc-web]")
```

The extra carries a `sys_platform == "emscripten"` marker, so the same requirement is a
no-op on CPython — one requirements list works everywhere. Installing the companion
directly (`micropip.install("weaviate-client-web")`) works too: it pins
`weaviate-client` to its own exact version, so a mismatched pair can never resolve.
Both forms require the first `weaviate-client` release that ships the extra and the
Emscripten marker; against older releases the resolver fails on `grpcio`.

This package is defined for its environment: it imports `pyodide` at module scope and is
therefore only importable under Emscripten/Pyodide. On CPython the base client never
imports it (and the extra never installs it).

## How it works

Under Pyodide there is no `grpcio` Emscripten wheel, and `import weaviate` hard-imports
Expand Down Expand Up @@ -81,7 +101,7 @@ await collection.query.near_text("hello", limit=3)

Nothing selects grpc-web: `use_async_with_local()`, `use_async_with_weaviate_cloud()` and
`use_async_with_custom()` all route gRPC onto the REST endpoint under `/v1/grpc-web` when
they run under Emscripten, and behave exactly as before everywhere else.
they run under Emscripten, and use native gRPC everywhere else.

```python
client = weaviate.use_async_with_weaviate_cloud(
Expand All @@ -107,7 +127,7 @@ Pass `headers={...}` / `auth_credentials=...` as usual for API keys, OIDC or WCD
Importing the companion explicitly first also works and remains the explicit form:

```python
import weaviate_client_web # installs the grpc shim under Emscripten (no-op elsewhere)
import weaviate_client_web # installs the grpc shim (only importable under Emscripten)
import weaviate
```

Expand Down Expand Up @@ -161,11 +181,22 @@ deployments that go through a grpc-web transcoder or a proxy must configure CORS
- note that a CORS-blocked request is indistinguishable from a network failure in the
browser (`TypeError: Failed to fetch`), and is retried as UNAVAILABLE.

## Testing on CPython
## Testing

Because the package imports `pyodide` at module scope, its unit tests run inside
Pyodide. From the repository root:

```sh
python -m build --wheel --outdir dist .
python -m build --wheel --outdir dist packages/web
npm install --prefix ci/pyodide-e2e
node --experimental-wasm-jspi ci/pyodide-e2e/units.mjs dist # pytest unit suite, no Weaviate needed
node ci/pyodide-e2e/run.mjs dist # e2e suite, needs a running Weaviate (see ci/)
```

`weaviate_client_web.install(force=True)` installs the shim on a normal CPython
interpreter (run it in a fresh process, before importing `weaviate`). Inject a sender
with `weaviate_client_web.set_sender(...)` (e.g. `make_httpx_sender()`) to exercise the
transport against an Envoy/vanguard transcoder without a browser.
`install_fetch_transport(force=True)` likewise patches httpx on CPython, given an
importable `pyodide.http` stand-in.
`units.mjs` runs pytest over `packages/web/tests/` inside Pyodide — async tests execute
on Pyodide's event loop via JSPI stack switching, hence the Node flag — plus a
fresh-interpreter bootstrap scenario; everything is driven through fake senders and a
fake `pyfetch`. `run.mjs` runs the e2e suite against a live Weaviate. On CPython the
`conftest.py` keeps pytest from collecting these modules (they cannot import there);
the base client's import-hook branches are covered by `test/test_wasm_compat.py`.
20 changes: 9 additions & 11 deletions packages/web/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[build-system]
requires = ["setuptools>=65", "wheel"]
requires = ["setuptools>=65", "setuptools_scm[toml] >6.2", "wheel"]
build-backend = "setuptools.build_meta"

[project]
Expand All @@ -10,21 +10,19 @@ requires-python = ">=3.10"
license = { text = "BSD-3-Clause" }
authors = [{ name = "Weaviate", email = "hello@weaviate.io" }]
keywords = ["weaviate", "grpc-web", "pyodide", "wasm", "emscripten"]
# Version is kept in lockstep with weaviate-client. TODO(lockstep): derive from the same
# git tag via setuptools_scm and assert the built versions match in CI before publishing.
version = "0.0.1.dev0"
# Deliberately depends on weaviate-client WITHOUT grpcio (grpcio is excluded under
# Emscripten by the `sys_platform != "emscripten"` marker in the base package's deps).
dependencies = [
"weaviate-client",
# Pyodide's bundled httpx build omits anyio, but authlib imports it directly.
'anyio ; sys_platform == "emscripten"',
]
# Version comes from the repository's git tags via setuptools_scm (root below), so every
# build carries the same version as weaviate-client; CI asserts the built wheels match.
# Dependencies are computed in setup.py: the weaviate-client requirement is pinned to
# that same version at build time, so mismatched pairs cannot resolve at install time.
dynamic = ["version", "dependencies"]

[project.urls]
Source = "https://github.com/weaviate/weaviate-python-client"
Tracker = "https://github.com/weaviate/weaviate-python-client/issues"

[tool.setuptools_scm]
root = "../.."

[tool.setuptools.packages.find]
where = ["src"]

Expand Down
25 changes: 25 additions & 0 deletions packages/web/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Injects the lockstep ``weaviate-client==<version>`` pin at build time.

Both packages derive their version from the repository's git tags (setuptools_scm), so
the version is only known when the wheel is built and a static ``dependencies`` list
cannot express the pin. The pin makes mismatched pairs unresolvable at install time:
the two packages share private contracts (the ``HTTP <status>`` error-string markers,
the exception constants ``_channel`` imports), so a companion must only ever install
next to the base client it was built with.

Consequence for releasing: every tag must publish BOTH packages — a base-only release
would leave the extra pointing at a companion whose pin no longer resolves.
"""

from setuptools import setup
from setuptools_scm import get_version

version = get_version(root="../..", relative_to=__file__)

setup(
install_requires=[
f"weaviate-client=={version}",
# Pyodide's bundled httpx build omits anyio, but authlib imports it directly.
'anyio ; sys_platform == "emscripten"',
]
)
Loading
Loading