From 427566f9e8e644973aad0e36a6cae7b29a6b087a Mon Sep 17 00:00:00 2001 From: Asel Sagingalieva <41651062+AselSB@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:07:36 +0300 Subject: [PATCH] feat: add the router provider for eigenorbit-router-client Run eigenkit circuits through EigenOrbit Router with `pip install "eigenkit[router]"` and `provider=RouterConfig(device=...)`. - `router` extra, also part of `all` - `router` provider registered lazily when the client is installed - `eigenkit.configs.RouterConfig` resolves to the client's class - a loaded bundle never sets a provider's URL or credentials --- ARCHITECTURE.md | 24 +- README.md | 1 + docs/getting-started.md | 3 +- docs/user-guide/providers.md | 1 + pyproject.toml | 6 +- src/eigenkit/configs.py | 23 ++ src/eigenkit/core/backends/__init__.py | 20 +- src/eigenkit/core/provider.py | 1 + src/eigenkit/qml/_serialization.py | 37 ++- tests/algorithms/test_serialization.py | 124 +++---- tests/core/test_router_seam.py | 433 +++++++++++++++++++++++++ uv.lock | 73 ++++- 12 files changed, 660 insertions(+), 86 deletions(-) create mode 100644 tests/core/test_router_seam.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d302510..f2b40f7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -147,20 +147,22 @@ reference every other backend is checked against, and it advertises `gradient_method="adjoint"`. The remaining backends translate IR to native SDK objects; they all implement the same Provider protocol, with these differences: -| | PennyLane | Qiskit | Braket (local/cloud) | Braket (QPU) | Qiskit Runtime | IQM | CudaQuantum | Qulacs | -|---|---|---|---|---|---|---|---|---| -| `gradient_method` | `"native"` | `"parameter_shift"` | `"parameter_shift"` | `"none"`¹ | `"none"`¹ | `"none"`¹ | `"parameter_shift"` | `"adjoint"` | -| Symbolic params | resolved per-call | `qiskit.circuit.Parameter` | `FreeParameter` | `FreeParameter` | `qiskit.circuit.Parameter` (transpiled) | `qiskit.circuit.Parameter` (transpiled) | `make_kernel(list)` | `set_parameter` per-call on a `ParametricQuantumCircuit` | -| Compile output | device + qnodes + Pauli ops | parametric QuantumCircuit + SparsePauliOp | parametric Circuit + deduplicated result_types | same + lazy `AwsDevice` | transpiled circuit + layout-mapped SparsePauliOp (estimator) or a qubit-wise-commuting measurement plan (sampler) | parametric QuantumCircuit + one transpiled measured circuit per qubit-wise-commuting group (default `transpile_mode="parametric"`) | `make_kernel` + cudaq spin-op hamiltonians | `ParametricQuantumCircuit` + qulacs `Observable`s | -| Sync `run()` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| Async `submit()` | – | – | ✓ (returns `BraketJobHandle`) | ✓ | ✓ (returns `QiskitRuntimeJobHandle`) | ✓ (returns `IQMJobHandle`) | – | – | -| `dry_run_info()` | – | – | ✓ | ✓ | ✓ | ✓ | – | – | -| Cost guardrails | – | – | `CostWarning` on cloud compile | + `shots > 0` enforced + `gradient_method="none"`¹ | `CostWarning` on real-hardware compile + `shots > 0` enforced | `CostWarning` on real-hardware compile + `shots > 0` enforced | – | – | -| Auto-registered | ✓ "pennylane" | ✓ "qiskit" | ✓ "braket" | ✓ "braket" (config flips behaviour) | ✗ opt-in | ✓ "iqm" (when extra installed) | ✗ opt-in (extra dep) | ✓ "qulacs" (when extra installed) | +| | PennyLane | Qiskit | Braket (local/cloud) | Braket (QPU) | Qiskit Runtime | IQM | CudaQuantum | Qulacs | EigenOrbit Router² | +|---|---|---|---|---|---|---|---|---|---| +| `gradient_method` | `"native"` | `"parameter_shift"` | `"parameter_shift"` | `"none"`¹ | `"none"`¹ | `"none"`¹ | `"parameter_shift"` | `"adjoint"` | `"none"` on a QPU, `"parameter_shift"` on a simulator | +| Symbolic params | resolved per-call | `qiskit.circuit.Parameter` | `FreeParameter` | `FreeParameter` | `qiskit.circuit.Parameter` (transpiled) | `qiskit.circuit.Parameter` (transpiled) | `make_kernel(list)` | `set_parameter` per-call on a `ParametricQuantumCircuit` | – (the circuit goes out as wire JSON; weights per call) | +| Compile output | device + qnodes + Pauli ops | parametric QuantumCircuit + SparsePauliOp | parametric Circuit + deduplicated result_types | same + lazy `AwsDevice` | transpiled circuit + layout-mapped SparsePauliOp (estimator) or a qubit-wise-commuting measurement plan (sampler) | parametric QuantumCircuit + one transpiled measured circuit per qubit-wise-commuting group (default `transpile_mode="parametric"`) | `make_kernel` + cudaq spin-op hamiltonians | `ParametricQuantumCircuit` + qulacs `Observable`s | wire JSON of the circuit and observables | +| Sync `run()` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| Async `submit()` | – | – | ✓ (returns `BraketJobHandle`) | ✓ | ✓ (returns `QiskitRuntimeJobHandle`) | ✓ (returns `IQMJobHandle`) | – | – | ✓ (returns `RouterJobHandle`) | +| `dry_run_info()` | – | – | ✓ | ✓ | ✓ | ✓ | – | – | ✓ | +| Cost guardrails | – | – | `CostWarning` on cloud compile | + `shots > 0` enforced + `gradient_method="none"`¹ | `CostWarning` on real-hardware compile + `shots > 0` enforced | `CostWarning` on real-hardware compile + `shots > 0` enforced | – | – | `CostWarning` with the router's price estimate on compile | +| Auto-registered | ✓ "pennylane" | ✓ "qiskit" | ✓ "braket" | ✓ "braket" (config flips behaviour) | ✗ opt-in | ✓ "iqm" (when extra installed) | ✗ opt-in (extra dep) | ✓ "qulacs" (when extra installed) | ✓ "router" (when extra installed) | ¹ `"parameter_shift"` when the device config sets `enable_gradients=True`; compiling against a paid device then raises a second `CostWarning` quoting the job count of one backward pass. Every gradient path (torch backward, `jacobian()`, the variational spine) goes through `Circuit._gradient_jacobians`, which refuses a `"none"` backend; torch's `_NoGradFn` and `variational()` refuse earlier, with messages specific to their path. -Async `submit()` covers Braket, Qiskit Runtime and IQM. +² The `router` extra installs eigenorbit-router-client, which ships this backend and `RouterConfig`; eigenkit registers `"router"` lazily when that package is importable. It runs circuits on the EigenOrbit Router, a hosted service that reaches real QPUs with one EigenOrbit API key. + +Async `submit()` covers Braket, Qiskit Runtime, IQM and EigenOrbit Router. Read PennyLane and Qiskit first (the simpler pair); Braket and CudaQuantum follow the same shape with backend-specific quirks. diff --git a/README.md b/README.md index 133c684..68ca5bb 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ pip install "eigenkit[all]" | `qulacs` | Qulacs | `provider="qulacs"` | | `qsim` | qsimcirq + Cirq | `provider="qsim"` (single-precision, ~1e-6 residual) | | `perceval` | Perceval (Quandela's photonic SDK) | `eigenkit.photonics` (local simulation, Quandela Cloud) | +| `router` | eigenorbit-router-client | `provider="router"`, `RouterConfig` (EigenOrbit Router: real QPUs with one EigenOrbit API key) | | `qml` | scikit-learn (torch-free) | `QuantumKernel`, `QSVC`/`QSVR` | | `datasets` | scikit-learn | `eigenkit.datasets` | | `benchmark` | matplotlib + pandas + scikit-learn | the model comparison harness | diff --git a/docs/getting-started.md b/docs/getting-started.md index 1b26a95..aaa93e1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -58,12 +58,13 @@ pip install "eigenkit[all]" | `qulacs` | Qulacs | `provider="qulacs"` | | `qsim` | qsimcirq + Cirq | `provider="qsim"` (single-precision) | | `perceval` | Perceval (Quandela's photonic SDK) | `eigenkit.photonics` (local simulation, Quandela Cloud) | +| `router` | eigenorbit-router-client | `provider="router"`, `RouterConfig` (EigenOrbit Router: real QPUs with one EigenOrbit API key) | | `qml` | scikit-learn (no PyTorch) | `QuantumKernel`, `QSVC`/`QSVR` | | `datasets` | scikit-learn | `eigenkit.datasets` | | `benchmark` | matplotlib + pandas + scikit-learn | the comparison harness | | `chemistry` | PySCF + OpenFermion (no PySCF on Windows) | real molecules (`molecular_hamiltonian`, `hydrogen_molecule`) | | `viz` | matplotlib | `Circuit.draw_mpl()` (the text drawer needs nothing) | -| `all` | every extra above except `perceval` | — | +| `all` | every extra above except `perceval` | — | Backend classes and their configurations are always available for import, even when their optional dependencies are not installed. However, attempting to diff --git a/docs/user-guide/providers.md b/docs/user-guide/providers.md index cc4406e..ac5617c 100644 --- a/docs/user-guide/providers.md +++ b/docs/user-guide/providers.md @@ -80,6 +80,7 @@ choose a backend for its speed. | Qiskit Aer | Lazy (`"qiskit-aer"`). | Parameter shift. | No, it loops. | The dense statevector core of Aer. It comes with the `qiskit` extra, so it adds no dependency. | | Qiskit Aer MPS | Lazy (`"qiskit-aer-mps"`). | Parameter shift. | No, it loops. | The same backend, with `method="matrix_product_state"`. It is exact when the bond dimension is not limited. It is the natural point of comparison for a simulation based on the time-evolving block decimation (TEBD) algorithm. | | qsim | Lazy (`"qsim"`). | Parameter shift. | No, it loops. | The qsim simulator of Google, through Cirq. It works in **single precision** (`float32`), so it is fast but has a residual error of about `1e-6`. It needs the `qsim` extra. | +| EigenOrbit Router | Lazy (`"router"`). | None on a QPU, parameter shift on a simulator. | No, one job per sample, submitted in parallel. | Real QPUs and cloud simulators through the hosted EigenOrbit Router, with one EigenOrbit API key and a prepaid balance. Pass `RouterConfig(device=...)` from eigenorbit-router-client: the device has no default, so a bare `provider="router"` raises an error that says so. It needs the `router` extra. | ## NumPy (built in) diff --git a/pyproject.toml b/pyproject.toml index cb8f0b9..0fbe54e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,6 +124,10 @@ qsim = [ perceval = [ "perceval-quandela>=1.3,<2", ] +# EigenOrbit Router client: real QPUs with one EigenOrbit API key. +router = [ + "eigenorbit-router-client>=0.1,<1", +] # Synthetic + real datasets and the sklearn baselines used by them. datasets = [ "scikit-learn>=1.3,<2", @@ -159,7 +163,7 @@ qml = [ "scikit-learn>=1.3,<2", ] all = [ - "eigenkit[torch,pennylane,qiskit,braket,iqm,tensorcircuit,cudaquantum,qulacs,qsim,benchmark,datasets,chemistry,qml,viz]", + "eigenkit[torch,pennylane,qiskit,braket,iqm,tensorcircuit,cudaquantum,qulacs,qsim,router,benchmark,datasets,chemistry,qml,viz]", ] [project.urls] diff --git a/src/eigenkit/configs.py b/src/eigenkit/configs.py index 9089bb4..c56e70e 100644 --- a/src/eigenkit/configs.py +++ b/src/eigenkit/configs.py @@ -28,6 +28,10 @@ - [`TensorCircuitConfig`][eigenkit.core.backends.tensorcircuit_backend.TensorCircuitConfig] - [`QulacsConfig`][eigenkit.core.backends.qulacs_backend.QulacsConfig] - [`QsimConfig`][eigenkit.core.backends.qsim_backend.QsimConfig] +- `RouterConfig`: the EigenOrbit Router, which runs circuits on real quantum + hardware with one EigenOrbit API key. It is the class of + eigenorbit-router-client and needs the `router` extra + (`pip install eigenkit[router]`). The blocks and `NumpyConfig` are imported with this module, because they only need NumPy. The other configurations are imported when they are first used, so @@ -63,6 +67,7 @@ from .core.blocks import AngleEncoding as Encoding from .core.blocks import BasicVariational as Variational from .core.blocks import Reuploading, StrongVariational +from .core.provider import _EXTRA_HINT # config name -> defining backend module (under eigenkit.core.backends) @@ -80,7 +85,25 @@ } +# Configs that live in another distribution: config name -> (module, extra). +# They stay out of `_LAZY_CONFIGS` and `__all__`, so a star import and the +# lazy-config roster never need that distribution. +_EXTERNAL_CONFIGS = {"RouterConfig": ("eigenorbit_router_client", "router")} + + def __getattr__(name: str): + external = _EXTERNAL_CONFIGS.get(name) + if external is not None: + module_name, extra = external + try: + module = importlib.import_module(module_name) + except ModuleNotFoundError as exc: + if exc.name != module_name: + raise + raise ImportError( + f"{name} needs {module_name} ({_EXTRA_HINT[extra]})" + ) from exc + return getattr(module, name) module_name = _LAZY_CONFIGS.get(name) if module_name is not None: module = importlib.import_module(f"eigenkit.core.backends.{module_name}") diff --git a/src/eigenkit/core/backends/__init__.py b/src/eigenkit/core/backends/__init__.py index 4bc346f..7eb4b53 100644 --- a/src/eigenkit/core/backends/__init__.py +++ b/src/eigenkit/core/backends/__init__.py @@ -7,9 +7,9 @@ or Braket) is imported only when a circuit first uses that provider. This keeps `import eigenkit` and `import eigenkit.core` free of these large dependencies. -`"pennylane"`, `"qiskit"`, `"braket"` and the other optional backends are -registered only when their extra is installed. `cudaq` is not registered -automatically: once it is installed, register it explicitly: +`"pennylane"`, `"qiskit"`, `"braket"`, `"router"` and the other optional +backends are registered only when their extra is installed. `cudaq` is not +registered automatically: once it is installed, register it explicitly: ```python from eigenkit import CudaQuantumBackend, register @@ -88,6 +88,20 @@ def _has_iqm_qiskit() -> bool: ) if importlib.util.find_spec("qulacs") is not None: register_lazy("qulacs", _loader("qulacs_backend", "QulacsBackend")) + + +# EigenOrbit Router through eigenorbit-router-client (the `router` extra). The +# backend lives in that package, not under this one, so it gets its own loader. +# `find_spec` on a top-level name locates the package without importing it, so +# `import eigenkit` pulls neither the client nor httpx. +def _router_backend(): + return importlib.import_module("eigenorbit_router_client").RouterBackend() + + +if importlib.util.find_spec("eigenorbit_router_client") is not None: + register_lazy("router", _router_backend) + + # Lazy class/config re-exports: name -> defining module (under this package). _LAZY_EXPORTS = { "BraketBackend": "braket_backend", diff --git a/src/eigenkit/core/provider.py b/src/eigenkit/core/provider.py index dddbfd6..8c83270 100644 --- a/src/eigenkit/core/provider.py +++ b/src/eigenkit/core/provider.py @@ -316,6 +316,7 @@ def register_lazy(name: str, loader: Callable[[], Provider]) -> None: "iqm": "install with `pip install eigenkit[iqm]`", "qiskit-aer": "install with `pip install eigenkit[qiskit]`", "qiskit-aer-mps": "install with `pip install eigenkit[qiskit]`", + "router": "install with `pip install eigenkit[router]`", "cudaq": ( "opt-in and Linux/arm64-macOS only: after installing it, register it " "yourself with `register('cudaq', CudaQuantumBackend())`" diff --git a/src/eigenkit/qml/_serialization.py b/src/eigenkit/qml/_serialization.py index 0b90387..6f1b11e 100644 --- a/src/eigenkit/qml/_serialization.py +++ b/src/eigenkit/qml/_serialization.py @@ -223,15 +223,23 @@ def serialize_provider_param(provider) -> object: # Fields that decide where a provider connects. They are never written to a # bundle and never read from one: a bundle is data from whoever shared it, and # loading it builds the backend with the loader's credentials (IQM_TOKEN, -# QISKIT_IBM_TOKEN), which must not go to a host the bundle names. -_ENDPOINT_FIELDS = ("url", "service_kwargs", "provider_kwargs", "backend_instance") +# QISKIT_IBM_TOKEN, EIGENORBIT_ROUTER_API_KEY), which must not go to a host the +# bundle names. +_ENDPOINT_FIELDS = ( + "url", + "service_kwargs", + "provider_kwargs", + "backend_instance", + "base_url", +) def deserialize_provider_param(value) -> object: """Restore a provider value from its JSON form. - The endpoint fields (`_ENDPOINT_FIELDS`) in the bundle are ignored, with a - warning. To connect elsewhere, pass `provider=` to `load()`. + The endpoint fields (`_ENDPOINT_FIELDS`) and the secret fields in the bundle + are ignored, with a warning. To connect elsewhere or with other credentials, + pass `provider=` to `load()`. Raises: SerializationError: If the configuration class is unknown. @@ -251,15 +259,28 @@ def deserialize_provider_param(value) -> object: ) from eigenkit import configs - # Every config `eigenkit.configs` exports, so a new one cannot save fine and + # Every config `eigenkit.configs` resolves, so a new one cannot save fine and # then fail to load. Only the class named is imported: the others may need - # SDKs this install lacks. - known = sorted({"NumpyConfig", *configs._LAZY_CONFIGS}) + # SDKs or distributions this install lacks. + known = sorted({"NumpyConfig", *configs._LAZY_CONFIGS, *configs._EXTERNAL_CONFIGS}) if cls_name not in known: raise SerializationError( f"Unknown provider config class {cls_name!r}. Known: {known}" ) - return getattr(configs, cls_name)(**kwargs) + cls = getattr(configs, cls_name) + # A credential in the bundle would run the loader's jobs, and send its + # inputs, under the bundle author's account; the loader's own is read instead. + secrets = sorted( + name for name in secret_field_names(cls) if kwargs.pop(name, None) is not None + ) + if secrets: + warnings.warn( + f"ignored {', '.join(secrets)} in the bundle's {cls_name}: a bundle " + "cannot carry credentials. Pass provider= to load() to set them.", + UserWarning, + stacklevel=2, + ) + return cls(**kwargs) def _observables_to_dict(observables) -> list | dict: diff --git a/tests/algorithms/test_serialization.py b/tests/algorithms/test_serialization.py index 0dc1294..3ba41a9 100644 --- a/tests/algorithms/test_serialization.py +++ b/tests/algorithms/test_serialization.py @@ -55,9 +55,9 @@ def test_hqnc_save_load_roundtrip(tmp_path): model.fit(X_tr, y_tr) pred_before = model.predict(X_te) - spk = tmp_path / "hqnc" - model.save(spk) - loaded = HQNC.load(spk) + bundle_path = tmp_path / "hqnc" + model.save(bundle_path) + loaded = HQNC.load(bundle_path) np.testing.assert_array_equal(pred_before, loaded.predict(X_te)) @@ -78,9 +78,9 @@ def test_recurrent_hqnr_save_load_roundtrip(tmp_path): model.fit(X_tr, y_tr) pred_before = model.predict(X_te) - spk = tmp_path / "recurrent_hqnr.eigenkit" - model.save(spk) - loaded = RecurrentHQNR.load(spk) + bundle_path = tmp_path / "recurrent_hqnr.eigenkit" + model.save(bundle_path) + loaded = RecurrentHQNR.load(bundle_path) np.testing.assert_allclose(pred_before, loaded.predict(X_te), rtol=1e-5, atol=1e-5) @@ -101,9 +101,9 @@ def test_recurrent_hqnc_save_load_roundtrip(tmp_path): model.fit(X_tr, y_tr) pred_before = model.predict(X_te) - spk = tmp_path / "recurrent_hqnc.eigenkit" - model.save(spk) - loaded = RecurrentHQNC.load(spk) + bundle_path = tmp_path / "recurrent_hqnc.eigenkit" + model.save(bundle_path) + loaded = RecurrentHQNC.load(bundle_path) np.testing.assert_array_equal(pred_before, loaded.predict(X_te)) @@ -119,9 +119,9 @@ def test_hqnr_save_load_roundtrip(tmp_path, reg_data): model.fit(X_tr, y_tr) pred_before = model.predict(X_te) - spk = tmp_path / "hqnr.eigenkit" - model.save(spk) - loaded = HQNR.load(spk) + bundle_path = tmp_path / "hqnr.eigenkit" + model.save(bundle_path) + loaded = HQNR.load(bundle_path) np.testing.assert_allclose(pred_before, loaded.predict(X_te), rtol=1e-5, atol=1e-5) @@ -131,21 +131,25 @@ def test_qsvc_save_load_roundtrip(tmp_path, clf_data): model.fit(X_tr, y_tr) pred_before = model.predict(X_te) - spk = tmp_path / "qsvc.eigenkit" - model.save(spk) - loaded = QSVC.load(spk) + bundle_path = tmp_path / "qsvc.eigenkit" + model.save(bundle_path) + loaded = QSVC.load(bundle_path) np.testing.assert_array_equal(pred_before, loaded.predict(X_te)) def test_a_failed_save_keeps_the_previous_bundle(tmp_path): - spk = tmp_path / "model.eigenkit" - save_bundle(spk, metadata={"v": 1}, params={}, circuit_spec=None, state={}) - before = spk.read_bytes() + bundle_path = tmp_path / "model.eigenkit" + save_bundle(bundle_path, metadata={"v": 1}, params={}, circuit_spec=None, state={}) + before = bundle_path.read_bytes() with pytest.raises(TypeError): save_bundle( - spk, metadata={"v": 2}, params={"x": object()}, circuit_spec=None, state={} + bundle_path, + metadata={"v": 2}, + params={"x": object()}, + circuit_spec=None, + state={}, ) - assert spk.read_bytes() == before + assert bundle_path.read_bytes() == before def test_qsvr_save_load_roundtrip(tmp_path, reg_data): @@ -154,9 +158,9 @@ def test_qsvr_save_load_roundtrip(tmp_path, reg_data): model.fit(X_tr, y_tr) pred_before = model.predict(X_te) - spk = tmp_path / "qsvr.eigenkit" - model.save(spk) - loaded = QSVR.load(spk) + bundle_path = tmp_path / "qsvr.eigenkit" + model.save(bundle_path) + loaded = QSVR.load(bundle_path) np.testing.assert_allclose(pred_before, loaded.predict(X_te), rtol=1e-5, atol=1e-5) @@ -173,9 +177,9 @@ def test_custom_classifier_save_load_roundtrip(tmp_path, clf_data): model.fit(X_tr, y_tr) pred_before = model.predict(X_te) - spk = tmp_path / "custom_clf.eigenkit" - model.save(spk) - loaded = CustomQuantumClassifier.load(spk) + bundle_path = tmp_path / "custom_clf.eigenkit" + model.save(bundle_path) + loaded = CustomQuantumClassifier.load(bundle_path) np.testing.assert_array_equal(pred_before, loaded.predict(X_te)) @@ -192,9 +196,9 @@ def test_custom_regressor_save_load_roundtrip(tmp_path, reg_data): model.fit(X_tr, y_tr) pred_before = model.predict(X_te) - spk = tmp_path / "custom_reg.eigenkit" - model.save(spk) - loaded = CustomQuantumRegressor.load(spk) + bundle_path = tmp_path / "custom_reg.eigenkit" + model.save(bundle_path) + loaded = CustomQuantumRegressor.load(bundle_path) np.testing.assert_allclose(pred_before, loaded.predict(X_te), rtol=1e-5, atol=1e-5) @@ -208,10 +212,10 @@ def test_provider_override_on_load(tmp_path, clf_data): show_progress=False, ) model.fit(X_tr, y_tr) - spk = tmp_path / "hqnc_provider.eigenkit" - model.save(spk) + bundle_path = tmp_path / "hqnc_provider.eigenkit" + model.save(bundle_path) - loaded = HQNC.load(spk, provider=NumpyConfig()) + loaded = HQNC.load(bundle_path, provider=NumpyConfig()) assert loaded.provider == NumpyConfig() assert loaded.predict(X_te).shape == (len(X_te),) @@ -231,9 +235,9 @@ def test_load_model_without_class_svm(tmp_path, clf_data, reg_data, estimator_cl pred_before = model.predict(X_te) rtol, atol = 1e-5, 1e-5 - spk = tmp_path / f"{estimator_cls.__name__}_load_model.eigenkit" - model.save(spk) - loaded = load_model(spk) + bundle_path = tmp_path / f"{estimator_cls.__name__}_load_model.eigenkit" + model.save(bundle_path) + loaded = load_model(bundle_path) assert isinstance(loaded, estimator_cls) if rtol is None: np.testing.assert_array_equal(pred_before, loaded.predict(X_te)) @@ -255,9 +259,9 @@ def test_train_losses_persisted_after_load(tmp_path, clf_data): model.fit(X_tr, y_tr) losses_before = list(model.train_losses_) - spk = tmp_path / "hqnc_train_losses.eigenkit" - model.save(spk) - loaded = HQNC.load(spk) + bundle_path = tmp_path / "hqnc_train_losses.eigenkit" + model.save(bundle_path) + loaded = HQNC.load(bundle_path) assert loaded.train_losses_ == losses_before assert len(loaded.train_losses_) == 2 @@ -274,9 +278,9 @@ def test_load_model_without_class(tmp_path, clf_data): model.fit(X_tr, y_tr) pred_before = model.predict(X_te) - spk = tmp_path / "hqnc_load_model.eigenkit" - model.save(spk) - loaded = load_model(spk) + bundle_path = tmp_path / "hqnc_load_model.eigenkit" + model.save(bundle_path) + loaded = load_model(bundle_path) assert isinstance(loaded, HQNC) np.testing.assert_array_equal(pred_before, loaded.predict(X_te)) @@ -291,10 +295,10 @@ def test_bundle_format_no_pickle(tmp_path, clf_data): show_progress=False, ) model.fit(X_tr, y_tr) - spk = tmp_path / "bundle.eigenkit" - model.save(spk) + bundle_path = tmp_path / "bundle.eigenkit" + model.save(bundle_path) - with zipfile.ZipFile(spk, "r") as zf: + with zipfile.ZipFile(bundle_path, "r") as zf: names = set(zf.namelist()) assert "metadata.json" in names assert "params.json" in names @@ -583,10 +587,10 @@ def test_qtsr_save_load_roundtrip(tmp_path, ts_data): model.fit(X, y) pred_before = model.predict(X[:20]) - spk = tmp_path / "qtsr.eigenkit" - model.save(spk) + bundle_path = tmp_path / "qtsr.eigenkit" + model.save(bundle_path) - loaded = QuantumTimeSeriesRegressor.load(spk) + loaded = QuantumTimeSeriesRegressor.load(bundle_path) np.testing.assert_allclose(pred_before, loaded.predict(X[:20]), rtol=1e-5, atol=1e-5) @@ -609,10 +613,10 @@ def test_qtsr_bundle_contains_format_version(tmp_path, ts_data): model = QuantumTimeSeriesRegressor(estimator=inner, in_size=5, shift=1) model.fit(X, y) - spk = tmp_path / "qtsr_meta.eigenkit" - model.save(spk) + bundle_path = tmp_path / "qtsr_meta.eigenkit" + model.save(bundle_path) - with zipfile.ZipFile(spk, "r") as zf: + with zipfile.ZipFile(bundle_path, "r") as zf: meta = json.loads(zf.read("metadata.json")) assert meta.get("format_version") == 1 assert meta.get("estimator_class") == "HQNR" @@ -635,12 +639,12 @@ def test_qtsc_save_load_roundtrip(tmp_path): model.fit(X, y) pred_before = model.predict(X[:15]) - spk = tmp_path / "qtsc.eigenkit" - model.save(spk) + bundle_path = tmp_path / "qtsc.eigenkit" + model.save(bundle_path) - loaded = QuantumTimeSeriesClassifier.load(spk) + loaded = QuantumTimeSeriesClassifier.load(bundle_path) np.testing.assert_array_equal(pred_before, loaded.predict(X[:15])) - np.testing.assert_array_equal(pred_before, load_model(spk).predict(X[:15])) + np.testing.assert_array_equal(pred_before, load_model(bundle_path).predict(X[:15])) def test_qtsc_bundle_contains_format_version(tmp_path): @@ -661,10 +665,10 @@ def test_qtsc_bundle_contains_format_version(tmp_path): model = QuantumTimeSeriesClassifier(estimator=inner, in_size=3, shift=1) model.fit(X, y) - spk = tmp_path / "qtsc_meta.eigenkit" - model.save(spk) + bundle_path = tmp_path / "qtsc_meta.eigenkit" + model.save(bundle_path) - with zipfile.ZipFile(spk, "r") as zf: + with zipfile.ZipFile(bundle_path, "r") as zf: meta = json.loads(zf.read("metadata.json")) assert meta.get("format_version") == 1 assert meta.get("estimator_class") == "RecurrentHQNC" @@ -690,10 +694,10 @@ def test_qtsc_autoregressive_save_load_roundtrip(tmp_path): model.fit(X, y) pred_before = model.predict(X[:15], y_history=y[:15]) - spk = tmp_path / "qtsc_ar.eigenkit" - model.save(spk) + bundle_path = tmp_path / "qtsc_ar.eigenkit" + model.save(bundle_path) - loaded = QuantumTimeSeriesClassifier.load(spk) + loaded = QuantumTimeSeriesClassifier.load(bundle_path) pred_after = loaded.predict(X[:15], y_history=y[:15]) np.testing.assert_array_equal(pred_before, pred_after) diff --git a/tests/core/test_router_seam.py b/tests/core/test_router_seam.py new file mode 100644 index 0000000..cd806a9 --- /dev/null +++ b/tests/core/test_router_seam.py @@ -0,0 +1,433 @@ +"""The `router` provider seam to eigenorbit-router-client. + +eigenkit registers `router` lazily when the client package is importable, and +the client registers the same name itself when it is imported. Both loaders +build the client's `RouterBackend`, so the import order must not change what +`get("router")` does. The client is not a test dependency: these tests write a +small stand-in package with the client's shape into a temporary directory and +run each check in a fresh interpreter, so the result does not depend on what the +test session has already imported. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import textwrap +import tomllib +from pathlib import Path + +import pytest + + +_REPO_ROOT = Path(__file__).resolve().parents[2] + +_NO_DEVICE = ( + "provider='router' needs a device: pass provider=RouterConfig(device='braket/sv1')" +) + +# The client's public shape: a backend that refuses to be built without a +# device, a config whose API key is a secret field, and the client's own +# registration of `router`, run last on import. +_FAKE_CLIENT = f""" +from dataclasses import KW_ONLY, dataclass, field + +from eigenkit.core.provider import register_lazy +from eigenkit.core.secret_fields import SecretFieldsMixin + + +class RouterBackend: + def __init__(self, config=None): + if config is None: + raise ValueError({_NO_DEVICE!r}) + self.config = config + + +@dataclass(frozen=True) +class RouterConfig(SecretFieldsMixin): + device: str + _: KW_ONLY + api_key: str | None = field(default=None, repr=False, metadata={{"secret": True}}) + base_url: str | None = None + + def make_backend(self): + return RouterBackend(config=self) + + +register_lazy("router", lambda: RouterBackend()) +""" + +_CIRCUIT = """ +import eigenkit as ek + +def router_circuit(): + return ek.Circuit( + [ek.Encoding(in_features=2), ek.Variational(depth=1, rotation="RY")], + ek.all_z(2), + provider="router", + interface="numpy", + ) +""" + + +@pytest.fixture +def fake_client(tmp_path: Path) -> Path: + """Write the stand-in client package and return the directory to put on the path.""" + package = tmp_path / "eigenorbit_router_client" + package.mkdir() + (package / "__init__.py").write_text(_FAKE_CLIENT) + return tmp_path + + +def _run(code: str, pythonpath: Path | None = None) -> subprocess.CompletedProcess: + env = dict(os.environ) + env.pop("PYTHONPATH", None) + if pythonpath is not None: + env["PYTHONPATH"] = str(pythonpath) + return subprocess.run( + [sys.executable, "-c", textwrap.dedent(code)], + capture_output=True, + text=True, + env=env, + ) + + +def test_router_is_not_offered_without_the_client(): + # A `None` entry in sys.modules makes the package unimportable, and + # `find_spec` then reports it missing, whatever the environment holds. + result = _run( + """ + import sys + sys.modules["eigenorbit_router_client"] = None + from eigenkit.core.provider import get, registered_names + assert "router" not in registered_names(), registered_names() + try: + get("router") + except KeyError as error: + assert "pip install eigenkit[router]" in str(error), str(error) + else: + raise AssertionError("router resolved without the client") + """ + ) + assert result.returncode == 0, result.stderr + + +def test_import_eigenkit_registers_router_without_importing_the_client(fake_client): + result = _run( + """ + import sys + import eigenkit # noqa: F401 + from eigenkit.core.provider import registered_names + assert "router" in registered_names(), registered_names() + loaded = sorted( + m for m in ("eigenorbit_router_client", "httpx") if m in sys.modules + ) + assert not loaded, f"import eigenkit pulled: {loaded}" + """, + fake_client, + ) + assert result.returncode == 0, result.stderr + + +def test_router_without_a_device_names_router_config_eigenkit_first(fake_client): + result = _run( + _CIRCUIT + + """ +from eigenkit.core.provider import get + +for attempt in range(2): + try: + get("router") + except ValueError as error: + assert "RouterConfig(device=" in str(error), str(error) + else: + raise AssertionError("router built a backend without a device") + +try: + router_circuit() +except ValueError as error: + assert "RouterConfig(device=" in str(error), str(error) +else: + raise AssertionError("Circuit accepted provider='router' without a device") +""", + fake_client, + ) + assert result.returncode == 0, result.stderr + + +def test_router_without_a_device_names_router_config_client_first(fake_client): + # The client registers `router` on import as well; whichever registration + # wins, the lookup must fail the same way. + result = _run( + """ +import eigenorbit_router_client # noqa: F401 +""" + + _CIRCUIT + + """ +from eigenkit.core.provider import get, registered_names + +assert "router" in registered_names(), registered_names() +try: + get("router") +except ValueError as error: + assert "RouterConfig(device=" in str(error), str(error) +else: + raise AssertionError("router built a backend without a device") + +try: + router_circuit() +except ValueError as error: + assert "RouterConfig(device=" in str(error), str(error) +else: + raise AssertionError("Circuit accepted provider='router' without a device") +""", + fake_client, + ) + assert result.returncode == 0, result.stderr + + +def test_router_extra_pins_the_client_and_is_part_of_all(): + pyproject = tomllib.loads((_REPO_ROOT / "pyproject.toml").read_text()) + extras = pyproject["project"]["optional-dependencies"] + assert extras["router"] == ["eigenorbit-router-client>=0.1,<1"] + assert any("router" in requirement for requirement in extras["all"]), extras["all"] + + +def test_configs_router_config_is_the_clients_class(fake_client): + result = _run( + """ + import eigenkit.configs as configs + import eigenorbit_router_client + from eigenkit.configs import RouterConfig + + assert RouterConfig is eigenorbit_router_client.RouterConfig + assert "RouterConfig" not in configs._LAZY_CONFIGS + assert "RouterConfig" not in configs.__all__ + """, + fake_client, + ) + assert result.returncode == 0, result.stderr + + +def test_configs_router_config_names_the_extra_without_the_client(): + result = _run( + """ + import sys + sys.modules["eigenorbit_router_client"] = None + try: + from eigenkit.configs import RouterConfig # noqa: F401 + except ImportError as error: + assert "pip install eigenkit[router]" in str(error), str(error) + assert not isinstance(error, ModuleNotFoundError), repr(error) + else: + raise AssertionError("RouterConfig imported without the client") + """ + ) + assert result.returncode == 0, result.stderr + + +def test_configs_star_import_does_not_need_the_client(): + result = _run( + """ + import sys + sys.modules["eigenorbit_router_client"] = None + from eigenkit.configs import * # noqa: F403 + assert "NumpyConfig" in globals() + assert "RouterConfig" not in globals() + """ + ) + assert result.returncode == 0, result.stderr + + +def test_configs_router_config_keeps_a_missing_dependency_of_the_client(tmp_path): + # A dependency missing inside the installed client is a different fault from + # a missing client, and its error must reach the caller unchanged. + package = tmp_path / "eigenorbit_router_client" + package.mkdir() + (package / "__init__.py").write_text("import a_dependency_that_is_missing\n") + result = _run( + """ + try: + from eigenkit.configs import RouterConfig # noqa: F401 + except ModuleNotFoundError as error: + assert error.name == "a_dependency_that_is_missing", repr(error) + else: + raise AssertionError("RouterConfig imported from a broken client") + """, + tmp_path, + ) + assert result.returncode == 0, result.stderr + + +def test_configs_unknown_name_is_still_an_attribute_error(): + import eigenkit.configs as configs + + with pytest.raises(AttributeError, match="has no attribute 'NoSuchConfig'"): + configs.NoSuchConfig # noqa: B018 + + +def test_bundle_cannot_send_the_router_key_to_a_host_it_names(fake_client): + # A bundle is data from whoever shared it; loading builds the router backend + # with the loader's API key, which must only go to the URL the loader sets. + result = _run( + """ + import warnings + import eigenorbit_router_client + from eigenkit.qml._serialization import deserialize_provider_param + + payload = { + "__provider_config__": "RouterConfig", + "base_url": "https://attacker.example", + "device": "braket/sv1", + } + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + config = deserialize_provider_param(payload) + messages = [str(warning.message) for warning in caught] + assert any( + "cannot choose where a provider connects" in message + and "base_url" in message + for message in messages + ), messages + assert isinstance(config, eigenorbit_router_client.RouterConfig) + assert config.base_url is None + assert config.api_key is None + assert config.device == "braket/sv1" + """, + fake_client, + ) + assert result.returncode == 0, result.stderr + + +def test_bundle_cannot_make_the_loader_run_under_its_own_key(fake_client): + # A key in the bundle would send the loader's jobs and inputs to the bundle + # author's account; the provider must read the loader's own key instead. + result = _run( + """ + import warnings + import eigenorbit_router_client + from eigenkit.qml._serialization import deserialize_provider_param + + payload = { + "__provider_config__": "RouterConfig", + "api_key": "eo_live_bundle_author_key", + "device": "braket/sv1", + } + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + config = deserialize_provider_param(payload) + messages = [str(warning.message) for warning in caught] + assert any( + "cannot carry credentials" in message and "api_key" in message + for message in messages + ), messages + assert isinstance(config, eigenorbit_router_client.RouterConfig) + assert config.api_key is None + assert config.device == "braket/sv1" + """, + fake_client, + ) + assert result.returncode == 0, result.stderr + + +def test_bundle_stores_neither_the_router_key_nor_its_url(fake_client): + result = _run( + """ + import json + from eigenkit.configs import RouterConfig + from eigenkit.qml._serialization import serialize_provider_param + + config = RouterConfig( + device="braket/sv1", + api_key="a-router-key-to-keep-out", + base_url="https://router.example", + ) + payload = serialize_provider_param(config) + assert payload == { + "__provider_config__": "RouterConfig", + "device": "braket/sv1", + }, payload + text = json.dumps(payload) + assert "a-router-key-to-keep-out" not in text + assert "router.example" not in text + """, + fake_client, + ) + assert result.returncode == 0, result.stderr + + +def test_saved_model_with_a_router_provider_loads(fake_client, tmp_path): + # The model is fitted on the reference simulator, then given a router + # provider and saved; the bundle is edited to name a host, as a shared + # bundle could, and loads with the router config but without the host. + # Loading rebuilds the kernel on the provider's backend, and the stand-in + # client has no working one, so the reference simulator stands in for it. + result = _run( + f""" + import json + import warnings + import zipfile + + import numpy as np + + import eigenorbit_router_client + from eigenkit.configs import NumpyConfig + from eigenkit.qml import QSVC + + eigenorbit_router_client.RouterConfig.make_backend = ( + lambda self: NumpyConfig().make_backend() + ) + + rng = np.random.default_rng(0) + X = rng.uniform(size=(8, 2)) + y = np.array([0, 1] * 4) + model = QSVC(feature_map="Angle", reps=1, provider="numpy").fit(X, y) + predictions = model.predict(X) + model.provider = eigenorbit_router_client.RouterConfig( + device="braket/sv1", + api_key="a-router-key-to-keep-out", + base_url="https://router.example", + ) + bundle_path = {str(tmp_path / "router.eigenkit")!r} + model.save(bundle_path) + + with zipfile.ZipFile(bundle_path) as archive: + entries = {{name: archive.read(name) for name in archive.namelist()}} + params = json.loads(entries["params.json"]) + assert params["provider"] == {{ + "__provider_config__": "RouterConfig", + "device": "braket/sv1", + }}, params["provider"] + params["provider"]["base_url"] = "https://attacker.example" + entries["params.json"] = json.dumps(params) + with zipfile.ZipFile(bundle_path, "w") as archive: + for name, data in entries.items(): + archive.writestr(name, data) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + loaded = QSVC.load(bundle_path) + assert any( + "cannot choose where a provider connects" in str(warning.message) + for warning in caught + ) + assert isinstance(loaded.provider, eigenorbit_router_client.RouterConfig) + assert loaded.provider.device == "braket/sv1" + assert loaded.provider.base_url is None + assert loaded.provider.api_key is None + np.testing.assert_array_equal(loaded.predict(X), predictions) + """, + fake_client, + ) + assert result.returncode == 0, result.stderr + + +def test_unknown_provider_config_lists_router_config(): + from eigenkit.qml._serialization import ( + SerializationError, + deserialize_provider_param, + ) + + with pytest.raises(SerializationError, match="RouterConfig"): + deserialize_provider_param({"__provider_config__": "NoSuchConfig"}) diff --git a/uv.lock b/uv.lock index 6f462fc..1313a80 100644 --- a/uv.lock +++ b/uv.lock @@ -100,6 +100,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/03/a851e84fcbb85214dc637b6378121ef9a0dd61b4c65264675d8a5c9b1ae7/antlr4_python3_runtime-4.13.2-py3-none-any.whl", hash = "sha256:fe3835eb8d33daece0e799090eda89719dbccee7aa39ef94eed3818cafa5a7e8", size = 144462, upload-time = "2024-08-03T19:00:11.134Z" }, ] +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + [[package]] name = "appdirs" version = "1.4.4" @@ -1039,6 +1052,7 @@ all = [ { name = "cirq-core" }, { name = "cuda-quantum-cu13", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or sys_platform == 'linux'" }, { name = "cudaq", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or sys_platform == 'linux'" }, + { name = "eigenorbit-router-client" }, { name = "iqm-client", extra = ["qiskit"], marker = "python_full_version < '3.14'" }, { name = "jax" }, { name = "matplotlib" }, @@ -1116,6 +1130,9 @@ qsim = [ qulacs = [ { name = "qulacs", marker = "python_full_version < '3.14'" }, ] +router = [ + { name = "eigenorbit-router-client" }, +] tensorcircuit = [ { name = "jax" }, { name = "tensorcircuit-ng" }, @@ -1157,7 +1174,8 @@ requires-dist = [ { name = "cudaq", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin' and extra == 'cudaquantum') or (sys_platform == 'linux' and extra == 'cudaquantum')", specifier = ">=0.14.0" }, { name = "eigenkit", extras = ["qiskit"], marker = "extra == 'iqm'" }, { name = "eigenkit", extras = ["qml"], marker = "extra == 'torch'" }, - { name = "eigenkit", extras = ["torch", "pennylane", "qiskit", "braket", "iqm", "tensorcircuit", "cudaquantum", "qulacs", "qsim", "benchmark", "datasets", "chemistry", "qml", "viz"], marker = "extra == 'all'" }, + { name = "eigenkit", extras = ["torch", "pennylane", "qiskit", "braket", "iqm", "tensorcircuit", "cudaquantum", "qulacs", "qsim", "router", "benchmark", "datasets", "chemistry", "qml", "viz"], marker = "extra == 'all'" }, + { name = "eigenorbit-router-client", marker = "extra == 'router'", specifier = ">=0.1,<1" }, { name = "iqm-client", extras = ["qiskit"], marker = "python_full_version < '3.14' and extra == 'iqm'", specifier = ">=35,<36" }, { name = "jax", marker = "extra == 'tensorcircuit'", specifier = ">=0.4" }, { name = "matplotlib", marker = "extra == 'benchmark'", specifier = ">=3.7" }, @@ -1188,7 +1206,7 @@ requires-dist = [ { name = "torch", marker = "sys_platform != 'darwin' and extra == 'torch'", specifier = ">=2.5,<3", index = "https://download.pytorch.org/whl/cu126" }, { name = "tqdm", marker = "extra == 'torch'", specifier = ">=4.66,<5" }, ] -provides-extras = ["torch", "pennylane", "qiskit", "braket", "iqm", "tensorcircuit", "cudaquantum", "qulacs", "qsim", "perceval", "datasets", "benchmark", "viz", "chemistry", "qml", "all"] +provides-extras = ["torch", "pennylane", "qiskit", "braket", "iqm", "tensorcircuit", "cudaquantum", "qulacs", "qsim", "perceval", "router", "datasets", "benchmark", "viz", "chemistry", "qml", "all"] [package.metadata.requires-dev] dev = [ @@ -1207,6 +1225,20 @@ dev = [ { name = "ruff", specifier = ">=0.16.10" }, ] +[[package]] +name = "eigenorbit-router-client" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eigenkit" }, + { name = "httpx" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/00/0ec3bd0d4cfedb1456fc8a7ff1b0178af45ceddac2ed28a040a6dca03d51/eigenorbit_router_client-0.1.0.tar.gz", hash = "sha256:24c08a51dac4aaeceb6ac4f761e50c073de3548909e8b24be2cf21ee6f8bb94d", size = 227544, upload-time = "2026-10-19T08:28:13.404Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/25/9589071692b9b852851e2337e5b7dbd3eee29c82d32c4a1acdae6217b79d/eigenorbit_router_client-0.1.0-py3-none-any.whl", hash = "sha256:32e4cef3c55536be91670a5d6f65f4c2b4caaba04a5f10939592b54d6ff8ea1f", size = 28742, upload-time = "2026-10-19T08:28:12.116Z" }, +] + [[package]] name = "exqalibur" version = "1.4.2" @@ -1491,6 +1523,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/03/640811d4d8c84f5e603995c5a9bab725223aa472cad9ca4286c3bbf1c3e3/grpcio-1.84.0-cp314-cp314-win_amd64.whl", hash = "sha256:800b7e00d92553313c0463c200087930aa78678ec1d528193aeb50906f55989b", size = 5394092, upload-time = "2026-09-14T06:58:59.61Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + [[package]] name = "h5py" version = "3.16.0" @@ -1534,6 +1575,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/91/9fad90cfc5f9b2489c7c26ad897157bce82f0e9534a986a221b99760b23b/h5py-3.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:faca8fb4e4319c09d83337adc80b2ca7d5c5a343c2d6f1b6388f32cfecca13c1", size = 2740706, upload-time = "2026-03-06T13:49:06.347Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "ibm-cloud-sdk-core" version = "3.28.0"