Source code for qc_executor.factory

"""Factory class for creating executor instances with plugin support."""

from __future__ import annotations

import logging
from importlib.metadata import entry_points
from typing import Any, Callable, Type, cast

from .base.executor_base import ExecutorBase

logger = logging.getLogger(__name__)


[docs] class Executor: """Factory class for creating executor instances based on backend name. This class provides a plugin-based architecture for executor backends. Backends can be registered using the @Executor.register() decorator or discovered automatically via entry points. Example: >>> executor = Executor.create("qiskit", shots=1024) >>> backends = Executor.available_backends() >>> print(backends) # ['qiskit', 'pennylane', 'qulacs'] """ _registry: dict[str, Type["ExecutorBase"]] = {} _backend_alias_map: dict[str, str] = {} _alias_registry_size: int = 0 _plugins_discovered: bool = False _backend_extra_map: dict[str, str] = { "qiskit": "qiskit-full", "pennylane": "pennylane", "qulacs": "qulacs", "pauli_propagation": "pauli_propagation", } def __init__(self): """Executor cannot be instantiated. Use Executor.create() instead.""" raise TypeError( "Executor cannot be instantiated directly. " "Use Executor.create(backend_name, **kwargs) instead." )
[docs] @classmethod def register(cls, name: str) -> Callable[[Type["ExecutorBase"]], Type["ExecutorBase"]]: """Decorator to register a backend implementation. Args: name: The name of the backend (e.g., "qiskit", "pennylane", "qulacs") Returns: Decorator function that registers the backend class Raises: TypeError: If the decorated class does not inherit from ExecutorBase Example: >>> @Executor.register("qiskit") ... class QiskitExecutor(ExecutorBase): ... pass """ def decorator(backend_class: Type["ExecutorBase"]) -> Type["ExecutorBase"]: if not issubclass(backend_class, ExecutorBase): raise TypeError( f"{backend_class.__name__} must inherit from ExecutorBase " f"to be registered as a backend" ) cls._registry[name] = backend_class cls._index_backend_aliases(name, backend_class) cls._alias_registry_size = len(cls._registry) logger.debug("Registered backend '%s': %s", name, backend_class.__name__) return backend_class return decorator
[docs] @classmethod def create(cls, target: str | Any, **kwargs) -> "ExecutorBase": """Create an executor instance for the specified backend. Args: target: Name of the backend (e.g., "qiskit", "pennylane", "qulacs"). May also be a Qiskit ``Backend`` / ``BackendV2`` instance, in which case the ``"qiskit"`` executor is used automatically and the object is forwarded as ``backend=<instance>``. **kwargs: Configuration parameters passed to the backend constructor Returns: An instance of the requested backend executor Raises: ValueError: If the backend is not found or not installed Example: >>> executor = Executor.create("qiskit", shots=1024, seed=42) >>> executor = Executor.create("pennylane", shots=1000) """ # Try discovering plugins if not done yet if not cls._plugins_discovered: cls._discover_plugins() # Keep alias map in sync if tests or callers manipulated _registry directly. cls._ensure_alias_map_consistency() # String path: look up in registry and create instance if isinstance(target, str): target_alias = cls._normalize_backend_alias(target) # Exact backend names take precedence over aliases. if target in cls._registry: backend_class = cls._registry[target] logger.info("Creating %s with config: %s", backend_class.__name__, kwargs) return backend_class(**kwargs) alias_backend_name = cls._backend_alias_map.get(target_alias) if alias_backend_name is not None: backend_class = cls._registry.get(alias_backend_name) if backend_class is None: cls._rebuild_backend_alias_map() alias_backend_name = cls._backend_alias_map.get(target_alias) backend_class = ( cls._registry.get(alias_backend_name) if alias_backend_name is not None else None ) if backend_class is not None: if "backend" in kwargs: raise ValueError( "Conflicting 'backend' argument: the backend was specified " f"both via alias routing ('{target}') and explicitly in " "**kwargs. Please specify the backend only once." ) logger.info( "Routing string target '%s' to backend '%s' via alias.", target, alias_backend_name, ) return backend_class(backend=target_alias, **kwargs) raise cls._build_backend_not_found_error(target, target_alias) # Non-string path: auto-detect executor from accepted_types for name, executor_class in cls._registry.items(): try: accepted = executor_class.get_accepted_backend_types() except (ImportError, NotImplementedError, AttributeError): logger.debug( "get_accepted_backend_types() failed for '%s'; skipping.", name, exc_info=True ) continue if any(isinstance(target, t) for t in accepted): logger.info( "Auto-detected backend '%s' for object of type %s.", name, type(target).__name__, ) return executor_class(backend=target, **kwargs) # No plugin matched — produce a helpful error message accepted_summary = { name: [t.__name__ for t in executor_class.get_accepted_backend_types()] for name, executor_class in cls._registry.items() if executor_class.get_accepted_backend_types() } raise ValueError( f"No registered executor accepts an object of type " f"'{type(target).__qualname__}'. " f"Pass the backend name as a string instead, or install the " f"matching plugin.\n" f"Accepted types per backend: {accepted_summary}" )
@classmethod def _discover_plugins(cls) -> None: """Discover and load plugins via entry points. This method searches for entry points in the 'qc_executor.backends' group and loads them. Loading a plugin module triggers its @register decorator, which adds the backend to the registry. """ cls._plugins_discovered = True # Get entry points for executor backends try: # Python 3.10+ API: supports group keyword argument eps = entry_points(group="qc_executor.backends") except TypeError: # Python < 3.10: entry_points() returns a dict-like mapping _all_eps = entry_points() _select = getattr(_all_eps, "select", None) if callable(_select): eps = cast(list, _select(group="qc_executor.backends")) else: eps = cast( list, getattr(_all_eps, "get", lambda *a: [])("qc_executor.backends", []) ) # Load each entry point for ep in eps: try: logger.debug("Loading plugin entry point: %s", ep.name) ep.load() # This triggers the @register decorator except (ImportError, AttributeError) as e: logger.warning("Failed to load plugin '%s': %s", ep.name, e) @classmethod def _build_backend_not_found_error(cls, target: str, target_alias: str) -> ValueError: """Build a helpful ValueError for an unrecognised backend name.""" available = cls.available_backends() available_str = ", ".join(f"'{b}'" for b in available) if available else "none" aliases = sorted(cls._backend_alias_map.keys()) aliases_str = ", ".join(f"'{a}'" for a in aliases) if aliases else "none" resolved = cls._backend_alias_map.get(target_alias) if resolved in cls._backend_extra_map: backend_key = resolved elif target in cls._backend_extra_map: backend_key = target else: backend_key = None message = ( f"Backend '{target}' not found. " f"Available backends: {available_str}. " f"Known backend aliases: {aliases_str}." ) if backend_key is not None: extra_name = cls._backend_extra_map[backend_key] message += f" Install with: pip install qc-executor[{extra_name}]" return ValueError(message)
[docs] @classmethod def available_backends(cls) -> list[str]: """Get a list of available (installed) backends. Returns: List of backend names that can be used with create() Example: >>> backends = Executor.available_backends() >>> print(backends) # ['qiskit', 'pennylane', 'qulacs'] """ # Ensure plugins are discovered if not cls._plugins_discovered: cls._discover_plugins() return sorted(cls._registry.keys())
@classmethod def _normalize_backend_alias(cls, alias: str) -> str: """Normalize a backend alias for stable lookups and duplicate checks.""" return alias.strip().lower() @classmethod def _index_backend_aliases( cls, backend_name: str, backend_class: Type["ExecutorBase"] ) -> None: """Add aliases for a backend to the central alias map. Raises: ValueError: If an alias is already owned by another backend. """ aliases = backend_class.get_accepted_backend_aliases() for alias in aliases: normalized_alias = cls._normalize_backend_alias(str(alias)) if not normalized_alias: continue existing_backend = cls._backend_alias_map.get(normalized_alias) if existing_backend is not None and existing_backend != backend_name: raise ValueError( f"Duplicate backend alias '{normalized_alias}' declared by " f"'{backend_name}' and '{existing_backend}'." ) cls._backend_alias_map[normalized_alias] = backend_name @classmethod def _rebuild_backend_alias_map(cls) -> None: """Rebuild the alias map from the current backend registry. This is a safety net for tests or integrations that modify ``_registry`` directly instead of using :meth:`register`. """ new_alias_map: dict[str, str] = {} for backend_name, backend_class in cls._registry.items(): aliases = backend_class.get_accepted_backend_aliases() for alias in aliases: normalized_alias = cls._normalize_backend_alias(str(alias)) if not normalized_alias: continue existing_backend = new_alias_map.get(normalized_alias) if existing_backend is not None and existing_backend != backend_name: raise ValueError( f"Duplicate backend alias '{normalized_alias}' declared by " f"'{backend_name}' and '{existing_backend}'." ) new_alias_map[normalized_alias] = backend_name cls._backend_alias_map = new_alias_map cls._alias_registry_size = len(cls._registry) @classmethod def _ensure_alias_map_consistency(cls) -> None: """Rebuild alias map when registry changes outside of ``register``.""" if len(cls._registry) != cls._alias_registry_size: cls._rebuild_backend_alias_map()
[docs] @classmethod def switch_backend( cls, executor: "ExecutorBase", backend: str | Any, **overrides ) -> "ExecutorBase": """Switch an executor to a different backend while preserving its configuration. Creates a new executor instance with the specified backend, copying the current configuration and applying any overrides. Args: executor (ExecutorBase): The existing executor whose configuration should be copied. backend: Name of the backend to switch to (e.g., ``"qiskit"``, ``"pennylane"``, ``"qulacs"``). May also be a Qiskit ``Backend`` / ``BackendV2`` instance, in which case the ``"qiskit"`` executor is used automatically. **overrides: Configuration parameters to override (e.g., shots=2048) Returns: ExecutorBase: New executor instance with the specified backend Example: >>> executor = Executor.create("qiskit", shots=1024, seed=42) >>> pennylane_executor = Executor.switch_backend(executor, "pennylane") >>> # pennylane_executor has shots=1024, seed=42 >>> >>> # Override specific parameters >>> qulacs_executor = Executor.switch_backend(executor, "qulacs", shots=2048) >>> # qulacs_executor has shots=2048, seed=42 >>> >>> # Switch to a real IBM Quantum backend >>> from qiskit_ibm_runtime import QiskitRuntimeService >>> service = QiskitRuntimeService() >>> ibm_backend = service.least_busy(operational=True, simulator=False) >>> ibm_executor = Executor.switch_backend(executor, ibm_backend) """ config = executor.get_config() config.update(overrides) logger.info("Switching backend from %s to %s", type(executor).__name__, backend) return cls.create(backend, **config)