Repository: rigetti/qcs-sdk-rust (language: python)

Issue:
Export rust QCS ClientConfiguration to python
From https://github.com/rigetti/qcs-sdk-rust/pull/230/files#r1068297511

As a `pyquil` user, I want to be able to provide a custom QCS client configuration when making API calls.

Since [`qcs-api-client`](https://pypi.org/project/qcs-api-client/) is being removed from pyquil as part of https://github.com/rigetti/pyquil/issues/1485, we'll need to replace the python [`QCSClientConfiguration`](https://github.com/rigetti/qcs-api-client-python/blob/main/qcs_api_client/client/_configuration/configuration.py#L25) functionality with the rust [`ClientConfiguration`](https://docs.rs/qcs-api-client-common/latest/qcs_api_client_common/configuration/struct.ClientConfiguration.html).

So far in [qcs-sdk-rust/pull/230](https://github.com/rigetti/qcs-sdk-rust/pull/230/files#r1068297511) there is only support for the environmentally-bootstrapped configuration.

---

<details>
<summary>previous suggestion, no longer relevant</summary>

Since `QCSClientConfiguration` is an object with getters, I'm thinking the solution looks like:
- Convert the object into a `dict`
- Pass it to rust as a `PyDict`
- Construct [`ClientConfiguration`](https://github.com/rigetti/qcs-api-client-rust/blob/main/qcs-api-client-common/src/configuration/mod.rs#L73) as closely as possible (likely requires changes to the crate)

</details>


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 7da525d7d..27067dc22 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,5 +19,6 @@ qcs-api/docs
 # macOS
 .DS_Store
 
-# pytest artifacts
+# Python artifacts
 **/__pycache__
+crates/python/qcs_sdk/*.so
diff --git a/Cargo.lock b/Cargo.lock
index e3cac3999..35e442965 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1344,9 +1344,9 @@ dependencies = [
 
 [[package]]
 name = "qcs-api-client-common"
-version = "0.2.7"
+version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a067d003dbf106c4a3ec821ac99bf41e508bddbba760e66e4124ea41619209b5"
+checksum = "13b09ac85ca2a9839e81d72c2cd8c7a0995984e52d5f78c0d0d6ad00da075589"
 dependencies = [
  "dirs",
  "futures",
@@ -1360,9 +1360,9 @@ dependencies = [
 
 [[package]]
 name = "qcs-api-client-grpc"
-version = "0.2.7"
+version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a77d9d4329f3d8a051417f96a69326965dadb21b10de83cd8c94018d4917b2c8"
+checksum = "623a691262ebdb46b72d1797682e2cc5fba1c859cacc17d58a2b59c9efe9ac77"
 dependencies = [
  "http-body",
  "pbjson",
@@ -1379,9 +1379,9 @@ dependencies = [
 
 [[package]]
 name = "qcs-api-client-openapi"
-version = "0.3.8"
+version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "de8ce34a5f6d100626d19f28219a2c791f11d7809a36521979c3817dfaac8f3a"
+checksum = "8a99306535343c8d28a013fe7489a9c52c7aeb4d64029f98444f44dadacc8334"
 dependencies = [
  "qcs-api-client-common",
  "reqwest",
@@ -1551,9 +1551,9 @@ dependencies = [
 
 [[package]]
 name = "rigetti-pyo3"
-version = "0.1.0-rc.0"
+version = "0.1.0-rc.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "298f878d681dbe7bea9652f48816534c16f165c259b2b19d1dc5a4f00f929ad6"
+checksum = "17d3d8822e4d3ab36de8cff3b8d9990a9d2ccc9c9da3536de9347e29353b2455"
 dependencies = [
  "num-complex",
  "num-traits",
diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml
index 6407713c1..2d67510af 100644
--- a/crates/lib/Cargo.toml
+++ b/crates/lib/Cargo.toml
@@ -21,9 +21,9 @@ lazy_static = "1.4.0"
 log = "0.4.17"
 num = { version = "0.4.0", features = ["serde"] }
 qcs-api = "0.2.1"
-qcs-api-client-common = "0.2.7"
-qcs-api-client-openapi = "0.3.8"
-qcs-api-client-grpc = "0.2.7"
+qcs-api-client-common = "0.3.0"
+qcs-api-client-openapi = "0.4.0"
+qcs-api-client-grpc = "0.3.0"
 quil-rs = "0.15"
 reqwest = { version = "0.11.12", default-features = false, features = ["rustls-tls", "json"] }
 rmp-serde = "1.1.1"
@@ -42,7 +42,7 @@ erased-serde = "0.3.23"
 float-cmp = "0.9.0"
 hex = "0.4.3"
 maplit = "1.0.2"
-qcs-api-client-grpc = { version = "0.2.7", features = ["server"] }
+qcs-api-client-grpc = { version = "0.3.0", features = ["server"] }
 simple_logger = { version = "2.3.0", default-features = false }
 tempfile = "3.3.0"
 tokio = { version = "1.21.2", features = ["macros", "rt-multi-thread"] }
diff --git a/crates/lib/src/api.rs b/crates/lib/src/api.rs
index 85186bf14..7da21c3b6 100644
--- a/crates/lib/src/api.rs
+++ b/crates/lib/src/api.rs
@@ -1,7 +1,7 @@
 //! This module provides convenience functions to handle compilation,
 //! translation, parameter arithmetic rewriting, and results collection.
 
-use std::{collections::HashMap, str::FromStr};
+use std::{collections::HashMap, str::FromStr, time::Duration};
 
 use num::Complex;
 use qcs_api_client_grpc::{
@@ -10,9 +10,11 @@ use qcs_api_client_grpc::{
         get_controller_job_results_request::Target, GetControllerJobResultsRequest,
     },
 };
+use qcs_api_client_openapi::apis::{quantum_processors_api, Error as OpenAPIError};
 use quil_rs::expression::Expression;
 use quil_rs::{program::ProgramError, Program};
 use serde::Serialize;
+use tokio::time::error::Elapsed;
 
 use crate::qpu::{
     self,
@@ -24,6 +26,10 @@ use crate::qpu::{
     IsaError,
 };
 
+/// TODO: make configurable at the client level.
+/// <https://github.com/rigetti/qcs-sdk-rust/issues/239>
+static DEFAULT_HTTP_API_TIMEOUT: Duration = Duration::from_secs(10);
+
 /// Uses quilc to convert a Quil program to native Quil
 pub fn compile(
     quil: &str,
@@ -313,3 +319,53 @@ pub async fn retrieve_results(
         .map(ExecutionResults::from)
         .ok_or_else(|| GrpcClientError::ResponseEmpty("Controller Job Execution Results".into()))
 }
+
+/// API Errors encountered when trying to list available quantum processors.
+#[derive(Debug, thiserror::Error)]
+pub enum ListQuantumProcessorsError {
+    /// Failed the http call
+    #[error("Failed to list processors via API: {0}")]
+    ApiError(#[from] OpenAPIError<quantum_processors_api::ListQuantumProcessorsError>),
+
+    /// Pagination did not finish before timeout
+    #[error("API pagination did not finish before timeout: {0:?}")]
+    TimeoutError(#[from] Elapsed),
+}
+
+/// Query the QCS API for the names of all available quantum processors.
+/// If `None`, the default `timeout` used is 10 seconds.
+pub async fn list_quantum_processors(
+    client: &Qcs,
+    timeout: Option<Duration>,
+) -> Result<Vec<String>, ListQuantumProcessorsError> {
+    let timeout = timeout.unwrap_or(DEFAULT_HTTP_API_TIMEOUT);
+
+    tokio::time::timeout(timeout, async move {
+        let mut quantum_processors = vec![];
+        let mut page_token = None;
+
+        loop {
+            let result = quantum_processors_api::list_quantum_processors(
+                &client.get_openapi_client(),
+                Some(100),
+                page_token.as_deref(),
+            )
+            .await?;
+
+            let mut data = result
+                .quantum_processors
+                .into_iter()
+                .map(|qpu| qpu.id)
+                .collect::<Vec<_>>();
+            quantum_processors.append(&mut data);
+
+            page_token = result.next_page_token;
+            if page_token.is_none() {
+                break;
+            }
+        }
+
+        Ok(quantum_processors)
+    })
+    .await?
+}
diff --git a/crates/lib/src/executable.rs b/crates/lib/src/executable.rs
index 489a6187d..78ea5c35a 100644
--- a/crates/lib/src/executable.rs
+++ b/crates/lib/src/executable.rs
@@ -313,7 +313,7 @@ impl Executable<'_, '_> {
         if let Some(config) = &self.config {
             Ok(config.clone())
         } else {
-            let config = ClientConfiguration::load().await?;
+            let config = ClientConfiguration::load_default().await?;
             self.config = Some(config.clone());
             Ok(config)
         }
diff --git a/crates/lib/src/qpu/client.rs b/crates/lib/src/qpu/client.rs
index 6cc8f6a2f..0b00f93bc 100644
--- a/crates/lib/src/qpu/client.rs
+++ b/crates/lib/src/qpu/client.rs
@@ -36,7 +36,9 @@ pub struct Qcs {
 impl Qcs {
     /// Create a [`Qcs`] and initialize it with the user's default [`ClientConfiguration`]
     pub async fn load() -> Result<Self, LoadError> {
-        ClientConfiguration::load().await.map(Self::with_config)
+        ClientConfiguration::load_default()
+            .await
+            .map(Self::with_config)
     }
 
     /// Create a [`Qcs`] and initialize it with the given [`ClientConfiguration`]
diff --git a/crates/lib/src/qpu/quilc/isa/edge.rs b/crates/lib/src/qpu/quilc/isa/edge.rs
index 98ff1a243..57eff9493 100644
--- a/crates/lib/src/qpu/quilc/isa/edge.rs
+++ b/crates/lib/src/qpu/quilc/isa/edge.rs
@@ -35,9 +35,9 @@ impl Edge {
         }
     }
 
-    pub(crate) fn add_operation<'op_name>(
+    pub(crate) fn add_operation(
         &mut self,
-        op_name: &'op_name str,
+        op_name: &str,
         characteristics: &[Characteristic],
     ) -> Result<(), Error> {
         let operator = match GATE_PARAMS.get_key_value(op_name) {
diff --git a/crates/python/Cargo.toml b/crates/python/Cargo.toml
index fca67cc36..b3a9a5221 100644
--- a/crates/python/Cargo.toml
+++ b/crates/python/Cargo.toml
@@ -17,14 +17,14 @@ crate-type = ["cdylib"]
 
 [dependencies]
 qcs = { path = "../lib" }
-qcs-api-client-common = "0.2.7"
-qcs-api-client-grpc = "0.2.7"
+qcs-api-client-common = "0.3.0"
+qcs-api-client-grpc = "0.3.0"
 pyo3 = { version = "0.17", features = ["extension-module"] }
 pyo3-asyncio = { version = "0.17", features = ["tokio-runtime"] }
 quil-rs = "0.15"
 tokio = "1.21"
 qcs-api = "0.2.1"
-rigetti-pyo3 = { version = "0.1.0-rc.0", features = ["extension-module", "complex"] }
+rigetti-pyo3 = { version = "0.1.0-rc.1", features = ["extension-module", "complex"] }
 serde_json = "1.0.86"
 
 [build-dependencies]
diff --git a/crates/python/qcs_sdk.pyi b/crates/python/qcs_sdk.pyi
deleted file mode 100644
index dcb977dc5..000000000
--- a/crates/python/qcs_sdk.pyi
+++ /dev/null
@@ -1,166 +0,0 @@
-"""
-The qcs_sdk module provides an interface to Rigetti Quantum Cloud Services. Allowing users to compile and run Quil programs on Rigetti quantum processors.
-"""
-from typing import Any, Dict, List, Optional, TypedDict
-from numbers import Number
-
-RecalculationTable = List[str]
-Memory = Dict[str, List[float]]
-PatchValues = Dict[str, List[float]]
-
-class RewriteArithmeticResults(TypedDict):
-    program: str
-    """
-    The resulting program where gate parameter arithmetic has been replaced with memory references. Before execution, the program memory should be updated using the `recalculation_table`.
-    """
-
-    recalculation_table: RecalculationTable
-    """ 
-    The recalculation table stores an ordered list of arithmetic expressions, which are to be used when updating the program memory before execution.
-    """
-
-class TranslationResult(TypedDict):
-    memory_descriptors: Optional[Dict[str, Any]]
-    """
-    A map from the name of memory (declared with `DECLARE`) to the size and type of that memory.
-    """
-
-    program: str
-    """
-    The compiled program binary.
-    """
-
-    ro_sources: Optional[List[List[str]]]
-    """
-    A mapping from the program's memory references to the key used to index the results map.
-    """
-
-    settings_timestamp: Optional[str]
-    """
-    The timestamp of the settings used during translation.
-    """
-
-class ExecutionResult(TypedDict):
-    shape: List[int]
-    """
-    The shape of the result data.
-    """
-
-    data: List[Number | List[float]]
-    """
-    The result data. Complex numbers are represented as [real, imaginary].
-    """
-
-    dtype: str
-    """
-    The type of the result data (as a `numpy` `dtype`).
-    """
-
-class ExecutionResults(TypedDict):
-    buffers: Dict[str, ExecutionResult]
-    """
-    The readout results of execution, mapping a published filter node to its data.
-
-    See `TranslationResult.ro_sources` which provides the mapping from the filter node name to the name of the memory declaration in the source program.
-    """
-
-    execution_duration_microseconds: Optional[int]
-    """
-    The time spent executing the program.
-    """
-
-async def compile(quil: str, target_device: str, *, timeout: int = 30) -> str:
-    """
-    Uses quilc to convert a quil program to native Quil.
-
-    Args:
-        quil: A Quil program.
-        target_device: A JSON encoded description of the Quantum Abstract Machine Architecture.
-
-    Keyword Args:
-        timeout: The number of seconds to wait before timing out. If set to None, there is no timeout (default: 30).
-
-    Returns:
-        An Awaitable that resolves to the native Quil program.
-    """
-    ...
-
-def rewrite_arithmetic(native_quil: str) -> RewriteArithmeticResults:
-    """
-    Rewrites parametric arithmetic such that all gate parameters are only memory references
-    to a newly declared memory location (__SUBST).
-
-    Args:
-        native_quil: A Quil program.
-
-    Returns:
-        A dictionary with the rewritten program and recalculation table (see `RewriteArithmeticResults`).
-    """
-    ...
-
-def build_patch_values(
-    recalculation_table: RecalculationTable, memory: Memory
-) -> PatchValues:
-    """
-    Evaluate the expressions in recalculation_table using the numeric values
-    provided in memory.
-
-    Args:
-        recalculation_table: an ordered list of arithmetic expressions, which are to be used when updating the program memory before execution (see `rewrite_arithmetic`).
-        memory: A mapping of symbols to their values (e.g. `{"theta": [0.0]}`).
-
-    Returns:
-        A dictionary that maps each symbol to the value it should be patched with.
-    """
-    ...
-
-async def translate(
-    native_quil: str, num_shots: int, quantum_processor_id: str
-) -> TranslationResult:
-    """
-    Translates a native Quil program into an executable.
-
-    Args:
-        native_quil: A Quil program.
-        num_shots: The number of shots to perform.
-        quantum_processor_id: The ID of the quantum processor the executable will run on (e.g. "Aspen-M-2").
-
-    Returns:
-        An Awaitable that resolves to a dictionary with the compiled program, memory descriptors, and readout sources (see `TranslationResult`).
-    """
-    ...
-
-async def submit(
-    program: str, patch_values: Dict[str, List[float]], quantum_processor_id: str
-) -> str:
-    """
-    Submits an executable `program` to be run on the specified QPU.
-
-    Args:
-        program: An executable program (see `translate`).
-        patch_values: A mapping of symbols to their desired values (see `build_patch_values`).
-        quantum_processor_id: The ID of the quantum processor to run the executable on.
-
-    Returns:
-        An Awaitable that resolves to the ID of the submitted job.
-    """
-    ...
-
-async def retrieve_results(job_id: str, quantum_processor_id: str) -> ExecutionResults:
-    """
-    Fetches results for the corresponding job ID.
-
-    Args:
-        job_id: The ID of the job to retrieve results for.
-        quantum_processor_id: The ID of the quanutum processor the job ran on.
-
-    Returns:
-        An Awaitable that resolves to a dictionary describing the results of the execution and its duration (see `ExecutionResults`).
-    """
-    ...
-
-async def get_quilc_version() -> str:
-    """
-    Returns the version number of the running quilc server.
-    """
-    ...
diff --git a/crates/python/qcs_sdk/__init__.py b/crates/python/qcs_sdk/__init__.py
new file mode 100644
index 000000000..e0d6434d7
--- /dev/null
+++ b/crates/python/qcs_sdk/__init__.py
@@ -0,0 +1,4 @@
+# See the following documentation for why this file is necessary:
+# https://pyo3.rs/v0.18.0/python_typing_hints#__init__py-content
+
+from .qcs_sdk import *
diff --git a/crates/python/qcs_sdk/__init__.pyi b/crates/python/qcs_sdk/__init__.pyi
new file mode 100644
index 000000000..279ad5cb6
--- /dev/null
+++ b/crates/python/qcs_sdk/__init__.pyi
@@ -0,0 +1,23 @@
+from .api import *
+
+from .qpu.client import (
+    QcsClient as QcsClient
+)
+
+from ._execution_data import (
+    QPU as QPU,
+    QVM as QVM,
+    ReadoutMap as ReadoutMap,
+)
+
+from ._executable import (
+    Executable as Executable,
+    ExeParameter as ExeParameter,
+    JobHandle as JobHandle,
+    QcsExecutionError as QcsExecutionError,
+    Service as Service,
+)
+
+from ._register_data import (
+    RegisterData as RegisterData,
+)
diff --git a/crates/python/qcs_sdk/_executable.pyi b/crates/python/qcs_sdk/_executable.pyi
new file mode 100644
index 000000000..2ec80827d
--- /dev/null
+++ b/crates/python/qcs_sdk/_executable.pyi
@@ -0,0 +1,109 @@
+"""
+Do not import this file, it has no exports.
+It is only here to represent the structure of the rust source code 1:1
+"""
+from enum import Enum
+
+from typing import Dict, List, Optional
+from .qpu.quilc import CompilerOpts
+from ._execution_data import QVM, QPU
+
+class QcsExecutionError(RuntimeError):
+    """Error encounteted when executing programs."""
+    ...
+
+
+class Executable:
+    def __new__(
+        cls,
+        registers: Optional[List[str]] = None,
+        parameters: Optional[List[ExeParameter]] = None,
+        shots: Optional[int] = None,
+        compile_with_quilc: Optional[bool] = None,
+        compiler_options: Optional[CompilerOpts] = None
+    ) -> "Executable": ...
+
+    async def execute_on_qvm(self) -> QVM:
+        """
+        Execute on a QVM which must be available at the configured URL (default http://localhost:5000).
+
+        Raises:
+            - ``QcsExecutionError``: If the job fails to execute.
+        """
+        ...
+
+    async def execute_on_qpu(self, quantum_processor_id: str) -> QPU:
+        """
+        Compile the program and execute it on a QPU, waiting for results.
+
+        Raises:
+            - ``QcsExecutionError``: If the job fails to execute.
+        """
+        ...
+    
+    async def retrieve_results(job_handle: JobHandle) -> QPU:
+        """
+        Wait for the results of a job to complete.
+
+        Raises:
+            - ``QcsExecutionError``: If there is a problem constructing job results.
+        """
+        ...
+
+
+class JobHandle:
+    """
+    The result of submitting a job to a QPU.
+    
+    Used to retrieve the results of a job.
+    """
+
+    @property
+    def job_id(self) -> str:
+        """
+        Unique ID associated with a single job execution.
+        """
+        ...
+
+    @property
+    def readout_map(self) -> Dict[str, str]:
+        """
+        The readout map from source readout memory locations to the filter pipeline node which publishes the data.
+        """
+        ...
+
+
+class ExeParameter:
+    """
+    Program execution parameters.
+
+    Note: The validity of parameters is not checked until execution.
+    """
+    def __new__(
+        cls: type["ExeParameter"],
+        name: str,
+        index: int,
+        value: float,
+    ) -> "ExeParameter": ...
+
+    @property
+    def name(self) -> str: ...
+    @name.setter
+    def name(self, value: str): ...
+
+    @property
+    def index(self) -> int: ...
+    @index.setter
+    def index(self, value: int): ...
+
+    @property
+    def value(self) -> float: ...
+    @value.setter
+    def value(self, value: float): ...
+
+
+class Service(Enum):
+    Quilc = "Quilc",
+    Qvm = "Qvm",
+    Qcs = "Qcs",
+    Qpu = "Qpu",
diff --git a/crates/python/qcs_sdk/_execution_data.pyi b/crates/python/qcs_sdk/_execution_data.pyi
new file mode 100644
index 000000000..d64ae413f
--- /dev/null
+++ b/crates/python/qcs_sdk/_execution_data.pyi
@@ -0,0 +1,72 @@
+"""
+Do not import this file, it has no exports.
+It is only here to represent the structure of the rust source code 1:1
+"""
+
+import datetime
+from typing import List, Optional
+
+class _IntegerReadoutValues:
+    @property
+    def values(self) -> List[int]: ...
+    @values.setter
+    def values(self, value: List[int]): ...
+
+
+class _ComplexReadoutValues:
+    @property
+    def values(self) -> List[complex]: ...
+    @values.setter
+    def values(self, value: List[complex]): ...
+
+
+class _ReadoutValuesValues:
+    @property
+    def integer_values(self) -> _IntegerReadoutValues: ...
+    @integer_values.setter
+    def integer_values(self, value: _IntegerReadoutValues): ...
+
+    @property
+    def complex_values(self) -> _ComplexReadoutValues: ...
+    @complex_values.setter
+    def complex_values(self, value: _ComplexReadoutValues): ...
+
+
+class _ReadoutValues:
+    @property
+    def values(self) -> Optional[_ReadoutValuesValues]: ...
+    @values.setter
+    def values(self, value: Optional[_ReadoutValuesValues]): ...
+
+
+class ReadoutMap:
+    def get_readout_values(self, field: str, index: int) -> Optional[_ReadoutValues]:
+        """Given a known readout field name and index, return the result's ``ReadoutValues``, if any."""
+        ...
+    
+    def get_readout_values_for_field(self, field: str) -> Optional[List[Optional[_ReadoutValues]]]:
+        """Given a known readout field name, return the result's ``ReadoutValues`` for all indices, if any."""
+        ...
+
+class QVM:
+    @property
+    def registers(self) -> dict: ...
+    @registers.setter
+    def registers(self, value: dict): ...
+
+    @property
+    def duration(self) -> Optional[datetime.timedelta]: ...
+    @duration.setter
+    def duration(self, value: Optional[datetime.timedelta]): ...
+
+
+class QPU:
+    @property
+    def readout_data(self) -> ReadoutMap: ...
+    @readout_data.setter
+    def readout_data(self, value: ReadoutMap): ...
+
+    @property
+    def duration(self) -> Optional[datetime.timedelta]: ...
+    @duration.setter
+    def duration(self, value: Optional[datetime.timedelta]): ...
diff --git a/crates/python/qcs_sdk/_register_data.pyi b/crates/python/qcs_sdk/_register_data.pyi
new file mode 100644
index 000000000..3e7bd7313
--- /dev/null
+++ b/crates/python/qcs_sdk/_register_data.pyi
@@ -0,0 +1,49 @@
+"""
+Do not import this file, it has no exports.
+It is only here to represent the structure of the rust source code 1:1
+"""
+
+from typing import List, Optional
+
+
+class RegisterData:
+    """
+    Values present in a register that are one of a set of variants.
+
+    Variants:
+        - ``i8``: Corresponds to the Quil `BIT` or `OCTET` types.
+        - ``i16``: Corresponds to the Quil `INTEGER` type.
+        - ``f64``: Corresponds to the Quil `REAL` type.
+        - ``complex32``: Results containing complex numbers.
+
+    Methods (each per variant):
+        - ``is_*``: if the underlying values are that type.
+        - ``as_*``: if the underlying values are that type, then those values, otherwise ``None``.
+        - ``to_*``: the underlying values as that type, raises ``ValueError`` if they are not.
+        - ``from_*``: wrap underlying values as this enum type.
+
+    """
+    
+    def is_i8(self) -> bool: ...
+    def is_i16(self) -> bool: ...
+    def is_f64(self) -> bool: ...
+    def is_complex32(self) -> bool: ...
+
+    def as_i8(self) -> Optional[List[List[int]]]: ...
+    def as_i16(self) -> Optional[List[List[int]]]: ...
+    def as_f64(self) -> Optional[List[List[float]]]: ...
+    def as_complex32(self) -> Optional[List[List[complex]]]: ...
+
+    def to_i8(self) -> List[List[int]]: ...
+    def to_i16(self) -> List[List[int]]: ...
+    def to_f64(self) -> List[List[float]]: ...
+    def to_complex32(self) -> List[List[complex]]: ...
+
+    @staticmethod
+    def from_i8(inner: List[List[int]]) -> "RegisterData": ...
+    @staticmethod
+    def from_i16(inner: List[List[int]]) -> "RegisterData": ...
+    @staticmethod
+    def from_f64(inner: List[List[float]]) -> "RegisterData": ...
+    @staticmethod
+    def from_complex32(inner: List[List[complex]]) -> "RegisterData": ...
diff --git a/crates/python/qcs_sdk/api.pyi b/crates/python/qcs_sdk/api.pyi
new file mode 100644
index 000000000..802806990
--- /dev/null
+++ b/crates/python/qcs_sdk/api.pyi
@@ -0,0 +1,367 @@
+"""
+The qcs_sdk module provides an interface to Rigetti Quantum Cloud Services. Allowing users to compile and run Quil programs on Rigetti quantum processors.
+"""
+from typing import  Dict, List, Optional
+from numbers import Number
+
+from .qpu.client import QcsClient
+
+RecalculationTable = List[str]
+Memory = Dict[str, List[float]]
+PatchValues = Dict[str, List[float]]
+
+class ExecutionError(RuntimeError):
+    """Error encountered during program execution submission or when retrieving results."""
+    ...
+
+
+class TranslationError(RuntimeError):
+    """Error encountered during program translation."""
+    ...
+
+
+class CompilationError(RuntimeError):
+    """Error encountered during program compilation."""
+    ...
+
+
+class RewriteArithmeticError(RuntimeError):
+    """Error encountered rewriting arithmetic for program."""
+    ...
+
+
+class DeviceIsaError(ValueError):
+    """Error while building Instruction Set Architecture."""
+    ...
+
+
+class RewriteArithmeticResults:
+    """
+    The result of a call to [`rewrite_arithmetic`] which provides the information necessary to later patch-in memory values to a compiled program.
+    """
+
+    @property
+    def program(self) -> str:
+        """
+        The resulting program where gate parameter arithmetic has been replaced with memory references. Before execution, the program memory should be updated using the `recalculation_table`.
+        """
+        ...
+    @program.setter
+    def program(self, value: str): ...
+
+    @property
+    def recalculation_table(self) -> List[str]:
+        """ 
+        The recalculation table stores an ordered list of arithmetic expressions, which are to be used when updating the program memory before execution.
+        """
+        ...
+    @recalculation_table.setter
+    def recalculation_table(self, value: List[str]): ...
+
+
+class TranslationResult:
+    """
+    The result of a call to [`translate`] which provides information about the translated program.
+    """
+
+    @property
+    def program(self) -> str:
+        """
+        The compiled program binary.
+        """
+        ...
+    @program.setter
+    def program(self, value: str): ...
+
+    @property
+    def ro_sources(self) -> Optional[dict]:
+        """
+        A mapping from the program's memory references to the key used to index the results map.
+        """
+        ...
+    @ro_sources.setter
+    def ro_sources(self, value: Optional[dict]): ...
+
+
+class ExecutionResult:
+    """Execution readout data from a particular memory location."""
+
+    @property
+    def shape(self) -> List[int]:
+        """The shape of the result data."""
+        ...
+    @shape.setter
+    def shape(self, value: List[int]): ...
+    
+    @property
+    def data(self) -> List[Number | List[float]]:
+        """The result data. Complex numbers are represented as [real, imaginary]."""
+        ...
+    @data.setter
+    def data(self, value: List[Number | List[float]]): ...
+
+    @property
+    def dtype(self) -> str:
+        """The type of the result data (as a `numpy` `dtype`)."""
+        ...
+    @dtype.setter
+    def dtype(self, value: str): ...
+
+
+class ExecutionResults:
+    """Execution readout data for all memory locations."""
+
+    @property
+    def buffers(self) -> Dict[str, ExecutionResult]:
+        """
+        The readout results of execution, mapping a published filter node to its data.
+
+        See `TranslationResult.ro_sources` which provides the mapping from the filter node name to the name of the memory declaration in the source program.
+        """
+        ...
+    @buffers.setter
+    def buffers(self, value: Dict[str, ExecutionResult]): ...
+    
+    @property
+    def execution_duration_microseconds(self) -> Optional[int]:
+        """The time spent executing the program."""
+        ...
+    @execution_duration_microseconds.setter
+    def execution_duration_microseconds(self, value: Optional[int]): ...
+
+
+class Register:
+    """
+    Data from an individual register.
+
+    Variants:
+        - ``i8``: A register of 8-bit integers.
+        - ``i16``: A register of 16-bit integers.
+        - ``i32``: A register of 32-bit integers.
+        - ``f64``: A register of 64-bit floating point numbers.
+        - ``complex64``: A register of 64-bit complex numbers.
+
+    Methods (each per variant):
+        - ``is_*``: if the underlying values are that type.
+        - ``as_*``: if the underlying values are that type, then those values, otherwise ``None``.
+        - ``to_*``: the underlyting values as that type, raises ``ValueError`` if they are not.
+        - ``from_*``: wrap underlying values as this enum type.
+
+    """
+    
+    def is_i8(self) -> bool: ...
+    def is_i16(self) -> bool: ...
+    def is_i32(self) -> bool: ...
+    def is_f64(self) -> bool: ...
+    def is_complex64(self) -> bool: ...
+
+    def as_i8(self) -> Optional[List[int]]: ...
+    def as_i16(self) -> Optional[List[int]]: ...
+    def as_i32(self) -> Optional[List[int]]: ...
+    def as_f64(self) -> Optional[List[float]]: ...
+    def as_complex64(self) -> Optional[List[complex]]: ...
+
+    def to_i8(self) -> List[int]: ...
+    def to_i16(self) -> List[int]: ...
+    def to_i32(self) -> List[int]: ...
+    def to_f64(self) -> List[float]: ...
+    def to_complex64(self) -> List[complex]: ...
+
+    @staticmethod
+    def from_i8(inner: List[int]) -> "Register": ...
+    @staticmethod
+    def from_i16(inner: List[int]) -> "Register": ...
+    @staticmethod
+    def from_i32(inner: List[int]) -> "Register": ...
+    @staticmethod
+    def from_f64(inner: List[float]) -> "Register": ...
+    @staticmethod
+    def from_complex64(inner: List[complex]) -> "Register": ...
+
+
+async def compile(
+    quil: str,
+    target_device: str,
+    client: Optional[QcsClient] = None,
+    *,
+    timeout: int = 30,
+) -> str:
+    """
+    Uses quilc to convert a quil program to native Quil.
+
+    Args:
+        quil: A Quil program.
+        target_device: A JSON encoded description of the Quantum Abstract Machine Architecture.
+        client: The QcsClient to use. Loads one using environment configuration if unset - see https://docs.rigetti.com/qcs/references/qcs-client-configuration
+
+    Keyword Args:
+        timeout: The number of seconds to wait before timing out. If set to None, there is no timeout (default: 30).
+
+    Returns:
+        An Awaitable that resolves to the native Quil program.
+
+    Raises:
+        - ``LoadError`` If there is an issue loading the QCS Client configuration.
+        - ``DeviceIsaError`` If the `target_device` is misconfigured.
+        - ``CompilationError`` If the program could not compile.
+    """
+    ...
+
+
+def rewrite_arithmetic(
+    native_quil: str,
+) -> RewriteArithmeticResults:
+    """
+    Rewrites parametric arithmetic such that all gate parameters are only memory references
+    to a newly declared memory location (__SUBST).
+
+    Args:
+        native_quil: A Quil program.
+
+    Returns:
+        A dictionary with the rewritten program and recalculation table (see `RewriteArithmeticResults`).
+    
+    Raises:
+        - ``TranslationError`` If the program could not be translated.
+        - ``RewriteArithmeticError`` If the program arithmetic cannot be evaluated.
+    """
+    ...
+
+
+def build_patch_values(
+    recalculation_table: RecalculationTable,
+    memory: Memory,
+) -> PatchValues:
+    """
+    Evaluate the expressions in recalculation_table using the numeric values
+    provided in memory.
+
+    Args:
+        recalculation_table: an ordered list of arithmetic expressions, which are to be used when updating the program memory before execution (see `rewrite_arithmetic`).
+        memory: A mapping of symbols to their values (e.g. `{"theta": [0.0]}`).
+
+    Returns:
+        A dictionary that maps each symbol to the value it should be patched with.
+
+    Raises:
+        - ``TranslationError`` If the expressions in `recalculation_table` could not be evaluated.
+    """
+    ...
+
+
+async def translate(
+    native_quil: str,
+    num_shots: int,
+    quantum_processor_id: str,
+    client: Optional[QcsClient] = None,
+) -> TranslationResult:
+    """
+    Translates a native Quil program into an executable.
+
+    Args:
+        native_quil: A Quil program.
+        num_shots: The number of shots to perform.
+        quantum_processor_id: The ID of the quantum processor the executable will run on (e.g. "Aspen-M-2").
+        client: The QcsClient to use. Loads one using environment configuration if unset - see https://docs.rigetti.com/qcs/references/qcs-client-configuration
+
+    Returns:
+        An Awaitable that resolves to a dictionary with the compiled program, memory descriptors, and readout sources (see `TranslationResult`).
+    
+    Raises:
+        - ``LoadError`` If there is an issue loading the QCS Client configuration.
+        - ``TranslationError`` If the `native_quil` program could not be translated.
+    """
+    ...
+
+
+async def submit(
+    program: str,
+    patch_values: Dict[str, List[float]],
+    quantum_processor_id: str,
+    client: Optional[QcsClient] = None,
+) -> str:
+    """
+    Submits an executable `program` to be run on the specified QPU.
+
+    Args:
+        program: An executable program (see `translate`).
+        patch_values: A mapping of symbols to their desired values (see `build_patch_values`).
+        quantum_processor_id: The ID of the quantum processor to run the executable on.
+        client: The QcsClient to use. Loads one using environment configuration if unset - see https://docs.rigetti.com/qcs/references/qcs-client-configuration
+
+    Returns:
+        An Awaitable that resolves to the ID of the submitted job.
+    
+    Raises:
+        - ``LoadError`` If there is an issue loading the QCS Client configuration.
+        - ``ExecutionError`` If there was a problem during program execution.
+    """
+    ...
+
+
+async def retrieve_results(
+    job_id: str,
+    quantum_processor_id: str,
+    client: Optional[QcsClient] = None,
+) -> ExecutionResults:
+    """
+    Fetches results for the corresponding job ID.
+
+    Args:
+        job_id: The ID of the job to retrieve results for.
+        quantum_processor_id: The ID of the quanutum processor the job ran on.
+        client: The QcsClient to use. Loads one using environment configuration if unset - see https://docs.rigetti.com/qcs/references/qcs-client-configuration
+
+    Returns:
+        An Awaitable that resolves to a dictionary describing the results of the execution and its duration (see `ExecutionResults`).
+
+    Raises:
+        - ``LoadError`` If there is an issue loading the QCS Client configuration.
+        - ``ExecutionError`` If there was a problem fetching execution results.
+    """
+    ...
+
+
+async def get_quilc_version(
+    client: Optional[QcsClient] = None,
+) -> str:
+    """
+    Returns the version number of the running quilc server.
+
+    Args:
+        client: The QcsClient to use. Loads one using environment configuration if unset - see https://docs.rigetti.com/qcs/references/qcs-client-configuration
+    
+    Raises:
+        - ``LoadError`` If there is an issue loading the QCS Client configuration.
+        - ``CompilationError`` If there is an issue fetching the version from the quilc compiler.
+    """
+    ...
+
+
+async def get_quilc_version(
+    client: Optional[QcsClient] = None,
+) -> str:
+    """
+    Returns the version number of the running quilc server.
+
+    Args:
+        client: The QcsClient to use. Loads one using environment configuration if unset - see https://docs.rigetti.com/qcs/references/qcs-client-configuration
+    
+    Raises:
+        - ``LoadError`` If there is an issue loading the QCS Client configuration.
+        - ``CompilationError`` If there is an issue fetching the version from the quilc compiler.
+    """
+    ...
+
+async def list_quantum_processors(
+    client: Optional[QcsClient] = None,
+    timeout: Optional[float] = None,
+) -> List[str]:
+    """
+    Returns all available Quantum Processor IDs.
+
+    Args:
+        client: The QcsClient to use. Loads one using environment configuration if unset - see https://docs.rigetti.com/qcs/references/qcs-client-configuration
+        timeout: Maximum duration to wait for API calls to complete, in seconds.
+    """
+    ...
\ No newline at end of file
diff --git a/crates/python/py.typed b/crates/python/qcs_sdk/py.typed
similarity index 100%
rename from crates/python/py.typed
rename to crates/python/qcs_sdk/py.typed
diff --git a/crates/python/qcs_sdk/qpu/__init__.pyi b/crates/python/qcs_sdk/qpu/__init__.pyi
new file mode 100644
index 000000000..e596a2f0a
--- /dev/null
+++ b/crates/python/qcs_sdk/qpu/__init__.pyi
@@ -0,0 +1,2 @@
+class QcsIsaError(RuntimeError):
+    ...
diff --git a/crates/python/qcs_sdk/qpu/client.pyi b/crates/python/qcs_sdk/qpu/client.pyi
new file mode 100644
index 000000000..c59311ed5
--- /dev/null
+++ b/crates/python/qcs_sdk/qpu/client.pyi
@@ -0,0 +1,79 @@
+from typing import Optional
+
+
+class QcsClient:
+    """
+    Configuration for connecting and authenticating to QCS API resources.
+    """
+
+    def __new__(
+        cls,
+        tokens: Optional[QcsClientTokens] = None,
+        api_url: Optional[str] = None,
+        auth_server: Optional[QcsClientAuthServer] = None,
+        grpc_api_url: Optional[str] = None,
+        quilc_url: Optional[str] = None,
+        qvm_url: Optional[str] = None,
+    ) -> "QcsClient":
+        """
+        Construct a client from scratch.
+        
+        Use ``QcsClient.load`` to construct an environment-based profile.
+        """
+        ...
+    
+    @staticmethod
+    def load(
+        profile_name: Optional[str] = None,
+        use_gateway: Optional[bool] = None,
+    ) -> "QcsClient":
+        """
+        Load a QcsClient configuration using an environment-based configuration.
+
+        See for details: https://docs.rigetti.com/qcs/references/qcs-client-configuration#environment-variables-and-configuration-files
+        """
+        ...
+
+
+class QcsClientAuthServer:
+    """Authentication server configuration for the QCS API."""
+
+    @property
+    def client_id(self) -> str: ...
+    @client_id.setter
+    def client_id(self, value: str): ...
+
+    @property
+    def issuer(self) -> str: ...
+    @issuer.setter
+    def issuer(self, value: str): ...
+
+
+class QcsClientTokens:
+    """Authentication tokens for the QCS API."""
+
+    @property
+    def bearer_access_token(self) -> Optional[str]: ...
+    @bearer_access_token.setter
+    def bearer_access_token(self, value: Optional[str]): ...
+
+    @property
+    def refresh_token(self) -> Optional[str]: ...
+    @refresh_token.setter
+    def refresh_token(self, value: Optional[str]): ...
+
+
+class QcsGrpcClientError(RuntimeError):
+    """Error encountered while loading a QCS gRPC API client."""
+
+
+class QcsGrpcEndpointError(RuntimeError):
+    """Error when trying to resolve the QCS gRPC API endpoint."""
+
+
+class QcsGrpcError(RuntimeError):
+    """Error during QCS gRPC API requests."""
+
+
+class QcsLoadError(RuntimeError):
+    """Error encountered while loading the QCS API client configuration."""
diff --git a/crates/python/qcs_sdk/qpu/quilc.pyi b/crates/python/qcs_sdk/qpu/quilc.pyi
new file mode 100644
index 000000000..8d36e73b5
--- /dev/null
+++ b/crates/python/qcs_sdk/qpu/quilc.pyi
@@ -0,0 +1,29 @@
+from typing import Any, Dict, Optional
+
+DEFAULT_COMPILER_TIMEOUT: int
+
+
+class QuilcError(RuntimeError):
+    ...
+
+
+class CompilerOpts:
+    """A set of options that determine the behavior of compiling programs with quilc."""
+
+    @property
+    def timeout(self) -> Optional[int]:
+        """The number of seconds to wait before timing out. If `None`, there is no timeout."""
+        ...
+
+    def __new__(
+        cls,
+        timeout: Optional[int] = DEFAULT_COMPILER_TIMEOUT
+    ) -> "CompilerOpts":
+        ...
+
+    @staticmethod
+    def default() -> "CompilerOpts": ...
+
+
+class TargetDevice:
+    ...
diff --git a/crates/python/src/api.rs b/crates/python/src/api.rs
index ca8bfb975..3d39eb89f 100644
--- a/crates/python/src/api.rs
+++ b/crates/python/src/api.rs
@@ -1,4 +1,4 @@
-use std::collections::HashMap;
+use std::{collections::HashMap, time::Duration};
 
 use pyo3::{
     create_exception,
@@ -10,18 +10,18 @@ use pyo3::{
 };
 use qcs::{
     api::{
-        ExecutionResult, ExecutionResults, Register, RewriteArithmeticResult, TranslationResult,
-    },
-    qpu::{
-        quilc::{CompilerOpts, TargetDevice, DEFAULT_COMPILER_TIMEOUT},
-        Qcs,
+        list_quantum_processors, ExecutionResult, ExecutionResults, Register,
+        RewriteArithmeticResult, TranslationResult,
     },
+    qpu::quilc::{CompilerOpts, TargetDevice, DEFAULT_COMPILER_TIMEOUT},
 };
 use rigetti_pyo3::{
     create_init_submodule, py_wrap_data_struct, py_wrap_error, py_wrap_type, py_wrap_union_enum,
-    wrap_error, ToPython,
+    wrap_error, ToPython, ToPythonError,
 };
 
+use crate::qpu::client::PyQcsClient;
+
 create_init_submodule! {
     classes: [
         PyExecutionResult,
@@ -31,12 +31,12 @@ create_init_submodule! {
         PyTranslationResult
     ],
     errors: [
-        InvalidConfigError,
         ExecutionError,
         TranslationError,
         CompilationError,
         RewriteArithmeticError,
         DeviceIsaError,
+        QcsListQuantumProcessorsError,
         QcsSubmitError
     ],
     funcs: [
@@ -46,7 +46,8 @@ create_init_submodule! {
         submit,
         retrieve_results,
         build_patch_values,
-        get_quilc_version
+        get_quilc_version,
+        py_list_quantum_processors
     ],
 }
 
@@ -82,7 +83,6 @@ py_wrap_type! {
     PyExecutionResult(ExecutionResult) as "ExecutionResult";
 }
 
-create_exception!(qcs, InvalidConfigError, PyRuntimeError);
 create_exception!(qcs, ExecutionError, PyRuntimeError);
 create_exception!(qcs, TranslationError, PyRuntimeError);
 create_exception!(qcs, CompilationError, PyRuntimeError);
@@ -92,11 +92,22 @@ create_exception!(qcs, DeviceIsaError, PyValueError);
 wrap_error!(SubmitError(qcs::api::SubmitError));
 py_wrap_error!(api, SubmitError, QcsSubmitError, PyRuntimeError);
 
-#[pyfunction(kwds = "**")]
+wrap_error!(ListQuantumProcessorsError(
+    qcs::api::ListQuantumProcessorsError
+));
+py_wrap_error!(
+    api,
+    ListQuantumProcessorsError,
+    QcsListQuantumProcessorsError,
+    PyRuntimeError
+);
+
+#[pyfunction(client = "None", kwds = "**")]
 pub fn compile<'a>(
     py: Python<'a>,
     quil: String,
     target_device: String,
+    client: Option<PyQcsClient>,
     kwds: Option<&PyDict>,
 ) -> PyResult<&'a PyAny> {
     let target_device: TargetDevice =
@@ -113,9 +124,7 @@ pub fn compile<'a>(
     }
 
     pyo3_asyncio::tokio::future_into_py(py, async move {
-        let client = Qcs::load()
-            .await
-            .map_err(|e| InvalidConfigError::new_err(e.to_string()))?;
+        let client = PyQcsClient::get_or_create_client(client).await?;
         let options = CompilerOpts::default().with_timeout(compiler_timeout);
         let result = qcs::api::compile(&quil, target_device, &client, options)
             .map_err(|e| CompilationError::new_err(e.to_string()))?;
@@ -153,17 +162,16 @@ pub fn build_patch_values(
         .to_python(py)
 }
 
-#[pyfunction]
+#[pyfunction(client = "None")]
 pub fn translate(
     py: Python<'_>,
     native_quil: String,
     num_shots: u16,
     quantum_processor_id: String,
+    client: Option<PyQcsClient>,
 ) -> PyResult<&PyAny> {
     pyo3_asyncio::tokio::future_into_py(py, async move {
-        let client = Qcs::load()
-            .await
-            .map_err(|e| InvalidConfigError::new_err(e.to_string()))?;
+        let client = PyQcsClient::get_or_create_client(client).await?;
         let result = qcs::api::translate(&native_quil, num_shots, &quantum_processor_id, &client)
             .await
             .map_err(|e| TranslationError::new_err(e.to_string()))?;
@@ -171,19 +179,16 @@ pub fn translate(
     })
 }
 
-#[pyfunction]
+#[pyfunction(client = "None")]
 pub fn submit(
     py: Python<'_>,
     program: String,
     patch_values: HashMap<String, Vec<f64>>,
     quantum_processor_id: String,
-    use_gateway: bool,
+    client: Option<PyQcsClient>,
 ) -> PyResult<&PyAny> {
     pyo3_asyncio::tokio::future_into_py(py, async move {
-        let client = Qcs::load()
-            .await
-            .map_err(|e| InvalidConfigError::new_err(e.to_string()))?
-            .with_use_gateway(use_gateway);
+        let client = PyQcsClient::get_or_create_client(client).await?;
         let job_id = qcs::api::submit(&program, patch_values, &quantum_processor_id, &client)
             .await
             .map_err(|e| ExecutionError::new_err(e.to_string()))?;
@@ -191,18 +196,15 @@ pub fn submit(
     })
 }
 
-#[pyfunction]
+#[pyfunction(client = "None")]
 pub fn retrieve_results(
     py: Python<'_>,
     job_id: String,
     quantum_processor_id: String,
-    use_gateway: bool,
+    client: Option<PyQcsClient>,
 ) -> PyResult<&PyAny> {
     pyo3_asyncio::tokio::future_into_py(py, async move {
-        let client = Qcs::load()
-            .await
-            .map_err(|e| InvalidConfigError::new_err(e.to_string()))?
-            .with_use_gateway(use_gateway);
+        let client = PyQcsClient::get_or_create_client(client).await?;
         let results = qcs::api::retrieve_results(&job_id, &quantum_processor_id, &client)
             .await
             .map_err(|e| ExecutionError::new_err(e.to_string()))?;
@@ -210,14 +212,30 @@ pub fn retrieve_results(
     })
 }
 
-#[pyfunction]
-pub fn get_quilc_version(py: Python<'_>) -> PyResult<&PyAny> {
+#[pyfunction(client = "None")]
+pub fn get_quilc_version(py: Python<'_>, client: Option<PyQcsClient>) -> PyResult<&PyAny> {
     pyo3_asyncio::tokio::future_into_py(py, async move {
-        let client = Qcs::load()
-            .await
-            .map_err(|e| InvalidConfigError::new_err(e.to_string()))?;
+        let client = PyQcsClient::get_or_create_client(client).await?;
         let version = qcs::api::get_quilc_version(&client)
             .map_err(|e| CompilationError::new_err(e.to_string()))?;
         Ok(version)
     })
 }
+
+#[pyfunction(client = "None", timeout = "None")]
+#[pyo3(name = "list_quantum_processors")]
+pub fn py_list_quantum_processors(
+    py: Python<'_>,
+    client: Option<PyQcsClient>,
+    timeout: Option<f64>,
+) -> PyResult<&PyAny> {
+    pyo3_asyncio::tokio::future_into_py(py, async move {
+        let client = PyQcsClient::get_or_create_client(client).await?;
+        let timeout = timeout.map(Duration::from_secs_f64);
+        let names = list_quantum_processors(&client, timeout)
+            .await
+            .map_err(ListQuantumProcessorsError::from)
+            .map_err(ListQuantumProcessorsError::to_py_err)?;
+        Ok(names)
+    })
+}
diff --git a/crates/python/src/executable.rs b/crates/python/src/executable.rs
index b2ae881fe..be5f43c3d 100644
--- a/crates/python/src/executable.rs
+++ b/crates/python/src/executable.rs
@@ -1,13 +1,11 @@
 use std::sync::Arc;
 
-use pyo3::{
-    exceptions::PyRuntimeError, pyclass, pymethods, types::PyDict, FromPyObject, Py, PyAny,
-    PyResult, Python,
-};
+use pyo3::{pyclass, FromPyObject};
 use qcs::{Error, Executable, JobHandle, Qpu, Qvm, Service};
 use rigetti_pyo3::{
-    impl_as_mut_for_wrapper, py_wrap_error, py_wrap_simple_enum, py_wrap_type, wrap_error,
-    PyWrapper, ToPython, ToPythonError,
+    impl_as_mut_for_wrapper, py_wrap_error, py_wrap_simple_enum, py_wrap_type,
+    pyo3::{exceptions::PyRuntimeError, pymethods, types::PyDict, Py, PyAny, PyResult, Python},
+    wrap_error, PyWrapper, ToPython, ToPythonError,
 };
 use tokio::sync::Mutex;
 
@@ -35,9 +33,20 @@ impl_as_mut_for_wrapper!(PyExecutable);
 #[pyo3(name = "ExeParameter")]
 #[derive(FromPyObject)]
 pub struct PyParameter {
-    name: String,
-    index: usize,
-    value: f64,
+    #[pyo3(get, set)]
+    pub name: String,
+    #[pyo3(get, set)]
+    pub index: usize,
+    #[pyo3(get, set)]
+    pub value: f64,
+}
+
+#[pymethods]
+impl PyParameter {
+    #[new]
+    pub fn new(name: String, index: usize, value: f64) -> Self {
+        Self { name, index, value }
+    }
 }
 
 #[pymethods]
diff --git a/crates/python/src/lib.rs b/crates/python/src/lib.rs
index 3ca002a28..6debef875 100644
--- a/crates/python/src/lib.rs
+++ b/crates/python/src/lib.rs
@@ -1,6 +1,8 @@
 use pyo3::prelude::*;
 use rigetti_pyo3::create_init_submodule;
 
+use executable::QcsExecutionError;
+
 pub mod api;
 pub mod executable;
 pub mod execution_data;
@@ -14,12 +16,14 @@ create_init_submodule! {
         execution_data::PyQvm,
         execution_data::PyReadoutMap,
         executable::PyExecutable,
+        executable::PyParameter,
         executable::PyJobHandle,
         executable::PyService,
-        register_data::PyRegisterData
+        register_data::PyRegisterData,
+        qpu::client::PyQcsClient
     ],
     errors: [
-        executable::QcsExecutionError
+        QcsExecutionError
     ],
     funcs: [
         api::compile,
@@ -28,7 +32,8 @@ create_init_submodule! {
         api::submit,
         api::retrieve_results,
         api::build_patch_values,
-        api::get_quilc_version
+        api::get_quilc_version,
+        api::py_list_quantum_processors
     ],
     submodules: [
         "api": api::init_submodule,
diff --git a/crates/python/src/qpu/client.rs b/crates/python/src/qpu/client.rs
index 842e6fec8..87b3a2f42 100644
--- a/crates/python/src/qpu/client.rs
+++ b/crates/python/src/qpu/client.rs
@@ -1,12 +1,25 @@
-use pyo3::{exceptions::PyRuntimeError, pymethods, PyResult, Python};
 use pyo3_asyncio::tokio::future_into_py;
-use qcs::qpu::Qcs;
+use qcs_api_client_common::{
+    configuration::{AuthServer, BuildError, ClientConfigurationBuilder, Tokens},
+    ClientConfiguration,
+};
 use rigetti_pyo3::{
-    create_init_submodule, py_wrap_error, py_wrap_type, wrap_error, ToPython, ToPythonError,
+    create_init_submodule, py_wrap_data_struct, py_wrap_error, py_wrap_type,
+    pyo3::{
+        conversion::IntoPy, exceptions::PyRuntimeError, pyclass, pyclass::CompareOp, pymethods,
+        types::PyString, FromPyObject, Py, PyAny, PyObject, PyResult, Python,
+    },
+    wrap_error, ToPythonError,
 };
 
+use qcs::qpu::Qcs;
+
 create_init_submodule! {
-    classes: [PyQcsClient],
+    classes: [
+        PyQcsClient,
+        PyQcsClientAuthServer,
+        PyQcsClientTokens
+    ],
     errors: [
         QcsGrpcClientError,
         QcsGrpcEndpointError,
@@ -40,18 +53,151 @@ py_wrap_error!(
     PyRuntimeError
 );
 
+wrap_error!(ConfigurationBuildError(BuildError));
+py_wrap_error!(
+    qcs,
+    ConfigurationBuildError,
+    QcsConfigurationBuildError,
+    PyRuntimeError
+);
+
+/// The fields on qcs_api_client_common::client::AuthServer are not public.
+#[pyclass]
+#[pyo3(name = "QcsClientAuthServer")]
+#[derive(FromPyObject)]
+pub struct PyQcsClientAuthServer {
+    #[pyo3(get, set)]
+    pub client_id: Option<String>,
+    #[pyo3(get, set)]
+    pub issuer: Option<String>,
+}
+
+impl From<PyQcsClientAuthServer> for AuthServer {
+    fn from(value: PyQcsClientAuthServer) -> Self {
+        let mut auth_server = AuthServer::default();
+        if let Some(client_id) = value.client_id {
+            auth_server = auth_server.set_client_id(client_id);
+        }
+        if let Some(issuer) = value.issuer {
+            auth_server = auth_server.set_issuer(issuer);
+        }
+        auth_server
+    }
+}
+
+#[pymethods]
+impl PyQcsClientAuthServer {
+    #[new]
+    #[args(client_id = "None", issuer = "None")]
+    pub fn new(client_id: Option<String>, issuer: Option<String>) -> Self {
+        Self { client_id, issuer }
+    }
+}
+
+py_wrap_data_struct! {
+    PyQcsClientTokens(Tokens) as "QcsClientTokens" {
+        bearer_access_token: Option<String> => Option<Py<PyString>>,
+        refresh_token: Option<String> => Option<Py<PyString>>
+    }
+}
+
+#[pymethods]
+impl PyQcsClientTokens {
+    #[new]
+    #[args(bearer_access_token = "None", refresh_token = "None")]
+    pub fn new(bearer_access_token: Option<String>, refresh_token: Option<String>) -> Self {
+        Self(Tokens {
+            bearer_access_token,
+            refresh_token,
+        })
+    }
+}
+
 py_wrap_type! {
     PyQcsClient(Qcs) as "QcsClient";
 }
 
+impl PyQcsClient {
+    pub(crate) async fn get_or_create_client(client: Option<Self>) -> PyResult<Qcs> {
+        Ok(match client {
+            Some(client) => client.into(),
+            None => Qcs::load()
+                .await
+                .map_err(LoadError::from)
+                .map_err(LoadError::to_py_err)?,
+        })
+    }
+}
+
+impl PartialEq for PyQcsClient {
+    fn eq(&self, other: &Self) -> bool {
+        format!("{:?}", self.0) == format!("{:?}", other.0)
+    }
+}
+
 #[pymethods]
 impl PyQcsClient {
     #[new]
-    #[args("/", use_gateway = "None")]
-    pub fn new(py: Python<'_>, use_gateway: Option<bool>) -> PyResult<Self> {
+    #[args(
+        "/",
+        tokens = "None",
+        api_url = "None",
+        auth_server = "None",
+        grpc_api_url = "None",
+        quilc_url = "None",
+        qvm_url = "None"
+    )]
+    pub fn new(
+        tokens: Option<PyQcsClientTokens>,
+        api_url: Option<String>,
+        auth_server: Option<PyQcsClientAuthServer>,
+        grpc_api_url: Option<String>,
+        quilc_url: Option<String>,
+        qvm_url: Option<String>,
+    ) -> PyResult<Self> {
+        let mut builder = ClientConfigurationBuilder::default();
+        if let Some(tokens) = tokens {
+            builder = builder.set_tokens(tokens.into());
+        }
+        if let Some(api_url) = api_url {
+            builder = builder.set_api_url(api_url);
+        }
+        if let Some(auth_server) = auth_server {
+            builder = builder.set_auth_server(auth_server.into());
+        }
+        if let Some(grpc_api_url) = grpc_api_url {
+            builder = builder.set_grpc_api_url(grpc_api_url);
+        }
+        if let Some(quilc_url) = quilc_url {
+            builder = builder.set_quilc_url(quilc_url);
+        }
+        if let Some(qvm_url) = qvm_url {
+            builder = builder.set_grpc_api_url(qvm_url);
+        }
+        let client = builder
+            .build()
+            .map(Qcs::with_config)
+            .map_err(ConfigurationBuildError::from)
+            .map_err(ConfigurationBuildError::to_py_err)?;
+
+        Ok(Self(client))
+    }
+
+    #[staticmethod]
+    #[args("/", profile_name = "None", use_gateway = "None")]
+    pub fn load(
+        py: Python<'_>,
+        profile_name: Option<String>,
+        use_gateway: Option<bool>,
+    ) -> PyResult<&PyAny> {
         future_into_py(py, async move {
-            let client = Qcs::load()
-                .await
+            let config = match profile_name {
+                Some(profile_name) => ClientConfiguration::load_profile(profile_name).await,
+                None => ClientConfiguration::load_default().await,
+            };
+
+            let client = config
+                .map(Qcs::with_config)
                 .map_err(LoadError)
                 .map_err(ToPythonError::to_py_err)?;
 
@@ -60,8 +206,15 @@ impl PyQcsClient {
                 Some(use_gateway) => client.with_use_gateway(use_gateway),
             };
 
-            Python::with_gil(|py| Self(client).to_python(py))
-        })?
-        .extract()
+            Ok(Self(client))
+        })
+    }
+
+    fn __richcmp__(&self, other: &Self, op: CompareOp, py: Python<'_>) -> PyObject {
+        match op {
+            CompareOp::Eq => (self == other).into_py(py),
+            CompareOp::Ne => (self != other).into_py(py),
+            _ => py.NotImplemented(),
+        }
     }
 }


Test patch:
diff --git a/crates/python/tests/qcs_config/settings.toml b/crates/python/tests/qcs_config/settings.toml
index e2e9d3f77..4d32338f9 100644
--- a/crates/python/tests/qcs_config/settings.toml
+++ b/crates/python/tests/qcs_config/settings.toml
@@ -3,8 +3,8 @@ default_profile_name = "default"
 [auth_servers]
 
   [auth_servers.default]
-    client_id = ""
-    issuer = "https://rigetticomputing.okta.com/oauth2/ausmoidj5qavd4v310h7"
+    client_id = "0oa3ykoirzDKpkfzk357"
+    issuer = "https://auth.qcs.rigetti.com/oauth2/aus8jcovzG0gW2TUG355"
 
 [profiles]
 
@@ -13,3 +13,12 @@ default_profile_name = "default"
     auth_server_name = "default"
     credentials_name = "default"
 
+  [profiles.empty]
+    api_url = "https://api.qcs.rigetti.com"
+    auth_server_name = "default"
+    credentials_name = "does-not-exist"
+
+  [profiles.broken]
+    api_url = "https://api.qcs.rigetti.com"
+    auth_server_name = "does-not-exist"
+    credentials_name = "default"
diff --git a/crates/python/tests/test_api.py b/crates/python/tests/test_api.py
index 03805b293..eb0993991 100644
--- a/crates/python/tests/test_api.py
+++ b/crates/python/tests/test_api.py
@@ -45,7 +45,24 @@ def test_build_patch_values():
     assert patch_values == expected
 
 
+def test_exe_parameters():
+    """Should be able to construct and pass exe parameters"""
+    exe_parameter = qcs_sdk.ExeParameter("a", 1, 2.5)
+    assert exe_parameter.name == "a"
+    assert exe_parameter.index == 1
+    assert exe_parameter.value == 2.5
+
+    qcs_sdk.Executable("quil", parameters=[exe_parameter])
+
+
 @pytest.mark.asyncio
 async def test_get_quilc_version():
     version = await qcs_sdk.get_quilc_version()
     assert re.match(r"^([0-9]+)\.([0-9]+)\.([0-9]+)$", version)
+
+
+@pytest.mark.asyncio
+@pytest.mark.skip
+async def test_list_quantum_processors():
+    qpus = await qcs_sdk.list_quantum_processors()
+    assert isinstance(qpus, list)
diff --git a/crates/python/tests/test_client.py b/crates/python/tests/test_client.py
new file mode 100644
index 000000000..29552b395
--- /dev/null
+++ b/crates/python/tests/test_client.py
@@ -0,0 +1,45 @@
+import pytest
+
+from qcs_sdk import QcsClient
+from qcs_sdk.qpu.client import QcsLoadError, QcsClientAuthServer, QcsClientTokens
+
+@pytest.fixture
+def default_client():
+    return QcsClient()
+
+
+@pytest.mark.asyncio
+async def test_client_empty_profile_is_default(default_client: QcsClient):
+    """The profile "empty" is configured to be similar to a default client."""
+    client = await QcsClient.load(profile_name="empty")
+
+    assert client == default_client
+
+
+@pytest.mark.asyncio
+async def test_client_default_profile_is_not_empty(default_client: QcsClient):
+    """The "default" profile is configured to have a token, unlike the default client."""
+    client = await QcsClient.load()
+
+    assert client != default_client
+
+
+@pytest.mark.asyncio
+async def test_client_broken_raises():
+    """Using a profile with broken configuration should surface the underlying error."""
+    with pytest.raises(QcsLoadError, match=r"Expected auth server .* but it didn't exist"):
+        await QcsClient.load(profile_name="broken")
+
+
+def test_client_auth_server_can_be_manually_defined():
+    """Ensures that pyo3 usage is correct."""
+    auth_server = QcsClientAuthServer(client_id="foo", issuer="bar")
+    assert auth_server.client_id == "foo"
+    assert auth_server.issuer == "bar"
+
+
+def test_client_tokens_can_be_manually_defined():
+    """Ensures that pyo3 usage is correct."""
+    auth_server = QcsClientTokens(bearer_access_token="foo", refresh_token="bar")
+    assert auth_server.bearer_access_token == "foo"
+    assert auth_server.refresh_token == "bar"