From 2f4ab23750b79c4d4cc7d2fe0826abdb6a1dfcc3 Mon Sep 17 00:00:00 2001 From: GBisi Date: Wed, 30 Apr 2025 17:36:41 +0200 Subject: [PATCH 1/4] fix(local_aer): :bug: change provider name from localaer to local_aer --- .github/workflows/docs.yaml | 4 +++- src/quantum_executor/local_aer/provider.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 46159e6..855a992 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -1,7 +1,9 @@ name: Build Docs # and Deploy on: - workflow_dispatch: # Manual trigger + push: + branches: + - main # every commit that lands on main triggers the job # permissions: # contents: write # Required to push to gh-pages branch diff --git a/src/quantum_executor/local_aer/provider.py b/src/quantum_executor/local_aer/provider.py index dd1590d..d9b663e 100644 --- a/src/quantum_executor/local_aer/provider.py +++ b/src/quantum_executor/local_aer/provider.py @@ -49,7 +49,7 @@ def _build_runtime_profile(self, backend: BackendV2, program_spec: ProgramSpec | simulator=True, num_qubits=backend.num_qubits, program_spec=program_spec, - provider_name="LocalAER", + provider_name="Local_AER", ) @cached_method # type: ignore @@ -127,5 +127,5 @@ def __hash__(self) -> int: """ if not hasattr(self, "_hash"): - object.__setattr__(self, "_hash", hash("LocalAER")) + object.__setattr__(self, "_hash", hash("Local_AER")) return int(self._hash) # pylint: disable=no-member #type: ignore[unused-ignore] From 45c2b4553ad0bb3161cbd7e5d125234a673c0fea Mon Sep 17 00:00:00 2001 From: GBisi Date: Wed, 30 Apr 2025 17:54:55 +0200 Subject: [PATCH 2/4] fix(actions): :bug: change release token to PAT --- .github/workflows/release.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1a7ac04..2051870 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -36,7 +36,7 @@ jobs: id: release uses: python-semantic-release/python-semantic-release@v9 with: - github_token: ${{ secrets.GITHUB_TOKEN }} # or a PAT if your main branch is protected + github_token: ${{ secrets.GH_PAT }} # or a PAT if your main branch is protected git_committer_name: "github-actions" git_committer_email: "actions@users.noreply.github.com" @@ -45,7 +45,7 @@ jobs: if: steps.release.outputs.released == 'true' uses: python-semantic-release/publish-action@v9 with: - github_token: ${{ secrets.GITHUB_TOKEN }} + github_token: ${{ secrets.GH_PAT }} tag: ${{ steps.release.outputs.tag }} # e.g. v1.2.3 [oai_citation:2‡Python Semantic Release](https://python-semantic-release.readthedocs.io/en/latest/automatic-releases/github-actions.html) - name: Build distributions From fffd312cd82043a17a0f85f9f5b290b93a98fa00 Mon Sep 17 00:00:00 2001 From: GBisi Date: Thu, 8 May 2025 17:06:49 +0200 Subject: [PATCH 3/4] feat(executor): :sparkles: generate dispatch first-class method --- src/quantum_executor/dispatch.py | 30 +++++++++ src/quantum_executor/executor.py | 98 +++++++++++++++++++++++---- tests/test_quantum_executor.py | 111 +++++++++++++++++++++++++++++-- 3 files changed, 219 insertions(+), 20 deletions(-) diff --git a/src/quantum_executor/dispatch.py b/src/quantum_executor/dispatch.py index 74b66f0..e3ae3f3 100644 --- a/src/quantum_executor/dispatch.py +++ b/src/quantum_executor/dispatch.py @@ -42,6 +42,22 @@ def __init__(self, circuit: Any, shots: int, configuration: dict[str, Any] | Non self.shots: int = shots self.configuration: dict[str, Any] = configuration or {} + def to_dict(self) -> dict[str, Any]: + """Return a dictionary representation of the Job. + + Returns + ------- + Dict[str, Any] + Dictionary containing job details: id, circuit, shots, and configuration. + + """ + return { + "id": self.id, + "circuit": self.circuit, + "shots": self.shots, + "configuration": self.configuration, + } + def __repr__(self) -> str: """Return a string representation of the Job. @@ -203,3 +219,17 @@ def items(self) -> dict[str, dict[str, list[Job]]]: """ return self._jobs.copy() + + def to_dict(self) -> DispatchDict: + """Return a dictionary representation of the Dispatch. + + Returns + ------- + Dict[str, Dict[str, List[Dict[str, Any]]]] + Nested dictionary mapping provider -> backend -> list of job-info dicts. + + """ + return { + provider: {backend: [job.to_dict() for job in jobs] for backend, jobs in backends.items()} + for provider, backends in self._jobs.items() + } diff --git a/src/quantum_executor/executor.py b/src/quantum_executor/executor.py index ec998cb..e640119 100644 --- a/src/quantum_executor/executor.py +++ b/src/quantum_executor/executor.py @@ -4,6 +4,7 @@ import logging import threading from collections.abc import Callable +from collections.abc import Sequence from concurrent.futures import ProcessPoolExecutor from concurrent.futures import as_completed from pathlib import Path @@ -235,10 +236,79 @@ def __init__( # pylint: disable=too-many-arguments too-many-positional-argument self._policies = load_policies_from_folder(self._policies_folder, raise_exc=self._raise_exc) logger.info("QuantumExecutor initialized.") + def generate_dispatch( # pylint: disable=too-many-positional-arguments too-many-arguments too-many-locals + self, + circuits: Any | Sequence[Any], # noqa: ANN401 + shots: int | Sequence[int], + backends: dict[str, list[str]], + split_policy: str = _default_split, + split_data: dict[str, Any] | None = None, + ) -> tuple[Dispatch, dict[str, Any]]: + """Split a circuit into jobs based on the specified split policy. + + Parameters + ---------- + circuits : Any or Sequence[Any] + Quantum circuit or list of quantum circuits. + shots : int or Sequence[int] + Number of shots or list of numbers of shots. + If a list, it must match the length of `circuits`. + If a single int, all circuits will use the same number of shots. + backends : dict[str, list[str]] + Provider → list of backends. + split_policy : str, optional + Which split policy to use. + split_data : dict, optional + Initial data for split policy. + + Returns + ------- + tuple[Dispatch, dict[str, Any]] + A Dispatch object containing the jobs and any updated split data. + """ + if isinstance(circuits, Sequence): + circuits = list(circuits) + shots_list = [shots] * len(circuits) if isinstance(shots, int) else list(shots) + + if len(shots_list) != len(circuits): + raise ValueError( + "When passing multiple circuits, shots must be a single int or a list of the same length." + ) + + split_fn = self.get_split_policy(split_policy) + aggregated: dict[str, dict[str, list[Any]]] = {} + + split_data = split_data or {} + for circ, sh in zip(circuits, shots_list, strict=False): + disp_i, updated_split_data = split_fn(circ, sh, backends, self._virtual_provider, split_data) + split_data = updated_split_data + for prov, back_map in disp_i.to_dict().items(): + agg_back_map = aggregated.setdefault(prov, {}) + for back, jobs in back_map.items(): + agg_back_map.setdefault(back, []).extend(jobs) + + return Dispatch(aggregated), split_data or {} + + if isinstance(shots, Sequence) and len(shots) > 1: + raise ValueError("When passing a single circuit, shots must be a single int, not a list.") + if isinstance(shots, Sequence): + shots = shots[0] + + # Single-circuit path + split_fn = self.get_split_policy(split_policy) + split_data = split_data or {} + return split_fn( # type: ignore[no-any-return] + circuits, + shots, + backends, + self._virtual_provider, + split_data, + ) + def run_experiment( # pylint: disable=too-many-positional-arguments too-many-arguments self, - circuit: Any, # noqa: ANN401 - shots: int, + circuits: Any | Sequence[Any], # noqa: ANN401 + shots: int | Sequence[int], backends: dict[str, list[str]], split_policy: str = _default_split, merge_policy: str | None = None, @@ -252,10 +322,12 @@ def run_experiment( # pylint: disable=too-many-positional-arguments too-many-ar Parameters ---------- - circuit : Any - Quantum circuit. - shots : int - Number of shots. + circuits : Any or Sequence[Any] + Quantum circuit or list of quantum circuits. + shots : int or Sequence[int] + Number of shots or list of numbers of shots. + If a list, it must match the length of `circuits`. + If a single int, all circuits will use the same number of shots. backends : dict[str, list[str]] Provider → list of backends. split_policy : str, optional @@ -284,14 +356,12 @@ def run_experiment( # pylint: disable=too-many-positional-arguments too-many-ar split_policy, merge_policy, ) - split_fn = self.get_split_policy(split_policy) - split_data = split_data or {} - dispatch_obj, updated_split = split_fn( - circuit, - shots, - backends, - self._virtual_provider, - split_data, + dispatch_obj, updated_split = self.generate_dispatch( + circuits=circuits, + shots=shots, + backends=backends, + split_policy=split_policy, + split_data=split_data, ) return self.run_dispatch( diff --git a/tests/test_quantum_executor.py b/tests/test_quantum_executor.py index 927da02..e7ea407 100644 --- a/tests/test_quantum_executor.py +++ b/tests/test_quantum_executor.py @@ -377,7 +377,7 @@ def test_quantum_executor_run_experiment_sync( backends = {"local_aer": ["aer_simulator"]} collector = quantum_executor.run_experiment( - circuit=qc, + circuits=qc, shots=100, backends=backends, split_policy="test_policy", @@ -411,7 +411,7 @@ def test_quantum_executor_run_experiment_missing_policy( with pytest.raises(KeyError, match="Split policy 'no_such_policy' not found"): quantum_executor.run_experiment( - circuit=qc, + circuits=qc, shots=100, backends=backends, split_policy="no_such_policy", @@ -437,7 +437,7 @@ def test_quantum_executor_run_experiment_async_wait( logging.warning("Starting quantum experiment with async execution.") collector = quantum_executor.run_experiment( - circuit=qc, + circuits=qc, shots=50, backends=backends, split_policy="test_policy", @@ -470,7 +470,7 @@ def test_quantum_executor_run_experiment_async_no_wait( backends = {"local_aer": ["aer_simulator"]} collector = quantum_executor.run_experiment( - circuit=qc, + circuits=qc, shots=25, backends=backends, split_policy="test_policy", @@ -589,7 +589,7 @@ def test_quantum_executor_run_experiment_no_backends( backends: dict[str, list[str]] = {} # Empty backends dictionary collector = quantum_executor.run_experiment( - circuit=qc, + circuits=qc, shots=10, backends=backends, split_policy="test_policy", @@ -633,7 +633,7 @@ def test_quantum_executor_run_experiment_with_fake_backends(policy_name: str) -> backends = {"local_aer": ["fake_torino", "fake_oslo"]} collector = executor.run_experiment( - circuit=qc, + circuits=qc, shots=51, backends=backends, split_policy=policy_name, @@ -812,3 +812,102 @@ def test_dispatch_init_from_dict_multiple_jobs() -> None: # Second job: provided configuration and autogenerated ID assert jobs[1].shots == 10, "Second job shots should match the provided value." assert jobs[1].configuration == {"a": 1}, "Second job configuration should match provided dict." + + +def test_generate_dispatch_single_circuit_shots_int(quantum_executor: QuantumExecutor) -> None: # pylint: disable=redefined-outer-name + """Single circuit + integer shots should produce exactly one job.""" + qc = QuantumCircuit(1, 1) + backends = {"prov": ["backend"]} + dispatch, split_data = quantum_executor.generate_dispatch( + circuits=qc, shots=10, backends=backends, split_policy="test_policy" + ) + assert isinstance(dispatch, Dispatch) + jobs = list(dispatch.all_jobs()) + assert len(jobs) == 1 + prov, back, job = jobs[0] + assert prov == "prov" + assert back == "backend" + assert job.shots == 10 + assert split_data == {} + + +def test_generate_dispatch_single_circuit_shots_list_length1(quantum_executor: QuantumExecutor) -> None: # pylint: disable=redefined-outer-name + """Single circuit + shots=[n] should be treated as shots=n.""" + qc = QuantumCircuit(1, 1) + backends = {"prov": ["backend"]} + initial_data = {"foo": "bar"} + dispatch, split_data = quantum_executor.generate_dispatch( + circuits=qc, shots=[15], backends=backends, split_policy="test_policy", split_data=initial_data + ) + jobs = list(dispatch.all_jobs()) + assert len(jobs) == 1 + assert jobs[0][2].shots == 15 + # initial split_data should be preserved + assert split_data is initial_data + + +def test_generate_dispatch_single_circuit_shots_list_error(quantum_executor: QuantumExecutor) -> None: # pylint: disable=redefined-outer-name + """Single circuit + shots list of length>1 must raise ValueError.""" + qc = QuantumCircuit(1, 1) + backends = {"prov": ["backend"]} + with pytest.raises(ValueError, match="single circuit, shots must be a single int"): + quantum_executor.generate_dispatch(circuits=qc, shots=[1, 2], backends=backends, split_policy="test_policy") + + +def test_generate_dispatch_multiple_circuits_shots_int(quantum_executor: QuantumExecutor) -> None: # pylint: disable=redefined-outer-name + """Multiple circuits + int shots should produce one job per circuit.""" + qcs = [QuantumCircuit(1, 1), QuantumCircuit(1, 1)] + backends = {"prov": ["backend"]} + dispatch, split_data = quantum_executor.generate_dispatch( + circuits=qcs, shots=20, backends=backends, split_policy="test_policy" + ) + jobs = list(dispatch.all_jobs()) + assert len(jobs) == 2 + for _, _, job in jobs: + assert job.shots == 20 + assert split_data == {} + + +def test_generate_dispatch_multiple_circuits_shots_list_matching(quantum_executor: QuantumExecutor) -> None: # pylint: disable=redefined-outer-name + """Multiple circuits + shots list of matching length should use per-circuit shots.""" + qcs = [QuantumCircuit(1, 1), QuantumCircuit(1, 1)] + backends = {"prov": ["backend"]} + initial_data = {"x": 1} + dispatch, split_data = quantum_executor.generate_dispatch( + circuits=qcs, shots=[5, 6], backends=backends, split_policy="test_policy", split_data=initial_data + ) + jobs = list(dispatch.all_jobs()) + assert len(jobs) == 2 + assert jobs[0][2].shots == 5 + assert jobs[1][2].shots == 6 + assert split_data is initial_data + + +def test_generate_dispatch_multiple_circuits_shots_list_mismatch(quantum_executor: QuantumExecutor) -> None: # pylint: disable=redefined-outer-name + """Multiple circuits + shots list of wrong length must raise ValueError.""" + qcs = [QuantumCircuit(1, 1), QuantumCircuit(1, 1)] + backends = {"prov": ["backend"]} + with pytest.raises(ValueError, match="single int or a list of the same length"): + quantum_executor.generate_dispatch(circuits=qcs, shots=[10], backends=backends, split_policy="test_policy") + + +def test_generate_dispatch_no_backends(quantum_executor: QuantumExecutor) -> None: # pylint: disable=redefined-outer-name + """Empty backends dict should yield zero jobs and empty split_data.""" + qc = QuantumCircuit(1, 1) + dispatch, split_data = quantum_executor.generate_dispatch( + circuits=qc, shots=10, backends={}, split_policy="test_policy" + ) + assert isinstance(dispatch, Dispatch) + assert not list(dispatch.all_jobs()) + assert split_data == {} + + +def test_generate_dispatch_preserves_initial_split_data(quantum_executor: QuantumExecutor) -> None: # pylint: disable=redefined-outer-name + """Whatever split_data you pass in should be returned unmodified.""" + qc = QuantumCircuit(1, 1) + backends = {"prov": ["backend"]} + initial_data = {"keep": True} + _, split_data = quantum_executor.generate_dispatch( + circuits=qc, shots=10, backends=backends, split_policy="test_policy", split_data=initial_data + ) + assert split_data is initial_data From 78c80d31537f92023f86f5c6cd3f1084d22e23ba Mon Sep 17 00:00:00 2001 From: GBisi Date: Fri, 23 May 2025 16:06:01 +0200 Subject: [PATCH 4/4] docs: :bug: updated readthedocs configuration --- .github/workflows/docs.yaml | 2 -- readthedocs.yml | 8 ++++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 855a992..6ee7cac 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -2,8 +2,6 @@ name: Build Docs # and Deploy on: push: - branches: - - main # every commit that lands on main triggers the job # permissions: # contents: write # Required to push to gh-pages branch diff --git a/readthedocs.yml b/readthedocs.yml index a975f88..776496c 100644 --- a/readthedocs.yml +++ b/readthedocs.yml @@ -1,4 +1,3 @@ -# readthedocs.yml version: 2 build: @@ -6,9 +5,10 @@ build: tools: python: "3.12" jobs: - pre_build: - - pip install poetry - - poetry install --with dev --with docs + build: + commands: + - pip install poetry + - poetry install sphinx: configuration: docs/conf.py