diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 94869f5..208b1ee 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,6 +20,26 @@ defaults: shell: bash -euo pipefail {0} jobs: + quality: + name: Lint, types, and docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + filter: blob:none + fetch-depth: 0 + - uses: astral-sh/setup-uv@v7 + with: + cache-dependency-glob: | + pyproject.toml + hatch.toml + - name: Run pre-commit + env: + SKIP: no-commit-to-branch + run: uvx hatch run lint:check + - name: Build documentation + run: uvx hatch run docs:build + build: name: Build runs-on: ubuntu-latest @@ -113,6 +133,7 @@ jobs: name: All CI Green if: always() needs: + - quality - build - get-test-environments - test diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 82f8dbd..d05d996 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,11 +1,3 @@ -# This workflow will upload a Python Package to PyPI when a release is created -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - name: Upload Python Package on: @@ -20,42 +12,34 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 with: - python-version: "3.x" + fetch-depth: 0 + + - uses: astral-sh/setup-uv@v7 - - name: Build release distributions + - name: Build and validate release distributions run: | - # NOTE: put your own distribution build steps here. - python -m pip install build - python -m build + uv build + uvx twine check --strict dist/* - name: Upload distributions uses: actions/upload-artifact@v4 with: name: release-dists path: dist/ + if-no-files-found: error pypi-publish: runs-on: ubuntu-latest needs: - release-build permissions: - # IMPORTANT: this permission is mandatory for trusted publishing id-token: write - # Dedicated environments with protections for publishing are strongly recommended. - # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules environment: name: pypi - # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status: - # url: https://pypi.org/p/YOURPROJECT - # - # ALTERNATIVE: if your GitHub Release name is the PyPI project version string - # ALTERNATIVE: exactly, uncomment the following line instead: - # url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }} + url: https://pypi.org/p/scverse-backends steps: - name: Retrieve release distributions diff --git a/README.md b/README.md index 3526957..0e4559e 100644 --- a/README.md +++ b/README.md @@ -6,22 +6,37 @@ > ⚠️ **Under active development.** APIs may shift. -The default plugin & dispatch mechanism for [scverse](https://scverse.org). -Any host library decorates its public functions with `@backend_dispatch`; any -backend — GPU, distributed, JAX, PyTorch, anything -— plugs in via a Python entrypoint and gets picked up automatically. +The default plugin and dispatch mechanism for [scverse](https://scverse.org). +Host libraries mark public functions with `@backend_dispatch` or replaceable +classes with `@backend_class`. GPU, distributed, JAX, PyTorch, and other +backends plug in through Python entry points and are discovered automatically. Want to add a PyTorch backend, a JAX backend, your own custom one? **You don't need a PR against the host.** Ship a package that exposes -a module or object with `name`, `aliases`, and host-named callables, +a module or object with `name`, `aliases`, and host-named functions or classes, register it as an entry point, and users install it next to the host. -That's the whole contract. + +## Install + +```console +pip install scverse-backends +``` + +## At a glance ```python import example_host as eh +# One function call +eh.some_function(data, backend="accelerated") + +# A complete backend-provided class +model = eh.SomeModel(data, backend="accelerated") + +# A scoped default for functions and classes with eh.settings.use_backend("accelerated"): eh.some_function(data) + model = eh.SomeModel(data) ``` ## Status @@ -33,7 +48,3 @@ with eh.settings.use_backend("accelerated"): ## Docs Full docs at [scverse-backends.readthedocs.io](https://scverse-backends.readthedocs.io/en/latest/). - -## License - -MIT. diff --git a/docs/api.md b/docs/api.md index c9ed8c0..de54975 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2,6 +2,17 @@ ## Public API +`BackendDispatcher` is the host-owned entry point. A host normally re-exports +these members: + +| member | purpose | +| --- | --- | +| `backend_dispatch` | decorate a module-level function | +| `backend_class` | decorate a completely replaceable class | +| `settings` | select a default backend globally or in a context | +| `get_backend(name)` | retrieve a discovered adapter | +| `available_backend_names()` | list registered canonical names and aliases | + ```{eval-rst} .. currentmodule:: scverse_backends diff --git a/docs/index.md b/docs/index.md index d630d3b..23edbd1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,10 +9,11 @@ This repository is under active development. APIs may shift before the first stable release. ``` -**The default plugin & dispatch mechanism for [scverse](https://scverse.org).** -Any host library decorates its public functions with `@backend_dispatch`; any -backend — GPU, distributed, JAX, PyTorch, your own — plugs in via a -Python entrypoint and gets picked up automatically. +**The default plugin and dispatch mechanism for +[scverse](https://scverse.org).** Host libraries mark public functions with +`@backend_dispatch` and replaceable classes with `@backend_class`. GPU, +distributed, JAX, PyTorch, and other backends plug in through Python entry +points and are discovered automatically. ## At a glance @@ -22,7 +23,10 @@ import example_host as eh # Per-call backend eh.compute_score(data, method="fast", backend="cuda") -# Global +# Complete class replacement +model = eh.Neighborhood(data, backend="cuda") + +# Current context eh.settings.backend = "cuda" eh.compute_score(data, method="fast") @@ -33,11 +37,10 @@ with eh.settings.use_backend("cuda"): ## Want to add a backend? -**No PR against the host needed.** Ship a package that exposes a module -or object with `name`, `aliases`, and callables named after the host -functions you implement, register it under the host's entrypoint group, -and users install it next to the host. That's the entire contract — see -{doc}`usage/backend`. +**No PR against the host needed.** Ship a package that exposes a module or +object with `name`, `aliases`, and functions or classes named after the host +APIs you implement. Register it under the host's entrypoint group, and users +install it next to the host. See {doc}`usage/backend`. This is the path for a PyTorch backend, a JAX backend, a Dask backend, or anything else. The host library doesn't need to know you exist. @@ -58,6 +61,7 @@ to. usage/host usage/backend usage/conformance +release-notes api ``` diff --git a/docs/release-notes.md b/docs/release-notes.md new file mode 100644 index 0000000..94ecb7f --- /dev/null +++ b/docs/release-notes.md @@ -0,0 +1,34 @@ +# Release notes + +## 0.0.3 — 2026-07-23 + +### Whole-class dispatch + +- Added `BackendDispatcher.backend_class` for APIs whose backend replaces a + complete class. +- Class construction supports the active setting and a per-instance + `backend=` override, with CPU fallback when an adapter does not implement the + class. +- Host signatures, documentation, custom metaclasses, class APIs, and normal + subclass construction are preserved. +- Backend adapter exports are validated as classes, and recursive + self-registration is rejected with a clear error. + +### Reliability and packaging + +- Invalid dispatcher identity and trusted-provider configuration now fail + early with actionable errors. +- One broken adapter registration no longer prevents other entry points from + being discovered. +- The conformance runner raises an explicit `ValueError` when a backend cannot + be resolved or a requested function filter is invalid. +- Function dispatch rejects unsupported methods and variadic positional host + signatures instead of silently misrouting values. +- Positional-only backend calls preserve omitted defaults, and host-only + default comparisons no longer assume scalar equality. +- Runtime type-hint introspection now resolves every public annotation. +- Discovery can be retried after metadata or signature-update failures, and + adapter metadata failures cannot leave a partially registered backend. +- Installed trusted backends report host-configured aliases consistently. +- CI and publishing configuration now exercise the same lint, documentation, + coverage, build, and metadata checks used for release validation. diff --git a/docs/usage/backend.md b/docs/usage/backend.md index 36ecf85..91fa2aa 100644 --- a/docs/usage/backend.md +++ b/docs/usage/backend.md @@ -1,13 +1,13 @@ # Plugging in a backend -A *backend* is a Python package that provides alternative implementations -for one or more functions in a host library. Common examples are GPU, +A *backend* is a Python package that provides alternative implementations for +one or more functions or classes in a host library. Common examples are GPU, distributed, JAX, or PyTorch implementations. The backend contract is intentionally small: **no inheritance, no `scverse-backends` import, no base class**. A backend exposes a module or -object with metadata and callables; the host discovers it through Python -entry points. +object with metadata and same-named implementations; the host discovers it +through Python entry points. ## Recommended layout @@ -32,18 +32,18 @@ An adapter module needs three things: 1. `name` — the canonical backend name. 2. `aliases` — optional concrete names users can pass to `settings.backend = ...` or `backend=...`. -3. Callables named after the host functions the backend implements. +3. Callables or classes named after the host APIs the backend implements. ```python # my_backend/_backends/example_host.py from __future__ import annotations -from my_backend.example_host_impl import compute_score, embed, summarize +from my_backend.example_host_impl import Neighborhood, compute_score, embed name = "my_backend" aliases = ["mine", "cuda"] -__all__ = ["compute_score", "embed", "summarize"] +__all__ = ["Neighborhood", "compute_score", "embed"] ``` If the implementations live across several backend packages, gather them @@ -139,13 +139,25 @@ def __getattr__(attr_name: str): ``` This lets the host inspect the adapter metadata without importing CUDA, -JAX, or another heavy runtime during normal host import. +JAX, or another heavy runtime during normal host import. On backend discovery, +the host may inspect every matching decorated API to merge signatures and +documentation, so `__getattr__` can load more than the one function the user is +about to call. If per-function loading matters, export lightweight wrappers +with explicit public signatures and import the heavy implementation inside +each wrapper body. ## Function signatures Backend callables should use the same names as host functions for shared parameters. Any extra public keyword-capable parameters are treated as -backend-only parameters. +backend-only parameters. Both host and backend callables must expose signatures +that Python's `inspect.signature` can read; wrap extension callables or provide +an explicit `__signature__` when necessary. + +The host function cannot use variadic positional parameters (`*args`); +dispatch is name-based. A backend callable may accept `**kwargs`, but explicit +named parameters provide better signatures, routing, and generated +documentation. ```python def compute_score( @@ -199,6 +211,50 @@ batch_size (my_backend) Private parameters such as `_internal` are not injected into host docs. Use public keyword-only parameters for user-facing backend options. +## Complete class implementations + +When a host uses `@backend_class`, export a class with exactly the same name +from the adapter: + +```python +# example_host +@backend_class +class Neighborhood: + def __init__(self, data, *, n_neighbors=15): + ... +``` + +```python +# my_backend/_backends/example_host.py +from my_backend.example_host_impl import Neighborhood + +name = "my_backend" +aliases = ["mine", "cuda"] + +__all__ = ["Neighborhood"] +``` + +The backend class is a complete replacement and does not need to inherit from +the host class. Keep its public constructor compatible with the host +constructor: + +```python +# my_backend/example_host_impl.py +class Neighborhood: + def __init__(self, data, *, n_neighbors=15): + self.data = move_to_device(data) + self.n_neighbors = n_neighbors +``` + +At construction time, `scverse-backends` removes the `backend` selector and +passes every other argument directly to this constructor. Backend-only +constructor parameters are not merged into the decorated host signature or +documentation, so shared constructor contracts are strongly recommended. + +If an adapter does not export the class, the host class is used as a fallback. +If it exports that name as something other than a class, construction raises a +`TypeError` with the backend and class names. + ## Trusted backends Trust is owned by the host. A backend cannot make itself trusted by @@ -253,4 +309,6 @@ my_backend = "my_backend._backends.example_host" - Keep adapter imports cheap, or use `__getattr__` for lazy loading. - Match host function names and shared parameter names. - Put backend-only option docs in the backend function's numpydoc. +- Export same-named classes for hosts using `@backend_class`, and keep their + constructor contracts compatible. - Consider running host feedback tests in backend CI. diff --git a/docs/usage/conformance.md b/docs/usage/conformance.md index f8833b3..16591df 100644 --- a/docs/usage/conformance.md +++ b/docs/usage/conformance.md @@ -95,6 +95,10 @@ hardware-specific edge cases. default, re-raises the first failure so pytest shows the useful traceback. +Passing `functions=[...]` restricts the run to named host checks. Unknown +function names and malformed filters raise `ValueError`, so a typo cannot +accidentally look like a successful empty run. + The runner intentionally does not import the host, NumPy, CuPy, or any backend runtime. All scientific setup and assertions remain inside host- or backend-owned tests. diff --git a/docs/usage/host.md b/docs/usage/host.md index fbb3220..92493bb 100644 --- a/docs/usage/host.md +++ b/docs/usage/host.md @@ -24,6 +24,7 @@ _dispatcher = BackendDispatcher( ) backend_dispatch = _dispatcher.backend_dispatch +backend_class = _dispatcher.backend_class settings = _dispatcher.settings get_backend = _dispatcher.get_backend available_backend_names = _dispatcher.available_backend_names @@ -84,11 +85,28 @@ independently in the same process. No backend may claim these names. Use this for names that are too generic or intentionally left undefined by the host. -## 2. Decorate your public functions +## 2. Decorate public functions or classes + +Choose the decorator that matches the unit a backend replaces: + +| host API | decorator | backend adapter export | +| --- | --- | --- | +| module-level function | `@backend_dispatch` | same-named callable | +| complete class | `@backend_class` | same-named class | + +Implementations are matched by their plain Python `__name__` within one +dispatcher. Keep decorated public function and class names unique across the +host API when they require different backend implementations. + +### Dispatching a function Use `@backend_dispatch` on module-level public functions. Instance methods and -class methods are outside the dispatch contract because their `self`/`cls` -binding does not map cleanly onto backend adapter callables. +class methods are outside the function-dispatch contract because their +`self`/`cls` binding does not map cleanly onto backend adapter callables. Host +functions with variadic positional parameters (`*args`) are also rejected +because name-based routing cannot preserve their meaning safely. Regular +positional, positional-only, keyword-only, and `**kwargs` parameters are +supported. ```python # example_host/analysis.py @@ -119,13 +137,73 @@ signature and numpydoc. `None` means "use the active setting"; The injected `backend` selector is documented with the host-owned `Parameters`, while backend-specific parameters are placed under `Other Parameters` and marked on the parameter line with the backend that -provided them. That -keeps host-owned knobs visually separate from optional backend package -knobs. +provided them. This keeps host-owned knobs visually separate from optional +backend package knobs. For example, a backend-only parameter from `example_accel` is rendered as `solver (example_accel)` under `Other Parameters`. +### Dispatching a complete class + +Use `@backend_class` when construction should return a backend's complete +implementation instead of the host implementation. The adapter exposes a class +with the same name; it does not need to inherit from the host class. + +Apply `@backend_class` as the outermost class decorator so it receives the +finished host class. For example, place it above `@dataclass`, `@define`, or +another decorator that generates the constructor or modifies the class. + +```python +# example_host/models.py +from example_host._backends import backend_class + +@backend_class +class Neighborhood: + def __init__(self, data, *, n_neighbors=15): + self.data = data + self.n_neighbors = n_neighbors +``` + +```python +# example_accel/_backends/example_host.py +class Neighborhood: + def __init__(self, data, *, n_neighbors=15): + self.data = move_to_device(data) + self.n_neighbors = n_neighbors +``` + +Construction follows the same selection order as function dispatch: + +```python +Neighborhood(data) # active setting, CPU by default +Neighborhood(data, backend="example") # backend class for this instance +Neighborhood(data, backend="cpu") # host class for this instance +``` + +The `backend` selector is consumed by the decorator and is not forwarded to +either constructor. All other positional and keyword arguments are passed to +the selected class unchanged. If the selected backend has no `Neighborhood` +class, construction falls back to the host implementation. + +Keep the backend constructor compatible with the host's public constructor +signature. Unlike function dispatch, `@backend_class` does not merge +backend-only constructor parameters or docstrings into the host API. + +Only direct construction of the decorated class dispatches. An undecorated +subclass keeps normal Python construction semantics; decorate that subclass +too if it needs its own same-named backend implementation. Custom metaclasses +and class APIs are preserved, but the host class must be subclassable because +the decorator creates a lightweight dispatch subclass. + +The returned backend object is an instance of the backend's class. Backends do +not have to inherit from the host class, so code should rely on the documented +interface rather than assuming `isinstance(obj, Neighborhood)` for +backend-created objects. + +Dispatch happens when the class is constructed. Accessing a class attribute or +calling a class method on `Neighborhood` still uses the host class; behavior on +the constructed object comes from whichever class was selected. + ## 3. Re-export `settings` for users ```python @@ -136,8 +214,9 @@ from example_host._backends import settings # noqa: F401 ## 4. Trigger eager discovery where signatures matter Discovery is **lazy by default** — backend entrypoints aren't loaded -until something actually queries the dispatcher (settings setter, -`get_backend`, a non-CPU dispatched call). That keeps host import +until something actually queries a non-CPU backend (a settings setter, +`get_backend`, or a dispatched call). Explicit CPU selection does not trigger +discovery. That keeps host import fast. But `help(my_func)`, IDE tooltips, and Sphinx autodoc introspect the diff --git a/pyproject.toml b/pyproject.toml index 49cffda..366a7af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ optional-dependencies.docs = [ optional-dependencies.test = ["pytest>=7", "pytest-cov>=4"] urls."Bug Tracker" = "https://github.com/scverse/scverse-backends/issues" +urls.Documentation = "https://scverse-backends.readthedocs.io" urls.Source = "https://github.com/scverse/scverse-backends" [tool.hatch] @@ -97,6 +98,7 @@ branch = true [tool.coverage.report] exclude_also = ["if TYPE_CHECKING:", "if __name__ == .__main__.:"] +fail_under = 85 show_missing = true [tool.codespell] diff --git a/src/scverse_backends/_dispatch.py b/src/scverse_backends/_dispatch.py index 3ededb0..c29f20f 100644 --- a/src/scverse_backends/_dispatch.py +++ b/src/scverse_backends/_dispatch.py @@ -5,7 +5,7 @@ import functools import inspect import warnings -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypeVar, cast if TYPE_CHECKING: from collections.abc import Callable @@ -13,6 +13,8 @@ from scverse_backends._registry import _Registry from scverse_backends._settings import _Settings +_T = TypeVar("_T") + # numpydoc section headers that end a Parameters block _NUMPYDOC_SECTIONS = frozenset( @@ -30,7 +32,7 @@ "Methods", ) ) -_RESERVED_BACKEND_PARAM_NAMES = frozenset({"self", "backend"}) +_RESERVED_BACKEND_PARAM_NAMES = frozenset({"self", "cls", "backend"}) def _is_injectable_backend_param(name: str, param: inspect.Parameter) -> bool: @@ -89,6 +91,17 @@ def _routable_param_names(sig: inspect.Signature) -> set[str]: } +def _is_default_value(value: Any, default: Any) -> bool: + """Compare a supplied value to a default without assuming scalar equality.""" + if value is default: + return True + try: + result = value == default + return bool(result) + except Exception: # noqa: BLE001 + return False + + def _callable_name(func: Callable) -> str: """Best-effort function name for dynamic callable objects.""" return getattr(func, "__name__", type(func).__name__) @@ -367,6 +380,59 @@ def _build_signature(func: Callable) -> None: setattr(func, "__signature__", sig.replace(parameters=params)) +def _build_class_signature(cls: type[Any], signature: inspect.Signature) -> None: + """Expose a class constructor signature with the ``backend`` selector.""" + params = list(signature.parameters.values()) + backend_param = inspect.Parameter( + "backend", + inspect.Parameter.KEYWORD_ONLY, + default=None, + annotation=str | None, + ) + kwargs_idx = next( + ( + i + for i, param in enumerate(params) + if param.kind == inspect.Parameter.VAR_KEYWORD + ), + None, + ) + if kwargs_idx is not None: + params.insert(kwargs_idx, backend_param) + else: + params.append(backend_param) + setattr(cls, "__signature__", signature.replace(parameters=params)) + + +def _class_signature(cls: type[Any]) -> inspect.Signature: + """Inspect a host class without inheriting a decorated base's signature.""" + inherited_cpu_signature = next( + ( + base.__dict__["__scverse_backends_cpu_signature__"] + for base in cls.__mro__[1:] + if "__scverse_backends_cpu_signature__" in base.__dict__ + ), + None, + ) + if inherited_cpu_signature is None: + return inspect.signature(cls) + + constructor = cls.__dict__.get("__init__") + if constructor is None: + constructor = cls.__dict__.get("__new__") + if constructor is None: + return inherited_cpu_signature + + signature = inspect.signature(constructor) + parameters = list(signature.parameters.values()) + if parameters: + parameters.pop(0) + return signature.replace( + parameters=parameters, + return_annotation=inspect.Signature.empty, + ) + + def _find_public_func(wrapper: Callable) -> Callable: """Find the outermost public function that wraps a dispatch wrapper. @@ -388,9 +454,11 @@ def _find_public_func(wrapper: Callable) -> Callable: return wrapper obj = candidate - while obj is not None: + seen: set[int] = set() + while obj is not None and id(obj) not in seen: if obj is wrapper: return candidate + seen.add(id(obj)) obj = getattr(obj, "__wrapped__", None) return wrapper @@ -606,11 +674,42 @@ def decorator(self, func: Callable) -> Callable: If the active backend does not implement the decorated function, the call falls back to the CPU implementation transparently. """ - if "backend" in inspect.signature(func).parameters: + if isinstance(func, type) or not callable(func): + raise TypeError( + "@backend_dispatch can only decorate callable functions; " + "classes are not supported." + ) + + try: + signature = inspect.signature(func) + except (TypeError, ValueError) as err: + raise TypeError( + f"Cannot dispatch {_callable_module(func)}." + f"{_callable_qualname(func)}: its signature cannot be inspected." + ) from err + if "backend" in signature.parameters: raise TypeError( f"Cannot dispatch {_callable_module(func)}.{_callable_qualname(func)}: " "'backend' is reserved for scverse-backends." ) + first_param = next(iter(signature.parameters.values()), None) + if inspect.ismethod(func) or ( + first_param is not None and first_param.name in {"self", "cls"} + ): + raise TypeError( + f"Cannot dispatch {_callable_module(func)}.{_callable_qualname(func)}: " + "@backend_dispatch supports module-level functions, not instance " + "or class methods. Use @backend_class to replace a complete class." + ) + if any( + param.kind == inspect.Parameter.VAR_POSITIONAL + for param in signature.parameters.values() + ): + raise TypeError( + f"Cannot dispatch {_callable_module(func)}.{_callable_qualname(func)}: " + "@backend_dispatch does not support variadic positional " + "parameters (*args)." + ) func_name = _callable_name(func) registry = self._registry @@ -630,10 +729,23 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: if method is None: # Backend doesn't implement this function — fall back to CPU return func(*args, **kwargs) + if not callable(method): + raise TypeError( + f"Backend {effective!r} exposes {func_name!r}, but it " + "is not callable." + ) - shared, host_only, backend_only, host_defaults = self._get_param_sets( - func, method, canonical - ) + try: + shared, host_only, backend_only, host_defaults = self._get_param_sets( + func, + method, + canonical, + ) + except (TypeError, ValueError) as err: + raise TypeError( + f"Backend {effective!r} implementation {func_name!r} must " + "expose an inspectable signature." + ) from err adapter_kwargs = self._route_arguments( func=func, @@ -692,7 +804,9 @@ def _route_arguments( adapter_kwargs[key] = value elif key in host_only: default = host_defaults.get(key, inspect.Parameter.empty) - if default is inspect.Parameter.empty or value != default: + if default is inspect.Parameter.empty or not _is_default_value( + value, default + ): warnings.warn( f"{key!r} has no effect on backend {backend_name!r}.", stacklevel=3, @@ -707,23 +821,156 @@ def _call_backend_method(method: Callable, adapter_kwargs: dict[str, Any]) -> An adapter_args: list[Any] = [] call_kwargs: dict[str, Any] = {} consumed: set[str] = set() + positional_only = [ + param + for param in adapter_sig.parameters.values() + if param.kind == inspect.Parameter.POSITIONAL_ONLY + ] + supplied_positions = [ + index + for index, param in enumerate(positional_only) + if param.name in adapter_kwargs + ] + if supplied_positions: + for param in positional_only[: max(supplied_positions) + 1]: + if param.name in adapter_kwargs: + adapter_args.append(adapter_kwargs[param.name]) + consumed.add(param.name) + elif param.default is not inspect.Parameter.empty: + adapter_args.append(param.default) + else: + raise TypeError( + f"Cannot call backend method {_callable_qualname(method)}: " + f"required positional-only parameter {param.name!r} " + "precedes a supplied positional-only parameter." + ) for name, param in adapter_sig.parameters.items(): - if name == "self" or param.kind == inspect.Parameter.VAR_POSITIONAL: - continue - if param.kind == inspect.Parameter.VAR_KEYWORD: + if param.kind in { + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + }: continue if name not in adapter_kwargs: continue consumed.add(name) - if param.kind == inspect.Parameter.POSITIONAL_ONLY: - adapter_args.append(adapter_kwargs[name]) - else: - call_kwargs[name] = adapter_kwargs[name] + call_kwargs[name] = adapter_kwargs[name] for name, value in adapter_kwargs.items(): if name not in consumed: call_kwargs[name] = value return method(*adapter_args, **call_kwargs) + + +class _ClassDispatch: + """Per-host decorator that selects a complete backend implementation class.""" + + def __init__(self, registry: _Registry, settings: _Settings) -> None: + self._registry = registry + self._settings = settings + + def decorator(self, cpu_class: type[_T]) -> type[_T]: + """Return a class whose construction selects the active backend class. + + Backend adapters opt in by exposing a class with the same name as the + decorated host class. The selected backend class receives all + constructor arguments unchanged. If the backend does not expose that + class, construction falls back to the host implementation. + """ + if not isinstance(cpu_class, type): + raise TypeError("@backend_class can only decorate classes.") + if "__scverse_backends_cpu_class__" in cpu_class.__dict__: + raise TypeError( + f"Class {_callable_module(cpu_class)}." + f"{_callable_qualname(cpu_class)} is already decorated with " + "@backend_class." + ) + + try: + cpu_signature = _class_signature(cpu_class) + except (TypeError, ValueError) as err: + raise TypeError( + f"Cannot dispatch class {_callable_module(cpu_class)}." + f"{_callable_qualname(cpu_class)}: its constructor signature " + "cannot be inspected." + ) from err + + if "backend" in cpu_signature.parameters: + raise TypeError( + f"Cannot dispatch class {_callable_module(cpu_class)}." + f"{_callable_qualname(cpu_class)}: 'backend' is reserved for " + "scverse-backends." + ) + + registry = self._registry + settings = self._settings + class_name = cpu_class.__name__ + base_metaclass: Any = type(cpu_class) + dispatched_class: Any + + class BackendClassMeta(base_metaclass): + def __call__( + cls, + *args: Any, + **kwargs: Any, + ) -> Any: + # An undecorated subclass inherits this metaclass. It must keep + # normal Python construction semantics unless it is explicitly + # decorated itself. + if cls is not dispatched_class: + return super().__call__(*args, **kwargs) + + local_backend = kwargs.pop("backend", None) + effective = settings.backend if local_backend is None else local_backend + + if effective == "cpu": + return super().__call__(*args, **kwargs) + + _, backend = registry.require_backend(effective) + implementation = getattr(backend, class_name, None) + if implementation is None or implementation is cpu_class: + return super().__call__(*args, **kwargs) + if not isinstance(implementation, type): + raise TypeError( + f"Backend {effective!r} exposes {class_name!r}, but it " + "is not a class." + ) + if implementation is cls: + raise TypeError( + f"Backend {effective!r} exposes the dispatched host class " + f"{class_name!r} as its own implementation." + ) + return implementation(*args, **kwargs) + + namespace = { + "__module__": cpu_class.__module__, + "__qualname__": cpu_class.__qualname__, + "__doc__": cpu_class.__doc__, + "__slots__": (), + "__scverse_backends_cpu_class__": cpu_class, + "__scverse_backends_cpu_signature__": cpu_signature, + } + try: + prepared_namespace = BackendClassMeta.__prepare__( + class_name, + (cpu_class,), + ) + for name, value in namespace.items(): + prepared_namespace[name] = value + dispatched_class = BackendClassMeta( + class_name, + (cpu_class,), + prepared_namespace, + ) + except TypeError as err: + raise TypeError( + f"Cannot dispatch class {_callable_module(cpu_class)}." + f"{_callable_qualname(cpu_class)}: @backend_class requires the " + "host class to support subclassing and its metaclass to support " + "a derived dispatch class." + ) from err + _build_class_signature(dispatched_class, cpu_signature) + return cast("type[_T]", dispatched_class) diff --git a/src/scverse_backends/_dispatcher.py b/src/scverse_backends/_dispatcher.py index 81983d0..0a789fd 100644 --- a/src/scverse_backends/_dispatcher.py +++ b/src/scverse_backends/_dispatcher.py @@ -2,14 +2,16 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from collections.abc import Callable, Mapping +from typing import Any, ParamSpec, TypeVar -from scverse_backends._dispatch import _Dispatch +from scverse_backends._dispatch import _ClassDispatch, _Dispatch from scverse_backends._registry import _Registry from scverse_backends._settings import _Settings -if TYPE_CHECKING: - from collections.abc import Callable +_P = ParamSpec("_P") +_R = TypeVar("_R") +_T = TypeVar("_T") class BackendDispatcher: @@ -52,6 +54,7 @@ class BackendDispatcher: ... }, ... ) >>> backend_dispatch = _dispatcher.backend_dispatch + >>> backend_class = _dispatcher.backend_class >>> settings = _dispatcher.settings """ @@ -60,9 +63,17 @@ def __init__( *, entrypoint_group: str, host_name: str, - trusted_backends: dict[str, dict[str, Any]] | None = None, - reserved_backends: dict[str, str] | None = None, + trusted_backends: Mapping[str, Mapping[str, Any]] | None = None, + reserved_backends: Mapping[str, str] | None = None, ) -> None: + if not isinstance(entrypoint_group, str) or not entrypoint_group.strip(): + raise ValueError("entrypoint_group must be a non-empty string.") + if not isinstance(host_name, str) or not host_name.strip(): + raise ValueError("host_name must be a non-empty string.") + if trusted_backends is not None and not isinstance(trusted_backends, Mapping): + raise ValueError("trusted_backends must be a mapping or None.") + if reserved_backends is not None and not isinstance(reserved_backends, Mapping): + raise ValueError("reserved_backends must be a mapping or None.") self.entrypoint_group = entrypoint_group self.host_name = host_name self._registry = _Registry( @@ -73,12 +84,26 @@ def __init__( ) self._settings = _Settings(self._registry) self._dispatch_impl = _Dispatch(self._registry, self._settings) + self._class_dispatch_impl = _ClassDispatch(self._registry, self._settings) @property - def backend_dispatch(self) -> Callable: + def backend_dispatch( + self, + ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: """The ``@backend_dispatch`` decorator for host functions.""" return self._dispatch_impl.decorator + @property + def backend_class(self) -> Callable[[type[_T]], type[_T]]: + """Decorate a host class for complete backend replacement. + + A selected backend opts in by exposing a class with the same name. + Construction returns an instance of that backend class and forwards all + arguments except the consumed ``backend`` selector. If the adapter has + no same-named class, construction falls back to the host class. + """ + return self._class_dispatch_impl.decorator + @property def settings(self) -> _Settings: """Settings object exposing ``.backend`` and ``.use_backend()``.""" @@ -96,8 +121,8 @@ def discover(self) -> None: """Eagerly load backends and merge their params into host signatures. Discovery is normally lazy — entrypoints are loaded the first time - anything in the dispatcher is queried (settings setter, - ``get_backend``, a non-CPU dispatched call). Call ``discover()`` + anything in the dispatcher is queried (a non-CPU settings setter, + non-CPU ``get_backend``, a non-CPU dispatched call). Call ``discover()`` when you want that to happen on a schedule you control: * In a host's Sphinx ``conf.py``, so autodoc sees the merged diff --git a/src/scverse_backends/_registry.py b/src/scverse_backends/_registry.py index 89a8f91..c9d1f7a 100644 --- a/src/scverse_backends/_registry.py +++ b/src/scverse_backends/_registry.py @@ -8,11 +8,9 @@ import threading import types import warnings +from collections.abc import Callable, Mapping from difflib import get_close_matches -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from collections.abc import Callable +from typing import Any logger = logging.getLogger(__name__) @@ -118,6 +116,57 @@ def _as_list(value: Any) -> list[str]: return list(value) +def _validate_trusted_provider_config(canonical: str, info: Any) -> None: + """Validate provider-verification fields before entrypoint discovery.""" + if not isinstance(info, Mapping): + raise ValueError( + f"Trusted backend configuration for {canonical!r} must be a mapping." + ) + + field_groups = ( + ("distribution", ("distributions", "distribution", "packages", "package")), + ("entrypoint", ("entrypoints", "entrypoint")), + ("object reference", ("object_refs", "object_ref")), + ("module prefix", ("module_prefixes", "module_prefix")), + ) + singular_fields = { + "distribution", + "package", + "entrypoint", + "object_ref", + "module_prefix", + } + for description, fields in field_groups: + for field in fields: + if field not in info or info[field] is None: + continue + raw = info[field] + if field in singular_fields and not isinstance(raw, str): + raise ValueError( + f"Trusted backend {field} for {canonical!r} must be a string." + ) + if not isinstance(raw, (str, *_ALIAS_CONTAINER_TYPES)): + raise ValueError( + f"Trusted backend {description} values for {canonical!r} must " + "be a string or a list, tuple, or set of strings." + ) + values = _as_list(raw) + if any(not isinstance(value, str) or not value for value in values): + raise ValueError( + f"Trusted backend {description} values for {canonical!r} must " + "contain only non-empty strings." + ) + + +def _copy_trusted_provider_config(info: Mapping[str, Any]) -> dict[str, Any]: + """Copy known container fields so caller mutations cannot alter trust checks.""" + copied = dict(info) + for field, value in copied.items(): + if isinstance(value, _ALIAS_CONTAINER_TYPES): + copied[field] = tuple(value) + return copied + + def _entrypoint_distribution_name(ep: importlib.metadata.EntryPoint) -> str | None: """Best-effort distribution name lookup for an entrypoint.""" try: @@ -167,12 +216,17 @@ def __init__( *, entrypoint_group: str, host_name: str, - trusted_backends: dict[str, dict[str, Any]], - reserved_backends: dict[str, str] | None = None, + trusted_backends: Mapping[str, Mapping[str, Any]], + reserved_backends: Mapping[str, str] | None = None, ) -> None: self.entrypoint_group = entrypoint_group self.host_name = host_name - self.trusted_backends: dict[str, dict[str, Any]] = dict(trusted_backends) + for canonical, info in trusted_backends.items(): + _validate_trusted_provider_config(canonical, info) + self.trusted_backends: dict[str, dict[str, Any]] = { + canonical: _copy_trusted_provider_config(info) + for canonical, info in trusted_backends.items() + } self.reserved_backends: dict[str, str] = dict(reserved_backends or {}) self._backends: dict[str, Any] = {} # canonical_name -> instance @@ -190,8 +244,13 @@ def __init__( # Build reverse lookup: alias -> canonical_name (for trusted backends) self._trusted_aliases: dict[str, str] = {} - for reserved in self.reserved_backends: + for reserved, reason in self.reserved_backends.items(): _validate_config_label(reserved, description="reserved backend name") + if not isinstance(reason, str) or not reason.strip(): + raise ValueError( + f"Reason for reserved backend name {reserved!r} must be a " + "non-empty string." + ) for canonical, info in self.trusted_backends.items(): _validate_config_label(canonical, description="trusted backend name") if canonical in self.reserved_backends: @@ -233,26 +292,61 @@ def _ensure_discovered(self) -> None: with self._discovery_lock: if self._discovered: return + backends_before = dict(self._backends) + aliases_before = dict(self._alias_map) + load_errors_before = dict(self._load_errors) + registration_errors_before = dict(self._registration_errors) self._discovered = True - for ep in importlib.metadata.entry_points(group=self.entrypoint_group): - try: - provider = _coerce_backend_provider(ep.load()) - except Exception as e: # noqa: BLE001 - self._load_errors[ep.name] = e - logger.debug( - "Failed to load backend entrypoint %r", ep.name, exc_info=True - ) - else: - self._register_backend( - provider, - entrypoint_name=ep.name, - distribution_name=_entrypoint_distribution_name(ep), - object_ref=ep.value, - ) - - if self._backends and self._on_discovered is not None: - self._on_discovered() + try: + entrypoints = importlib.metadata.entry_points( + group=self.entrypoint_group + ) + for ep in entrypoints: + try: + provider = _coerce_backend_provider(ep.load()) + except Exception as e: # noqa: BLE001 + self._load_errors[ep.name] = e + logger.debug( + "Failed to load backend entrypoint %r", + ep.name, + exc_info=True, + ) + else: + try: + self._register_backend( + provider, + entrypoint_name=ep.name, + distribution_name=_entrypoint_distribution_name(ep), + object_ref=ep.value, + ) + except Exception as e: # noqa: BLE001 + self._registration_errors[ep.name] = e + logger.debug( + "Failed to register backend entrypoint %r", + ep.name, + exc_info=True, + ) + warnings.warn( + f"Ignoring backend entrypoint {ep.name!r}: " + "registration failed with " + f"{type(e).__name__}: {e}", + stacklevel=2, + ) + + if self._on_discovered is not None: + self._on_discovered() + except BaseException: + self._backends.clear() + self._backends.update(backends_before) + self._alias_map.clear() + self._alias_map.update(aliases_before) + self._load_errors.clear() + self._load_errors.update(load_errors_before) + self._registration_errors.clear() + self._registration_errors.update(registration_errors_before) + self._discovered = False + raise def _register_backend( self, @@ -335,15 +429,14 @@ def _register_backend( ) return - self._backends[canonical] = instance - self._registration_errors.pop(canonical, None) - self._alias_map[canonical] = canonical - - for alias in _backend_aliases_from_instance( + aliases = _backend_aliases_from_instance( instance, canonical=canonical, entrypoint_name=entrypoint_name, - ): + ) + + valid_aliases: list[str] = [] + for alias in aliases: if not isinstance(alias, str) or not alias: warnings.warn( f"Ignoring invalid alias {alias!r} for backend {canonical!r}.", @@ -388,7 +481,13 @@ def _register_backend( stacklevel=2, ) else: - self._alias_map[alias] = canonical + valid_aliases.append(alias) + + self._backends[canonical] = instance + self._registration_errors.pop(canonical, None) + self._alias_map[canonical] = canonical + for alias in valid_aliases: + self._alias_map[alias] = canonical def _verify_trusted_provider( self, @@ -467,16 +566,18 @@ def _reject_trusted_provider(self, canonical: str, message: str) -> bool: def check_trusted(self, name: str) -> None: """Emit a one-time warning if the backend is not in the trusted list.""" - canonical = self._alias_map.get(name, name) - if canonical in self._warned_untrusted: - return - if canonical in self.trusted_backends or canonical not in self._backends: - return - self._warned_untrusted.add(canonical) + with self._discovery_lock: + canonical = self._alias_map.get(name, name) + if canonical in self._warned_untrusted: + return + if canonical in self.trusted_backends or canonical not in self._backends: + return + self._warned_untrusted.add(canonical) + trusted = sorted(self.trusted_backends) warnings.warn( f"Backend {canonical!r} is not in {self.host_name}'s trusted backends list. " f"It may not have passed the conformance test suite. " - f"Trusted backends: {sorted(self.trusted_backends)}.", + f"Trusted backends: {trusted}.", stacklevel=3, ) @@ -511,9 +612,9 @@ def resolve_name(self, name: str) -> str | None: Returns ``None`` only for completely unknown names. """ _validate_requested_backend_name(name) - self._ensure_discovered() if name == "cpu": return "cpu" + self._ensure_discovered() return ( self._alias_map.get(name) or self._trusted_aliases.get(name) @@ -524,9 +625,9 @@ def resolve_name(self, name: str) -> str | None: def get_backend(self, name: str) -> Any | None: """Get backend instance by name or alias. Returns None for ``"cpu"``.""" _validate_requested_backend_name(name) - self._ensure_discovered() if name == "cpu": return None + self._ensure_discovered() canonical = self._alias_map.get(name) or self._trusted_aliases.get(name) if canonical is None: return None @@ -539,6 +640,8 @@ def require_backend(self, name: str) -> tuple[str, Any | None]: backend instance is ``None``. """ _validate_requested_backend_name(name) + if name == "cpu": + return "cpu", None if name in self.reserved_backends: raise ValueError( f"Backend name {name!r} is reserved by {self.host_name}. " @@ -548,9 +651,6 @@ def require_backend(self, name: str) -> tuple[str, Any | None]: canonical = self.resolve_name(name) if canonical is None: raise ValueError(self.suggest(name)) - if canonical == "cpu": - return canonical, None - backend = self.get_backend(canonical) if backend is not None: self.check_trusted(canonical) @@ -581,7 +681,13 @@ def require_backend(self, name: str) -> tuple[str, Any | None]: def available_backend_names(self) -> list[str]: """Return all registered backend names and aliases.""" self._ensure_discovered() - return sorted(self._alias_map.keys()) + names = set(self._alias_map) + names.update( + alias + for alias, canonical in self._trusted_aliases.items() + if canonical in self._backends + ) + return sorted(names) def is_trusted(self, canonical: str) -> bool: return canonical in self.trusted_backends diff --git a/src/scverse_backends/_settings.py b/src/scverse_backends/_settings.py index 1fa58da..6537d18 100644 --- a/src/scverse_backends/_settings.py +++ b/src/scverse_backends/_settings.py @@ -2,20 +2,18 @@ from __future__ import annotations +from collections.abc import Generator # noqa: TC003 from contextlib import contextmanager from contextvars import ContextVar -from typing import TYPE_CHECKING, Any +from typing import Any -if TYPE_CHECKING: - from collections.abc import Generator - - from scverse_backends._registry import _Registry +from scverse_backends._registry import _Registry # noqa: TC001 class Settings: """Per-host settings exposing ``.backend`` and ``.use_backend()``. - Each ``BackendDispatcher`` owns one ``_Settings`` with its own + Each ``BackendDispatcher`` owns one ``Settings`` with its own ``ContextVar`` so host libraries' active backends are isolated. """ diff --git a/src/scverse_backends/testing/conformance.py b/src/scverse_backends/testing/conformance.py index 28a4102..1abd6db 100644 --- a/src/scverse_backends/testing/conformance.py +++ b/src/scverse_backends/testing/conformance.py @@ -32,10 +32,8 @@ def validate_backend(backend_name, functions=None): from __future__ import annotations -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from collections.abc import Callable, Sequence +from collections.abc import Callable, Sequence # noqa: TC003 +from typing import Any def run_conformance( @@ -69,11 +67,33 @@ def run_conformance( ------- Dict mapping function name to ``"PASSED"``, ``"SKIPPED (...)"``, or ``"FAILED: ..."``. + + Raises + ------ + ValueError + If ``get_backend`` cannot resolve ``backend_name``. """ backend = get_backend(backend_name) - assert backend is not None, f"Backend {backend_name!r} not found" - - to_test = {k: v for k, v in tests.items() if functions is None or k in functions} + if backend is None: + raise ValueError(f"Backend {backend_name!r} not found") + + requested: set[str] | None = None + if functions is not None: + if isinstance(functions, str): + raise ValueError("functions must be a sequence of non-empty strings.") + function_names = list(functions) + if any(not isinstance(name, str) or not name for name in function_names): + raise ValueError("functions must be a sequence of non-empty strings.") + requested = set(function_names) + unknown = sorted(requested - tests.keys()) + if unknown: + raise ValueError(f"Unknown conformance functions: {unknown}.") + + to_test = { + name: test_fn + for name, test_fn in tests.items() + if requested is None or name in requested + } results: dict[str, str] = {} for name, test_fn in to_test.items(): diff --git a/tests/test_conformance.py b/tests/test_conformance.py index 850e6a0..7f94cfc 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -58,6 +58,19 @@ def get_backend(name: str): assert results == {"implemented": "PASSED"} +@pytest.mark.parametrize("functions", ["implemented", ["missing"], ["", "implemented"]]) +def test_run_conformance_rejects_invalid_function_filters(functions): + backend = types.SimpleNamespace(implemented=object()) + + with pytest.raises(ValueError, match="functions"): + run_conformance( + backend_name="cuda", + tests={"implemented": lambda name: None}, + get_backend=lambda name: backend, + functions=functions, + ) + + def test_run_conformance_records_failures_without_raising(): backend = types.SimpleNamespace(implemented=object()) @@ -95,7 +108,7 @@ def failing_test(name: str) -> None: def test_run_conformance_requires_backend(): - with pytest.raises(AssertionError, match="Backend 'cuda' not found"): + with pytest.raises(ValueError, match="Backend 'cuda' not found"): run_conformance( backend_name="cuda", tests={"implemented": lambda name: None}, diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py index ac982b1..2ee6f72 100644 --- a/tests/test_dispatch.py +++ b/tests/test_dispatch.py @@ -2,8 +2,10 @@ from __future__ import annotations +import importlib.metadata import inspect import warnings +from dataclasses import dataclass import pytest from _helpers import register_fake @@ -110,6 +112,69 @@ def my_func(x, n_jobs=None): assert len(w) == 1 assert "n_jobs" in str(w[0].message) + def test_host_default_comparison_errors_do_not_break_dispatch(self, dispatcher): + class ExplosiveDefault: + def __eq__(self, other): + raise RuntimeError("comparison is unavailable") + + class Backend: + name = "comparison_gpu" + aliases = [] + + def my_func(self, x): + return x + + dispatcher._registry._backends["comparison_gpu"] = Backend() + dispatcher._registry._alias_map["comparison_gpu"] = "comparison_gpu" + dispatcher._registry._warned_untrusted.add("comparison_gpu") + default = ExplosiveDefault() + + @dispatcher.backend_dispatch + def my_func(x, option=default): + return x + + with dispatcher.settings.use_backend("comparison_gpu"): + with pytest.warns(UserWarning, match="'option' has no effect"): + assert my_func(1, option=ExplosiveDefault()) == 1 + + def test_noncallable_backend_export_raises_clear_error(self, dispatcher): + class Backend: + name = "broken_gpu" + aliases = [] + my_func = 42 + + dispatcher._registry._backends["broken_gpu"] = Backend() + dispatcher._registry._alias_map["broken_gpu"] = "broken_gpu" + dispatcher._registry._warned_untrusted.add("broken_gpu") + + @dispatcher.backend_dispatch + def my_func(x): + return x + + with pytest.raises(TypeError, match="not callable"): + my_func(1, backend="broken_gpu") + + def test_uninspectable_backend_export_raises_clear_error(self, dispatcher): + class Backend: + name = "opaque_gpu" + aliases = [] + my_func = staticmethod(iter) + + dispatcher._registry._backends["opaque_gpu"] = Backend() + dispatcher._registry._alias_map["opaque_gpu"] = "opaque_gpu" + dispatcher._registry._warned_untrusted.add("opaque_gpu") + + @dispatcher.backend_dispatch + def my_func(x): + return x + + with pytest.raises(TypeError, match="must expose an inspectable signature"): + my_func(object(), backend="opaque_gpu") + + def test_uninspectable_host_callable_is_rejected(self, dispatcher): + with pytest.raises(TypeError, match="signature cannot be inspected"): + dispatcher.backend_dispatch(iter) + def test_runtime_functions_with_same_qualname_do_not_share_routing_cache( self, dispatcher, @@ -255,6 +320,486 @@ def test_reserved_backend_parameter_rejected(self, dispatcher): def my_func(x, *, backend="old"): return x + def test_classes_and_methods_are_rejected(self, dispatcher): + class Model: + pass + + with pytest.raises(TypeError, match="classes are not supported"): + dispatcher.backend_dispatch(Model) + + def method(self, value): + return value + + with pytest.raises(TypeError, match="module-level functions"): + dispatcher.backend_dispatch(method) + + def class_method(cls, value): + return value + + with pytest.raises(TypeError, match="module-level functions"): + dispatcher.backend_dispatch(class_method) + + class WithMethod: + def method(self, value): + return value + + with pytest.raises(TypeError, match="module-level functions"): + dispatcher.backend_dispatch(WithMethod().method) + + def test_variadic_positional_host_parameter_is_rejected(self, dispatcher): + def my_func(x, *values): + return x, values + + with pytest.raises(TypeError, match=r"variadic positional.*\\*args"): + dispatcher.backend_dispatch(my_func) + + def test_ambiguous_default_comparison_warns_instead_of_crashing(self, dispatcher): + class AmbiguousTruth: + def __bool__(self): + raise ValueError("ambiguous") + + class DefaultValue: + def __eq__(self, other): + return AmbiguousTruth() + + class Backend: + name = "default_gpu" + aliases = [] + + def my_func(self, x): + return x + + backend = Backend() + dispatcher._registry._backends[backend.name] = backend + dispatcher._registry._alias_map[backend.name] = backend.name + dispatcher._registry._warned_untrusted.add(backend.name) + default = DefaultValue() + + @dispatcher.backend_dispatch + def my_func(x, option=default): + return x, option + + with dispatcher.settings.use_backend(backend.name): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + assert my_func(1, option=default) == 1 + assert caught == [] + + with pytest.warns(UserWarning, match="option"): + assert my_func(2, option=DefaultValue()) == 2 + + def test_positional_only_backend_defaults_are_not_shifted(self, dispatcher): + class Backend: + name = "positional_gpu" + aliases = [] + + def my_func(self, prefix="backend-default", x=None, /): + return prefix, x + + backend = Backend() + dispatcher._registry._backends[backend.name] = backend + dispatcher._registry._alias_map[backend.name] = backend.name + dispatcher._registry._warned_untrusted.add(backend.name) + + @dispatcher.backend_dispatch + def my_func(x): + return "cpu", x + + assert my_func(4, backend=backend.name) == ("backend-default", 4) + + def test_required_positional_only_backend_prefix_has_clear_error(self, dispatcher): + class Backend: + name = "positional_gpu" + aliases = [] + + def my_func(self, prefix, x, /): + return prefix, x + + backend = Backend() + dispatcher._registry._backends[backend.name] = backend + dispatcher._registry._alias_map[backend.name] = backend.name + dispatcher._registry._warned_untrusted.add(backend.name) + + @dispatcher.backend_dispatch + def my_func(x): + return "cpu", x + + with pytest.raises(TypeError, match="required positional-only.*prefix"): + my_func(4, backend=backend.name) + + def test_positional_only_and_var_keyword_routing(self, dispatcher): + class Backend: + name = "flexible_gpu" + aliases = [] + + def my_func(self, x, /, gpu_param=None, **kwargs): + return x, gpu_param, kwargs + + backend = Backend() + dispatcher._registry._backends[backend.name] = backend + dispatcher._registry._alias_map[backend.name] = backend.name + dispatcher._registry._warned_untrusted.add(backend.name) + + @dispatcher.backend_dispatch + def my_func(x, /, **kwargs): + return x, kwargs + + assert my_func( + 4, + gpu_param="specific", + extra="forwarded", + backend=backend.name, + ) == (4, "specific", {"extra": "forwarded"}) + + +class TestBackendClass: + def test_cpu_class_is_constructed_by_default(self, dispatcher): + @dispatcher.backend_class + class Model: + """A CPU model.""" + + def __init__(self, value): + self.value = value + + model = Model("cpu") + + assert model.value == "cpu" + assert isinstance(model, Model) + assert str(inspect.signature(Model)) == "(value, *, backend: str | None = None)" + assert Model.__scverse_backends_cpu_class__.__name__ == "Model" + assert Model.__doc__ == "A CPU model." + + def test_active_backend_replaces_entire_class(self, dispatcher): + class ClassBackend: + name = "class_gpu" + aliases = [] + + class Model: + def __init__(self, value, *, device="gpu"): + self.value = value + self.device = device + + dispatcher._registry._backends["class_gpu"] = ClassBackend() + dispatcher._registry._alias_map["class_gpu"] = "class_gpu" + dispatcher._registry._warned_untrusted.add("class_gpu") + + @dispatcher.backend_class + class Model: + def __init__(self, value, *, device="cpu"): + self.value = value + self.device = device + + with dispatcher.settings.use_backend("class_gpu"): + model = Model("accelerated", device="cuda") + + assert isinstance(model, ClassBackend.Model) + assert model.value == "accelerated" + assert model.device == "cuda" + + def test_backend_none_uses_active_setting(self, dispatcher): + class ClassBackend: + name = "class_gpu" + aliases = [] + + class Model: + source = "gpu" + + dispatcher._registry._backends["class_gpu"] = ClassBackend() + dispatcher._registry._alias_map["class_gpu"] = "class_gpu" + dispatcher._registry._warned_untrusted.add("class_gpu") + + @dispatcher.backend_class + class Model: + source = "cpu" + + with dispatcher.settings.use_backend("class_gpu"): + assert Model(backend=None).source == "gpu" + + def test_per_instance_backend_override(self, dispatcher): + class ClassBackend: + name = "class_gpu" + aliases = [] + + class Model: + def __init__(self, value): + self.value = f"gpu:{value}" + + dispatcher._registry._backends["class_gpu"] = ClassBackend() + dispatcher._registry._alias_map["class_gpu"] = "class_gpu" + dispatcher._registry._warned_untrusted.add("class_gpu") + + @dispatcher.backend_class + class Model: + def __init__(self, value): + self.value = f"cpu:{value}" + + assert Model("one").value == "cpu:one" + assert Model("two", backend="class_gpu").value == "gpu:two" + assert Model("three", backend="cpu").value == "cpu:three" + + def test_backend_without_matching_class_falls_back_to_cpu(self, dispatcher): + class ClassBackend: + name = "class_gpu" + aliases = [] + + dispatcher._registry._backends["class_gpu"] = ClassBackend() + dispatcher._registry._alias_map["class_gpu"] = "class_gpu" + dispatcher._registry._warned_untrusted.add("class_gpu") + + @dispatcher.backend_class + class Model: + def __init__(self, value): + self.value = value + + with dispatcher.settings.use_backend("class_gpu"): + model = Model("cpu fallback") + + assert model.value == "cpu fallback" + assert isinstance(model, Model) + + def test_undecorated_subclass_does_not_dispatch_as_parent(self, dispatcher): + class ClassBackend: + name = "class_gpu" + aliases = [] + + class Model: + source = "gpu" + + dispatcher._registry._backends["class_gpu"] = ClassBackend() + dispatcher._registry._alias_map["class_gpu"] = "class_gpu" + dispatcher._registry._warned_untrusted.add("class_gpu") + + @dispatcher.backend_class + class Model: + source = "cpu" + + class SpecializedModel(Model): + source = "specialized" + + def __init__(self, *, backend): + self.constructor_backend = backend + + with dispatcher.settings.use_backend("class_gpu"): + model = SpecializedModel(backend="application-value") + + assert isinstance(model, SpecializedModel) + assert model.source == "specialized" + assert model.constructor_backend == "application-value" + + def test_decorated_subclass_dispatches_by_its_own_name(self, dispatcher): + class ClassBackend: + name = "class_gpu" + aliases = [] + + class Parent: + source = "gpu-parent" + + class Child: + source = "gpu-child" + + dispatcher._registry._backends["class_gpu"] = ClassBackend() + dispatcher._registry._alias_map["class_gpu"] = "class_gpu" + dispatcher._registry._warned_untrusted.add("class_gpu") + + @dispatcher.backend_class + class Parent: + source = "cpu-parent" + + @dispatcher.backend_class + class Child(Parent): + source = "cpu-child" + + with dispatcher.settings.use_backend("class_gpu"): + assert Parent().source == "gpu-parent" + assert Child().source == "gpu-child" + + def test_custom_metaclass_is_preserved(self, dispatcher): + calls: list[str] = [] + + class TrackingMeta(type): + def __call__(cls, *args, **kwargs): + calls.append(cls.__name__) + return super().__call__(*args, **kwargs) + + @dispatcher.backend_class + class Model(metaclass=TrackingMeta): + pass + + model = Model() + + assert isinstance(type(Model), type) + assert isinstance(Model, TrackingMeta) + assert isinstance(model, Model) + assert calls == ["Model"] + + def test_custom_metaclass_prepared_namespace_is_preserved(self, dispatcher): + class PreparedNamespace(dict): + pass + + class PreparedMeta(type): + @classmethod + def __prepare__(mcls, name, bases): + return PreparedNamespace() + + def __new__(mcls, name, bases, namespace): + if not isinstance(namespace, PreparedNamespace): + raise TypeError("prepared namespace required") + return super().__new__(mcls, name, bases, namespace) + + @dispatcher.backend_class + class Model(metaclass=PreparedMeta): + pass + + assert isinstance(Model(), Model) + + def test_class_api_and_slots_are_preserved(self, dispatcher): + @dispatcher.backend_class + class Model: + """A model.""" + + __slots__ = ("value",) + category = "host" + + def __init__(self, value): + self.value = value + + @classmethod + def class_name(cls): + return cls.__name__ + + model = Model(3) + + assert model.value == 3 + assert Model.category == "host" + assert Model.class_name() == "Model" + assert Model.__doc__ == "A model." + with pytest.raises(AttributeError): + model.extra = 1 + + def test_dataclass_behavior_is_preserved(self, dispatcher): + @dispatcher.backend_class + @dataclass(frozen=True) + class Model: + value: int + + assert Model(3) == Model(3) + assert Model(3).value == 3 + + def test_backend_implementation_may_inherit_from_host_class(self, dispatcher): + class ClassBackend: + name = "class_gpu" + aliases = [] + + backend = ClassBackend() + dispatcher._registry._backends["class_gpu"] = backend + dispatcher._registry._alias_map["class_gpu"] = "class_gpu" + dispatcher._registry._warned_untrusted.add("class_gpu") + + @dispatcher.backend_class + class Model: + source = "cpu" + + class BackendModel(Model): + source = "gpu" + + backend.Model = BackendModel + + model = Model(backend="class_gpu") + + assert isinstance(model, Model) + assert isinstance(model, BackendModel) + assert model.source == "gpu" + + def test_backend_attribute_must_be_a_class(self, dispatcher): + class ClassBackend: + name = "class_gpu" + aliases = [] + Model = object() + + dispatcher._registry._backends["class_gpu"] = ClassBackend() + dispatcher._registry._alias_map["class_gpu"] = "class_gpu" + dispatcher._registry._warned_untrusted.add("class_gpu") + + @dispatcher.backend_class + class Model: + pass + + with pytest.raises(TypeError, match="is not a class"): + Model(backend="class_gpu") + + def test_backend_cannot_point_back_to_dispatched_host_class(self, dispatcher): + class ClassBackend: + name = "class_gpu" + aliases = [] + + backend = ClassBackend() + dispatcher._registry._backends["class_gpu"] = backend + dispatcher._registry._alias_map["class_gpu"] = "class_gpu" + dispatcher._registry._warned_untrusted.add("class_gpu") + + @dispatcher.backend_class + class Model: + pass + + backend.Model = Model + + with pytest.raises(TypeError, match="dispatched host class"): + Model(backend="class_gpu") + + def test_original_host_class_exported_by_backend_falls_back(self, dispatcher): + class ClassBackend: + name = "class_gpu" + aliases = [] + + backend = ClassBackend() + dispatcher._registry._backends["class_gpu"] = backend + dispatcher._registry._alias_map["class_gpu"] = "class_gpu" + dispatcher._registry._warned_untrusted.add("class_gpu") + + @dispatcher.backend_class + class Model: + source = "cpu" + + backend.Model = Model.__scverse_backends_cpu_class__ + + model = Model(backend="class_gpu") + + assert isinstance(model, Model) + assert model.source == "cpu" + + def test_double_decoration_is_rejected(self, dispatcher): + @dispatcher.backend_class + class Model: + pass + + with pytest.raises(TypeError, match="already decorated"): + dispatcher.backend_class(Model) + + def test_non_subclassable_host_class_has_clear_error(self, dispatcher): + class RequiresClassOption: + def __init_subclass__(cls, *, enabled, **kwargs): + super().__init_subclass__(**kwargs) + cls.enabled = enabled + + class Model(RequiresClassOption, enabled=True): + pass + + with pytest.raises(TypeError, match="requires the host class to support"): + dispatcher.backend_class(Model) + + def test_non_class_and_reserved_constructor_parameter_are_rejected( + self, dispatcher + ): + with pytest.raises(TypeError, match="only decorate classes"): + dispatcher.backend_class(lambda: None) + + with pytest.raises(TypeError, match="reserved.*backend"): + + @dispatcher.backend_class + class Model: + def __init__(self, backend): + self.backend = backend + class TestLazyDiscovery: def test_discovery_not_triggered_on_construction(self): @@ -264,11 +809,24 @@ def test_discovery_not_triggered_on_construction(self): d = BackendDispatcher(entrypoint_group="nope.never", host_name="t") assert not d._registry._discovered - def test_discovery_triggered_by_get_backend(self): + def test_cpu_selection_does_not_trigger_discovery(self): + from scverse_backends import BackendDispatcher + + d = BackendDispatcher(entrypoint_group="nope.never", host_name="t") + + assert d.get_backend("cpu") is None + d.settings.backend = "cpu" + with d.settings.use_backend("cpu"): + assert d.settings.backend == "cpu" + + assert not d._registry._discovered + + def test_discovery_triggered_by_non_cpu_get_backend(self): from scverse_backends import BackendDispatcher d = BackendDispatcher(entrypoint_group="nope.never", host_name="t") - d.get_backend("cpu") + d.get_backend("missing") + assert d._registry._discovered def test_discovery_triggered_by_settings_setter(self, dispatcher): @@ -276,3 +834,70 @@ def test_discovery_triggered_by_settings_setter(self, dispatcher): dispatcher._registry._discovered = False dispatcher.settings.backend = "fake" assert dispatcher._registry._discovered + + def test_discovery_without_backends_updates_generated_docs(self, monkeypatch): + from scverse_backends import BackendDispatcher + + monkeypatch.setattr( + importlib.metadata, + "entry_points", + lambda *, group: [], + ) + dispatcher = BackendDispatcher( + entrypoint_group="empty.backends", + host_name="empty", + ) + + @dispatcher.backend_dispatch + def my_func(x): + """Run the function. + + Parameters + ---------- + x + Input value. + """ + return x + + dispatcher.discover() + + assert "Backend selector injected" in my_func.__doc__ + + def test_backend_class_override_triggers_discovery(self, monkeypatch): + from scverse_backends import BackendDispatcher + + class ClassBackend: + name = "class_gpu" + aliases = ["class"] + + class Model: + source = "gpu" + + class Entrypoint: + name = "class_gpu" + value = f"{__name__}:ClassBackend" + dist = None + + def load(self): + return ClassBackend() + + def entry_points(*, group): + assert group == "class.backends" + return [Entrypoint()] + + monkeypatch.setattr(importlib.metadata, "entry_points", entry_points) + dispatcher = BackendDispatcher( + entrypoint_group="class.backends", + host_name="test", + ) + + @dispatcher.backend_class + class Model: + source = "cpu" + + assert not dispatcher._registry._discovered + with pytest.warns(UserWarning, match="not in test's trusted"): + model = Model(backend="class") + + assert dispatcher._registry._discovered + assert isinstance(model, ClassBackend.Model) diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..9b2ff1e --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,30 @@ +"""Tests for runtime introspection of the public typed API.""" + +from __future__ import annotations + +from typing import get_type_hints + +from scverse_backends import BackendDispatcher, Settings +from scverse_backends.testing import run_conformance + + +def test_public_type_hints_resolve_at_runtime(): + targets = [ + BackendDispatcher.__init__, + BackendDispatcher.backend_dispatch.fget, + BackendDispatcher.backend_class.fget, + BackendDispatcher.settings.fget, + BackendDispatcher.get_backend, + BackendDispatcher.available_backend_names, + BackendDispatcher.discover, + Settings.__init__, + Settings.backend.fget, + Settings.backend.fset, + Settings.use_backend, + Settings.available_backends, + Settings.get_backend, + run_conformance, + ] + + for target in targets: + assert get_type_hints(target) diff --git a/tests/test_settings.py b/tests/test_settings.py index 924fc1e..cdb3cb8 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -9,6 +9,7 @@ import time import types import warnings +from types import MappingProxyType import pytest from _helpers import register_fake @@ -18,6 +19,59 @@ class TestSettings: def test_default_is_cpu(self, dispatcher): assert dispatcher.settings.backend == "cpu" + @pytest.mark.parametrize( + ("argument", "value"), + [ + ("entrypoint_group", ""), + ("entrypoint_group", " "), + ("entrypoint_group", None), + ("host_name", ""), + ("host_name", " "), + ("host_name", None), + ], + ) + def test_dispatcher_requires_nonempty_identity(self, argument, value): + from scverse_backends import BackendDispatcher + + kwargs = {"entrypoint_group": "test.backends", "host_name": "test"} + kwargs[argument] = value + + with pytest.raises(ValueError, match=argument): + BackendDispatcher(**kwargs) + + @pytest.mark.parametrize( + ("argument", "value"), + [ + ("trusted_backends", []), + ("reserved_backends", []), + ], + ) + def test_dispatcher_requires_mapping_configuration(self, argument, value): + from scverse_backends import BackendDispatcher + + kwargs = {"entrypoint_group": "test.backends", "host_name": "test"} + kwargs[argument] = value + + with pytest.raises(ValueError, match=argument): + BackendDispatcher(**kwargs) + + def test_reserved_backend_reason_must_be_nonempty(self): + from scverse_backends import BackendDispatcher + + with pytest.raises(ValueError, match="Reason for reserved"): + BackendDispatcher( + entrypoint_group="test.backends", + host_name="test", + reserved_backends={"gpu": ""}, + ) + + with pytest.raises(ValueError, match="Reason for reserved"): + BackendDispatcher( + entrypoint_group="test.backends", + host_name="test", + reserved_backends={"gpu": " "}, + ) + def test_settings_type_is_public(self, dispatcher): from scverse_backends import Settings @@ -78,6 +132,28 @@ def test_untrusted_backend_warns_once(self, untrusted_dispatcher): assert len(w) == 1 assert "not in testhost's trusted backends list" in str(w[0].message) + def test_untrusted_backend_warns_once_across_threads(self, untrusted_dispatcher): + register_fake(untrusted_dispatcher) + start = threading.Barrier(4) + + def select_backend(): + start.wait(timeout=5) + untrusted_dispatcher.settings.backend = "fake_gpu" + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: + futures = [executor.submit(select_backend) for _ in range(4)] + for future in futures: + future.result() + + matching = [ + warning + for warning in caught + if "not in testhost's trusted backends list" in str(warning.message) + ] + assert len(matching) == 1 + def test_available_backends_empty(self, dispatcher): assert dispatcher.settings.available_backends() == [] @@ -85,6 +161,23 @@ def test_available_backends_with_registered(self, dispatcher): register_fake(dispatcher) assert "fake_gpu" in dispatcher.settings.available_backends() + def test_available_names_include_installed_trusted_config_aliases(self, dispatcher): + class Backend: + name = "fake_gpu" + aliases = [] + + dispatcher._registry._register_backend( + Backend(), + entrypoint_name="fake_gpu", + distribution_name="fake-gpu-pkg", + ) + + assert dispatcher.available_backend_names() == [ + "fake", + "fake_gpu", + "test-gpu", + ] + def test_get_backend_returns_instance(self, dispatcher): backend = register_fake(dispatcher) assert dispatcher.settings.get_backend("fake_gpu") is backend @@ -198,6 +291,103 @@ def entry_points(*, group): assert results == [["thread_gpu"], ["thread_gpu"]] assert load_count == 1 + def test_discovery_can_retry_after_enumeration_failure(self, monkeypatch): + from scverse_backends import BackendDispatcher + + calls = 0 + + def entry_points(*, group): + nonlocal calls + calls += 1 + if calls == 1: + raise RuntimeError("metadata unavailable") + return [] + + monkeypatch.setattr(importlib.metadata, "entry_points", entry_points) + dispatcher = BackendDispatcher( + entrypoint_group="retry.backends", + host_name="retry", + ) + + with pytest.raises(RuntimeError, match="metadata unavailable"): + dispatcher.discover() + assert not dispatcher._registry._discovered + + dispatcher.discover() + + assert dispatcher._registry._discovered + assert calls == 2 + + def test_discovery_can_retry_after_callback_failure(self, monkeypatch): + from scverse_backends import BackendDispatcher + + class Backend: + name = "retry_gpu" + aliases = [] + + class Entrypoint: + name = "retry_gpu" + value = "retry:Backend" + dist = None + + def load(self): + return Backend() + + monkeypatch.setattr( + importlib.metadata, + "entry_points", + lambda *, group: [Entrypoint()], + ) + dispatcher = BackendDispatcher( + entrypoint_group="retry.backends", + host_name="retry", + ) + calls = 0 + + def callback(): + nonlocal calls + calls += 1 + if calls == 1: + raise RuntimeError("merge unavailable") + + dispatcher._registry._on_discovered = callback + + with pytest.raises(RuntimeError, match="merge unavailable"): + dispatcher.discover() + assert not dispatcher._registry._discovered + assert dispatcher._registry._backends == {} + + dispatcher.discover() + + assert dispatcher._registry._discovered + assert calls == 2 + assert dispatcher.available_backend_names() == ["retry_gpu"] + + def test_trusted_config_is_copied_from_caller(self): + from scverse_backends import BackendDispatcher + + distributions = ["expected-package"] + trusted = MappingProxyType( + { + "fake_gpu": MappingProxyType( + { + "aliases": ["fake"], + "distributions": distributions, + } + ) + } + ) + dispatcher = BackendDispatcher( + entrypoint_group="test.backends", + host_name="test", + trusted_backends=trusted, + ) + distributions.append("unexpected-package") + + assert dispatcher._registry.trusted_backends["fake_gpu"]["distributions"] == ( + "expected-package", + ) + def test_trusted_alias_cannot_be_claimed_by_untrusted_backend(self, dispatcher): class BadBackend: name = "bad_gpu" @@ -212,6 +402,112 @@ class BadBackend: with pytest.raises(ImportError, match="fake-gpu-pkg"): dispatcher.settings.backend = "fake" + @pytest.mark.parametrize( + "info", + [ + None, + {"aliases": [], "package": 1}, + {"aliases": [], "package": ["not", "singular"]}, + {"aliases": [], "package": {"unexpected": "mapping"}}, + {"aliases": [], "entrypoints": ["valid", None]}, + {"aliases": [], "module_prefixes": ""}, + ], + ) + def test_invalid_trusted_provider_config_is_rejected(self, info): + from scverse_backends import BackendDispatcher + + with pytest.raises(ValueError, match="Trusted backend"): + BackendDispatcher( + entrypoint_group="test.backends", + host_name="test", + trusted_backends={"fake_gpu": info}, + ) + + def test_registration_failure_does_not_hide_other_backends(self, monkeypatch): + from scverse_backends import BackendDispatcher + + class BrokenBackend: + @property + def name(self): + raise RuntimeError("broken metadata") + + class GoodBackend: + name = "good" + aliases = [] + + class Entrypoint: + dist = None + + def __init__(self, name, provider): + self.name = name + self.value = f"test:{name}" + self._provider = provider + + def load(self): + return self._provider + + def entry_points(*, group): + assert group == "test.backends" + return [ + Entrypoint("broken", BrokenBackend()), + Entrypoint("good", GoodBackend()), + ] + + monkeypatch.setattr(importlib.metadata, "entry_points", entry_points) + dispatcher = BackendDispatcher( + entrypoint_group="test.backends", + host_name="test", + ) + + with pytest.warns(UserWarning, match="registration failed"): + assert dispatcher.available_backend_names() == ["good"] + with pytest.raises(ImportError, match="broken metadata"): + dispatcher.settings.backend = "broken" + + def test_alias_metadata_failure_does_not_partially_register(self, monkeypatch): + from scverse_backends import BackendDispatcher + + class BrokenBackend: + name = "broken" + + @property + def aliases(self): + raise RuntimeError("broken aliases") + + class GoodBackend: + name = "good" + aliases = [] + + class Entrypoint: + dist = None + + def __init__(self, name, provider): + self.name = name + self.value = f"test:{name}" + self._provider = provider + + def load(self): + return self._provider + + monkeypatch.setattr( + importlib.metadata, + "entry_points", + lambda *, group: [ + Entrypoint("broken", BrokenBackend()), + Entrypoint("good", GoodBackend()), + ], + ) + dispatcher = BackendDispatcher( + entrypoint_group="test.backends", + host_name="test", + ) + + with pytest.warns(UserWarning, match="broken aliases"): + names = dispatcher.available_backend_names() + + assert names == ["good"] + assert "broken" not in dispatcher._registry._backends + def test_invalid_backend_name_is_ignored(self, dispatcher): class BadBackend: name = "bad\nname"