diff --git a/azure-quantum/azure/quantum/qiskit/backends/__init__.py b/azure-quantum/azure/quantum/qiskit/backends/__init__.py index c401ba37..8f579804 100644 --- a/azure-quantum/azure/quantum/qiskit/backends/__init__.py +++ b/azure-quantum/azure/quantum/qiskit/backends/__init__.py @@ -37,8 +37,10 @@ QCIQPUBackend, ) +from azure.quantum.qiskit.backends.generic import ( + AzureGenericQirBackend, +) + from .backend import AzureBackendBase -__all__ = [ - "AzureBackendBase" -] \ No newline at end of file +__all__ = ["AzureBackendBase"] diff --git a/azure-quantum/azure/quantum/qiskit/backends/backend.py b/azure-quantum/azure/quantum/qiskit/backends/backend.py index acdbc34f..0c6be94a 100644 --- a/azure-quantum/azure/quantum/qiskit/backends/backend.py +++ b/azure-quantum/azure/quantum/qiskit/backends/backend.py @@ -46,6 +46,7 @@ try: # Qiskit 1.x legacy support from qiskit.providers.models import BackendConfiguration # type: ignore + BackendConfigurationType = BackendConfiguration from qiskit.qobj import QasmQobj, PulseQobj # type: ignore @@ -263,15 +264,11 @@ def from_dict(cls, data: Mapping[str, Any]) -> "AzureBackendConfig": ) @classmethod - def from_backend_configuration( - cls, configuration: Any - ) -> "AzureBackendConfig": + def from_backend_configuration(cls, configuration: Any) -> "AzureBackendConfig": return cls.from_dict(configuration.to_dict()) -def _ensure_backend_config( - configuration: Any -) -> AzureBackendConfig: +def _ensure_backend_config(configuration: Any) -> AzureBackendConfig: if isinstance(configuration, AzureBackendConfig): return configuration @@ -289,15 +286,12 @@ def _ensure_backend_config( class AzureBackendBase(Backend, SessionHost): # Name of the provider's input parameter which specifies number of shots for a submitted job. - # If None, backend will not pass this input parameter. + # If None, backend will not pass this input parameter. _SHOTS_PARAM_NAME = "shots" @abstractmethod def __init__( - self, - configuration: Any, - provider: "AzureQuantumProvider" = None, - **fields + self, configuration: Any, provider: "AzureQuantumProvider" = None, **fields ): if configuration is None: raise ValueError("Backend configuration is required for Azure backends") @@ -339,19 +333,19 @@ def _build_target(self, configuration: AzureBackendConfig) -> Target: target.add_instruction(instruction) return target - + @abstractmethod def run( self, run_input: Union[QuantumCircuit, List[QuantumCircuit]] = [], - shots: int = None, + shots: int = None, **options, ) -> AzureQuantumJob: """Run on the backend. This method returns a :class:`~azure.quantum.qiskit.job.AzureQuantumJob` object - that runs circuits. + that runs circuits. Args: run_input (QuantumCircuit or List[QuantumCircuit]): An individual or a @@ -399,6 +393,7 @@ def target(self) -> Target: @property def max_circuits(self) -> Optional[int]: return 1 + def retrieve_job(self, job_id) -> AzureQuantumJob: """Returns the Job instance associated with the given id.""" return self.provider.get_job(job_id) @@ -415,8 +410,10 @@ def _get_output_data_format(self, options: Dict[str, Any] = {}) -> str: output_data_format = options.pop("output_data_format", azure_defined_override) return output_data_format - - def _get_input_params(self, options: Dict[str, Any], shots: int = None) -> Dict[str, Any]: + + def _get_input_params( + self, options: Dict[str, Any], shots: int = None + ) -> Dict[str, Any]: # Backend options are mapped to input_params. input_params: Dict[str, Any] = vars(self.options).copy() @@ -426,7 +423,7 @@ def _get_input_params(self, options: Dict[str, Any], shots: int = None) -> Dict[ final_shots = None # First we check for the explicitly specified 'shots' parameter, then for a provider-specific - # field in options, then for a backend's default value. + # field in options, then for a backend's default value. # Warn about options conflict, default to 'shots'. if shots is not None and options_shots is not None: @@ -436,7 +433,7 @@ def _get_input_params(self, options: Dict[str, Any], shots: int = None) -> Dict[ stacklevel=3, ) final_shots = shots - + elif shots is not None: final_shots = shots elif options_shots is not None: @@ -446,7 +443,7 @@ def _get_input_params(self, options: Dict[str, Any], shots: int = None) -> Dict[ stacklevel=3, ) final_shots = options_shots - + # If nothing is found, try to get from default values. if final_shots is None: final_shots = input_params.get(self.__class__._SHOTS_PARAM_NAME) @@ -456,7 +453,6 @@ def _get_input_params(self, options: Dict[str, Any], shots: int = None) -> Dict[ _ = options.pop("count", None) input_params[self.__class__._SHOTS_PARAM_NAME] = final_shots - if "items" in options: input_params["items"] = options.pop("items") @@ -487,10 +483,7 @@ def _run(self, job_name, input_data, input_params, metadata, **options): # Anything left here is an invalid parameter with the user attempting to use # deprecated parameters. targetCapability = input_params.get("targetCapability", None) - if ( - targetCapability not in [None, "qasm"] - and input_data_format != "qir.v1" - ): + if targetCapability not in [None, "qasm"] and input_data_format != "qir.v1": message = "The targetCapability parameter has been deprecated and is only supported for QIR backends." message += os.linesep message += "To find a QIR capable backend, use the following code:" @@ -500,7 +493,6 @@ def _run(self, job_name, input_data, input_params, metadata, **options): ) raise ValueError(message) - # Update metadata with all remaining options values, then clear options # JobDetails model will error if unknown keys are passed down which # can happen with estiamtor and backend wrappers @@ -586,14 +578,14 @@ def _azure_config(self) -> Dict[str, str]: "output_data_format": "microsoft.quantum-results.v2", "is_default": True, } - + def _basis_gates(self) -> List[str]: return QIR_BASIS_GATES def run( self, run_input: Union[QuantumCircuit, List[QuantumCircuit]] = [], - shots: int = None, + shots: int = None, **options, ) -> AzureQuantumJob: """Run on the backend. @@ -632,7 +624,7 @@ def run( # config normalization input_params = self._get_input_params(options, shots=shots) - + shots_count = None if self._can_send_shots_input_param(): @@ -658,11 +650,10 @@ def _prepare_job_metadata(self, circuit: QuantumCircuit) -> Dict[str, str]: return { "qiskit": str(True), "name": circuit.name, - "num_qubits": circuit.num_qubits, + "num_qubits": str(circuit.num_qubits), "metadata": json.dumps(circuit.metadata), } - def _get_qir_str( self, circuit: QuantumCircuit, target_profile: TargetProfile, **kwargs ) -> str: @@ -679,9 +670,8 @@ def _get_qir_str( ) qir_str = backend.qir(circuit) - - return qir_str + return qir_str def _translate_input( self, circuit: QuantumCircuit, input_params: Dict[str, Any] @@ -703,7 +693,7 @@ def _translate_input( category=DeprecationWarning, stacklevel=3, ) - + qir_str = self._get_qir_str( circuit, target_profile, skip_transpilation=skip_transpilation ) @@ -763,9 +753,9 @@ def __init__( def _prepare_job_metadata(self, circuit): """Returns the metadata relative to the given circuit that will be attached to the Job""" return { - "qiskit": True, + "qiskit": str(True), "name": circuit.name, - "num_qubits": circuit.num_qubits, + "num_qubits": str(circuit.num_qubits), "metadata": json.dumps(circuit.metadata), } @@ -774,12 +764,12 @@ def _translate_input(self, circuit): pass def run( - self, - run_input: Union[QuantumCircuit, List[QuantumCircuit]] = [], - shots: int = None, - **options, - ): - """Submits the given circuit to run on an Azure Quantum backend.""" + self, + run_input: Union[QuantumCircuit, List[QuantumCircuit]] = [], + shots: int = None, + **options, + ): + """Submits the given circuit to run on an Azure Quantum backend.""" circuit = self._normalize_run_input_params(run_input, **options) options.pop("run_input", None) options.pop("circuit", None) diff --git a/azure-quantum/azure/quantum/qiskit/backends/generic.py b/azure-quantum/azure/quantum/qiskit/backends/generic.py new file mode 100644 index 00000000..517b810a --- /dev/null +++ b/azure-quantum/azure/quantum/qiskit/backends/generic.py @@ -0,0 +1,122 @@ +## +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +## + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Optional + +from azure.quantum.version import __version__ + +try: + from qiskit.providers import Options + from qsharp import TargetProfile +except ImportError as exc: + raise ImportError( + "Missing optional 'qiskit' dependencies. \ +To install run: pip install azure-quantum[qiskit]" + ) from exc + +from .backend import AzureBackendConfig, AzureQirBackend + +if TYPE_CHECKING: + from azure.quantum.qiskit import AzureQuantumProvider + + +_DEFAULT_SHOTS_COUNT = 500 + + +class AzureGenericQirBackend(AzureQirBackend): + """Fallback QIR backend for arbitrary Azure Quantum workspace targets. + + This backend is created dynamically by :class:`~azure.quantum.qiskit.provider.AzureQuantumProvider` + for targets present in the workspace that do not have a dedicated Qiskit backend class. + + It submits Qiskit circuits using QIR (`qir.v1`) payloads. + """ + + _SHOTS_PARAM_NAME = "shots" + + def __init__( + self, + name: str, + provider: "AzureQuantumProvider", + *, + provider_id: str, + target_profile: Optional[TargetProfile | str] = None, + num_qubits: Optional[int] = None, + description: Optional[str] = None, + **kwargs: Any, + ): + self._provider_id = provider_id + + config = AzureBackendConfig.from_dict( + { + "backend_name": name, + "backend_version": __version__, + "simulator": False, + "local": False, + "coupling_map": None, + "description": description + or f"Azure Quantum target '{name}' (generic QIR backend)", + "basis_gates": self._basis_gates(), + "memory": False, + "n_qubits": num_qubits, + "conditional": False, + "max_shots": None, + "open_pulse": False, + "gates": [{"name": "TODO", "parameters": [], "qasm_def": "TODO"}], + "azure": self._azure_config(), + } + ) + + super().__init__(config, provider, **kwargs) + + # Prefer an instance-specific target profile discovered from the workspace target metadata. + default_target_profile = self._coerce_target_profile(target_profile) + if default_target_profile is not None: + self.set_options(target_profile=default_target_profile) + + @staticmethod + def _coerce_target_profile( + value: Optional[TargetProfile | str], + ) -> Optional[TargetProfile]: + if value is None: + return None + if isinstance(value, TargetProfile): + return value + if not isinstance(value, str): + return None + + raw = value.strip() + if not raw: + return None + + # Prefer the qsharp helper when available. + from_str = getattr(TargetProfile, "from_str", None) + if callable(from_str): + try: + parsed = from_str(raw) + if isinstance(parsed, TargetProfile): + return parsed + except Exception: + pass + + # Best-effort: try enum attribute lookup. + normalized = raw.replace("-", "_") + return getattr(TargetProfile, normalized, None) + + @classmethod + def _default_options(cls) -> Options: + # Default to the most conservative QIR profile; users can override per-run via + # `target_profile=` in backend.run(...). + return Options( + **{cls._SHOTS_PARAM_NAME: _DEFAULT_SHOTS_COUNT}, + target_profile=TargetProfile.Base, + ) + + def _azure_config(self) -> Dict[str, str]: + config = super()._azure_config() + config.update({"provider_id": self._provider_id, "is_default": False}) + return config diff --git a/azure-quantum/azure/quantum/qiskit/provider.py b/azure-quantum/azure/quantum/qiskit/provider.py index d95f8ae3..31cf8798 100644 --- a/azure-quantum/azure/quantum/qiskit/provider.py +++ b/azure-quantum/azure/quantum/qiskit/provider.py @@ -5,7 +5,7 @@ import warnings import inspect -from typing import Dict, List, Optional, Tuple, Type +from typing import Dict, List, Optional, Tuple, Type, Mapping, Any from abc import ABC from azure.quantum import Workspace @@ -23,24 +23,30 @@ from azure.quantum.qiskit.backends.backend import AzureBackendBase from azure.quantum.qiskit.job import AzureQuantumJob from azure.quantum.qiskit.backends import * +from azure.quantum.qiskit.backends.generic import AzureGenericQirBackend +from azure.quantum._client.models import TargetStatus QISKIT_USER_AGENT = "azure-quantum-qiskit" + class AzureQuantumProvider(ABC): - - def __init__(self, workspace: Optional[Workspace]=None, **kwargs): + + def __init__(self, workspace: Optional[Workspace] = None, **kwargs): """Class for interfacing with the Azure Quantum service using Qiskit quantum circuits. - :param workspace: Azure Quantum workspace. If missing it will create a new Workspace passing `kwargs` to the constructor. Defaults to None. + :param workspace: Azure Quantum workspace. If missing it will create a new Workspace passing `kwargs` to the constructor. Defaults to None. :type workspace: Workspace """ if kwargs is not None and len(kwargs) > 0: from warnings import warn - warn(f"""Consider passing \"workspace\" argument explicitly. - The ability to initialize AzureQuantumProvider with arguments {', '.join(f'"{argName}"' for argName in kwargs)} is going to be deprecated in future versions.""", - DeprecationWarning, - stacklevel=2) + + warn( + f"""Consider passing \"workspace\" argument explicitly. + The ability to initialize AzureQuantumProvider with arguments {', '.join(f'"{argName}"' for argName in kwargs)} is going to be deprecated in future versions.""", + DeprecationWarning, + stacklevel=2, + ) if workspace is None: workspace = Workspace(**kwargs) @@ -85,7 +91,7 @@ def get_backend(self, name=None, **kwargs) -> AzureBackendBase: def backends(self, name=None, **kwargs): """Return a list of backends matching the specified filtering. - + Args: name (str): name of the backend. **kwargs: dict used for filtering. @@ -98,15 +104,22 @@ def backends(self, name=None, **kwargs): if self._backends is None: self._backends = self._init_backends() - if name: - if name not in self._backends: - raise QiskitBackendNotFoundError( - f"The '{name}' backend is not installed in your system." - ) - provider_id = kwargs.get("provider_id", None) - allowed_targets = self._get_allowed_targets_from_workspace(name, provider_id) + # Query targets available in the workspace. We'll use this both for workspace + # filtering and to synthesize fallback backends for targets without dedicated + # Qiskit backend classes. + status_by_target = self._get_workspace_target_status_map(name, provider_id) + allowed_targets: List[Tuple[str, str]] = list(status_by_target.keys()) + + # If a user asks for a specific backend name and it isn't installed, + # raise a clear error. With generic backends, a name can still be valid + # even if it isn't installed, as long as the target exists in the workspace. + if name and name not in self._backends and not allowed_targets: + provider_clause = f" for provider_id '{provider_id}'" if provider_id else "" + raise QiskitBackendNotFoundError( + f"The '{name}' backend is not installed in your system, nor is it a valid target{provider_clause} in your Azure Quantum workspace." + ) workspace_allowed = lambda backend: self._is_available_in_ws( allowed_targets, backend @@ -115,6 +128,33 @@ def backends(self, name=None, **kwargs): # flatten the available backends backend_list = [x for v in self._backends.values() for x in v] + # Add a generic QIR backend for targets that exist in the workspace but are + # missing from the installed backend classes. + existing_pairs = set() + for backend in backend_list: + try: + config = backend.configuration().to_dict() + except Exception: + continue + azure_cfg = config.get("azure", {}) or {} + existing_pairs.add((backend.name, azure_cfg.get("provider_id"))) + + for target_id, pid in allowed_targets: + if (target_id, pid) in existing_pairs: + continue + status = status_by_target.get((target_id, pid)) + backend_list.append( + AzureGenericQirBackend( + name=target_id, + provider=self, + provider_id=pid, + target_profile=( + status.target_profile if status is not None else None + ), + num_qubits=status.num_qubits if status is not None else None, + ) + ) + # filter by properties specified in the kwargs and filter function filtered_backends: List[Backend] = self._filter_backends( backend_list, filters=workspace_allowed, **kwargs @@ -128,17 +168,17 @@ def backends(self, name=None, **kwargs): ), filtered_backends, ) - ) + ) # If default backends were found - return them, otherwise return the filtered_backends collection. - # The latter case could happen where there's no default backend defined for the specified target. - if len(default_backends) > 0: + # The latter case could happen where there's no default backend defined for the specified target. + if len(default_backends) > 0: return default_backends return filtered_backends def get_job(self, job_id) -> AzureQuantumJob: """Returns the Job instance associated with the given id. - + Args: job_id (str): Id of the Job to return. Returns: @@ -159,14 +199,20 @@ def _is_available_in_ws( return True return False - def _get_allowed_targets_from_workspace( - self, name: str, provider_id: str - ) -> List[Tuple[str, str]]: + def _get_workspace_target_status_map( + self, name: Optional[str] = None, provider_id: Optional[str] = None + ) -> Dict[Tuple[str, str], TargetStatus]: + """Return workspace targets keyed by (target_id, provider_id). + + This is a thin wrapper over `Workspace._get_target_status` that preserves + the full status objects so callers can read metadata (e.g. num qubits) + without needing additional workspace queries. + """ target_statuses = self._workspace._get_target_status(name, provider_id) - candidates: List[Tuple[str, str]] = [] - for provider_id, status in target_statuses: - candidates.append((status.id, provider_id)) - return candidates + by_target: Dict[Tuple[str, str], TargetStatus] = {} + for pid, status in target_statuses: + by_target[(status.id, pid)] = status + return by_target def _get_candidate_subclasses(self, subtype: Type[Backend]): if not inspect.isabstract(subtype): @@ -178,7 +224,6 @@ def _get_candidate_subclasses(self, subtype: Type[Backend]): for leaf in self._get_candidate_subclasses(subclass): yield leaf - def _init_backends(self) -> Dict[str, List[Backend]]: instances: Dict[str, List[Backend]] = {} subclasses = list(self._get_candidate_subclasses(subtype=AzureBackendBase)) @@ -218,9 +263,7 @@ def _match_all(self, obj, criteria): def _match_config(self, obj, key, value): """Return True if the criteria matches the base config or azure config.""" - return obj.get(key, None) == value or self._match_azure_config( - obj, key, value - ) + return obj.get(key, None) == value or self._match_azure_config(obj, key, value) def _match_azure_config(self, obj, key, value): """Return True if the criteria matches the azure config.""" @@ -239,7 +282,7 @@ def _filter_backends( or from a boolean callable. The criteria for filtering can be specified via `**kwargs` or as a callable via `filters`, and the backends must fulfill all specified conditions. - + Args: backends (list[Backend]): list of backends. filters (callable): filtering conditions as a callable. @@ -257,7 +300,8 @@ def _filter_backends( # their configuration to be considered for filtering print(f"Looking for {key} with {value}") if any( - self._has_config_value(backend.configuration().to_dict(), key) for backend in backends + self._has_config_value(backend.configuration().to_dict(), key) + for backend in backends ): configuration_filters[key] = value else: @@ -277,9 +321,9 @@ def _filter_backends( warnings.warn( f"Specified filters {unknown_filters} are not supported by the available backends." ) - + backends = list(filter(filters, backends)) - + return backends def __eq__(self, other): diff --git a/azure-quantum/tests/test_qiskit.py b/azure-quantum/tests/test_qiskit.py index 1059b8dc..9a81e83d 100644 --- a/azure-quantum/tests/test_qiskit.py +++ b/azure-quantum/tests/test_qiskit.py @@ -25,6 +25,7 @@ from azure.quantum.qiskit.job import AzureQuantumJob from azure.quantum.qiskit.backends.backend import QIR_BASIS_GATES +from azure.quantum.qiskit.backends.generic import AzureGenericQirBackend from azure.quantum.qiskit.backends.ionq import ( IonQSimulatorBackend, IonQSimulatorQirBackend, @@ -35,8 +36,51 @@ QuantinuumEmulatorBackend, QuantinuumEmulatorQirBackend, ) +from azure.quantum._client.models import TargetStatus -from mock_client import create_default_workspace +from mock_client import create_default_workspace, _paged + +from types import SimpleNamespace + + +def _seed_workspace_target( + monkeypatch: pytest.MonkeyPatch, + ws, + *, + provider_id: str, + target_id: str, + num_qubits: int | None = None, + target_profile: str | None = None, +) -> None: + """Inject a provider+target into the offline Workspace mock. + + The Qiskit provider discovers targets via `Workspace._get_target_status()`, + which iterates `ws._client.services.providers.list()`. + """ + + # `AzureQuantumProvider.__init__` appends a user agent to the Workspace, which + # recreates the underlying client (and would wipe our patched providers.list). + # For this offline-only test, keep the existing mock client. + if hasattr(ws, "_connection_params") and hasattr( + ws._connection_params, "on_new_client_request" + ): + ws._connection_params.on_new_client_request = None + + target_status = TargetStatus( + { + "id": target_id, + "currentAvailability": "Available", + "averageQueueTime": 0, + "numQubits": num_qubits, + "targetProfile": target_profile, + } + ) + provider = SimpleNamespace(id=provider_id, targets=[target_status]) + monkeypatch.setattr( + ws._client.services.providers, + "list", + lambda *args, **kwargs: _paged([provider]), + ) def _patch_upload_input_data(monkeypatch: pytest.MonkeyPatch) -> None: @@ -382,3 +426,50 @@ def test_qir_target_profile_from_deprecated_target_capability(): profile = backend._get_target_profile(input_params) assert profile == TargetProfile.Base assert "target_profile" not in input_params + + +def test_generic_qir_backend_created_for_unknown_workspace_target( + monkeypatch: pytest.MonkeyPatch, +): + _patch_upload_input_data(monkeypatch) + + ws = create_default_workspace() + _seed_workspace_target( + monkeypatch, + ws, + provider_id="acme", + target_id="acme.qpu", + num_qubits=5, + target_profile="Adaptive_RI", + ) + + provider = AzureQuantumProvider(workspace=ws) + backend = provider.get_backend("acme.qpu") + + assert isinstance(backend, AzureGenericQirBackend) + + from qsharp import TargetProfile + + assert backend.options.get("target_profile") == TargetProfile.Adaptive_RI + + # Avoid calling `backend.run()` (requires qsharp for QIR generation). + input_params = backend._get_input_params({}, shots=11) + job = backend._run( + job_name="offline-generic", + input_data=b"; QIR placeholder", + input_params=input_params, + metadata={}, + ) + + details = ws._client.services.jobs.get( + ws.subscription_id, + ws.resource_group, + ws.name, + job.id(), + ) + + assert details.provider_id == "acme" + assert details.target == "acme.qpu" + assert details.input_data_format == "qir.v1" + assert details.output_data_format == "microsoft.quantum-results.v2" + assert details.input_params["shots"] == 11