Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,182 changes: 1,029 additions & 153 deletions Cargo.lock

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ members = ["crates/*"]
resolver = "2"

[workspace.dependencies]
qcs-api-client-common = "0.12.8"
qcs-api-client-grpc = "0.12.8"
qcs-api-client-openapi = "0.13.8"
qcs-api-client-common = "0.12.12"
qcs-api-client-grpc = "0.12.12"
qcs-api-client-openapi = "0.13.13"
serde_json = "1.0.86"
thiserror = "1.0.57"
tokio = "1.36.0"
tokio-util = "0.7"
# We specify quil-rs as a git and versioned dependency so that we can keep the version of
# quil-rs used in both the Rust and Python packages in sync. The tag used should always
# be a `quil-py` tag and should be compatible with the version specified in
Expand Down
4 changes: 4 additions & 0 deletions crates/lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ serde = { version = "1.0.145", features = ["derive"] }
serde_json.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
tokio-util = { workspace = true }
toml = "0.7.3"
tracing = { version = "0.1", optional = true, features = ["log"] }
uuid = { version = "1.2.1", features = ["v4"] }
Expand All @@ -56,6 +57,9 @@ erased-serde = "0.3.23"
float-cmp = "0.9.0"
hex = "0.4.3"
maplit = "1.0.2"
# keep pinned until this crate matures
oauth2-test-server = "=0.1.3"
qcs-api-client-common = { workspace = true, features = ["test"] }
qcs-api-client-grpc = { workspace = true, features = ["server"] }
simple_logger = { version = "4.1.0", default-features = false }
tempfile = "3.3.0"
Expand Down
2 changes: 1 addition & 1 deletion crates/lib/Makefile.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ args = ["build", "--examples", "--features", "experimental"]
[tasks.deny]
install_crate = "cargo-deny"
command = "cargo"
args = ["deny", "--all-features", "check"]
args = ["deny", "--exclude-dev", "--all-features", "check"]

[tasks.pre-ci-flow]
dependencies = ["deny", "lint"]
22 changes: 22 additions & 0 deletions crates/lib/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use qcs_api_client_grpc::{
},
};
use qcs_api_client_openapi::apis::configuration::Configuration as OpenApiConfiguration;
use tokio_util::sync::CancellationToken;
#[cfg(not(any(feature = "grpc-web", feature = "tracing")))]
use tonic::transport::Channel;
use tonic::Status;
Expand Down Expand Up @@ -96,6 +97,27 @@ impl Qcs {
ClientConfiguration::load_profile(profile).map(Self::with_config)
}

/// Create a [`Qcs`] and initialized with the given optional `profile`.
/// If credentials are not found or stale, a PKCE login redirect flow
/// will be initialized. Note that this opens up a TCP port on your
/// system to accept a browser HTTP redirect, so you should not use
/// this in environments where that is not possible, such as hosted
/// JupyterLab sessions.
///
/// # Errors
///
/// A [`LoadError`] will be returned if QCS credentials are
/// not correctly configured or the given profile is not defined
/// or the PKCE login flow failed.
pub async fn with_login(
cancel_token: CancellationToken,
profile: Option<String>,
) -> Result<Qcs, LoadError> {
ClientConfiguration::load_with_login(cancel_token, profile)
.await
.map(Self::with_config)
}

/// Return a reference to the underlying [`ClientConfiguration`] with all settings parsed and resolved from configuration sources.
#[must_use]
pub fn get_config(&self) -> &ClientConfiguration {
Expand Down
2 changes: 1 addition & 1 deletion crates/lib/src/qpu/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ impl Eq for ExecutionOptions {}
///
/// Use [`Default`] to get a reasonable set of defaults, or start with [`ApiExecutionOptionsBuilder`]
/// to build a custom set of options.
#[derive(Builder, Clone, Debug, Default, PartialEq)]
#[derive(Builder, Clone, Copy, Debug, Default, PartialEq)]
#[allow(clippy::module_name_repetitions)]
pub struct ApiExecutionOptions {
/// the inner proto representation
Expand Down
101 changes: 69 additions & 32 deletions crates/lib/tests/mocked_qpu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,24 @@ async fn test_qcs_against_mocks() {

async fn setup() {
simple_logger::init_with_env().unwrap();
std::env::set_var(SETTINGS_PATH_VAR, "tests/settings.toml");
std::env::set_var(SECRETS_PATH_VAR, "tests/secrets.toml");

// Create a temporary file to store the secrets generated by the test server,
// which must be a non-expired JWT in order for the client to even attempt to make the request.
let secrets_toml = tempfile::NamedTempFile::new().unwrap();

unsafe {
std::env::set_var(SETTINGS_PATH_VAR, "tests/settings.toml");
std::env::set_var(SECRETS_PATH_VAR, secrets_toml.path());
}

let (oauth_ready_tx, oauth_ready_rx) = tokio::sync::oneshot::channel::<()>();

tokio::spawn(qpu::run());
tokio::spawn(translation::run());
tokio::spawn(auth_server::run());
tokio::spawn(mock_oauth2::run(secrets_toml, oauth_ready_tx));
tokio::spawn(mock_qcs::run());

oauth_ready_rx.await.unwrap();
}

async fn quilc_client() -> rpcq::Client {
Expand Down Expand Up @@ -94,35 +106,60 @@ async fn run_bell_state(connection_strategy: ConnectionStrategy) {
assert_eq!(result.duration, Some(Duration::from_micros(8675)));
}

#[allow(dead_code)]
mod auth_server {
use serde::{Deserialize, Serialize};
use warp::Filter;

#[derive(Debug, Deserialize)]
struct TokenRequest {
grant_type: String,
client_id: String,
refresh_token: String,
}

#[derive(Serialize, Debug)]
struct TokenResponse {
refresh_token: &'static str,
access_token: &'static str,
}

pub(crate) async fn run() {
let token = warp::post()
.and(warp::path("v1").and(warp::path("token")))
.and(warp::body::form())
.map(|_request: TokenRequest| {
warp::reply::json(&TokenResponse {
refresh_token: "refreshed",
access_token: "accessed",
})
});
warp::serve(token).run(([127, 0, 0, 1], 8001)).await;
mod mock_oauth2 {
use std::io::Write as _;

use oauth2_test_server::{IssuerConfig, JwtOptions, OAuthTestServer};
use tokio::task::JoinError;

/// A test harness for serving a valid oauth2 issuer, including the well-known endpoint.
pub(super) async fn run(
secrets_toml: tempfile::NamedTempFile,
oauth_ready_tx: tokio::sync::oneshot::Sender<()>,
) -> Result<(), JoinError> {
const SCHEME: &str = "http";
const HOST: &str = "127.0.0.1";
const PORT: u16 = 8001;

let server = OAuthTestServer::start_with_config(IssuerConfig {
scheme: SCHEME.to_string(),
host: HOST.to_string(),
port: PORT,
..Default::default()
})
.await;

let client = server.register_client(serde_json::json!({
"scope": "openid",
"redirect_uris": [format!("{SCHEME}://{HOST}:{PORT}")],
"client_name": "mock_oauth2"
}));

// Generate a valid access token and persist it to the credentials,
// otherwise the client won't make a request with an invalid access token.
let token = server.generate_token(&client, JwtOptions::default());
let access_token = token.access_token;

let contents = format!(
r#"
[credentials]
[credential.default]
[credentials.default.token_payload]
access_token = "{access_token}"
"#
);
secrets_toml
.as_file()
.write_all(contents.as_bytes())
.unwrap();

oauth_ready_tx.send(()).unwrap();

server.wait_for_shutdown().await?;

secrets_toml.close().unwrap();

Ok(())
}
}

Expand Down
6 changes: 0 additions & 6 deletions crates/lib/tests/secrets.toml

This file was deleted.

6 changes: 3 additions & 3 deletions crates/lib/tests/settings.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ default_profile_name = "default"
[profiles.default]
auth_server_name = "default"
credentials_name = "default"
api_url = "http://localhost:8000"
grpc_api_url = "http://localhost:8003"
api_url = "http://127.0.0.1:8000"
grpc_api_url = "http://127.0.0.1:8003"

[auth_servers]
[auth_servers.default]
client_id = "default_client_id"
issuer = "http://localhost:8001"
issuer = "http://127.0.0.1:8001"
1 change: 1 addition & 0 deletions crates/python/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pyo3-tracing-subscriber = { workspace = true, features = ["pyo3", "layer-otel-ot
quil-rs.workspace = true
serde_json.workspace = true
tokio.workspace = true
tokio-util.workspace = true
thiserror.workspace = true
numpy.workspace = true
rigetti-pyo3.workspace = true
Expand Down
44 changes: 43 additions & 1 deletion crates/python/qcs_sdk/client.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,49 @@ class QCSClient:

:param profile_name: The QCS setting's profile name to use. If ``None``, the default value configured in your environment is used.

:raises `LoadClientError`: If there is an issue loading the profile defails from the environment.
:raises `LoadClientError`: If there is an issue loading the profile details from the environment.

See the [QCS documentation](https://docs.rigetti.com/qcs/references/qcs-client-configuration#environment-variables-and-configuration-files)
for more details.
"""
...
@staticmethod
def load_with_login(
profile_name: Optional[str] = None,
) -> "QCSClient":
"""
Create a `QCSClient` configuration using an environment-based configuration.

If credentials are not found or stale, a PKCE login redirect flow
will be initialized. Note that this opens up a TCP port on your
system to accept a browser HTTP redirect, so you should not use
this in environments where that is not possible, such as hosted
JupyterLab sessions.

:param profile_name: The QCS setting's profile name to use. If ``None``, the default value configured in your environment is used.

:raises `LoadClientError`: If there is an issue loading the profile details from the environment or if the PKCE login flow fails.

See the [QCS documentation](https://docs.rigetti.com/qcs/references/qcs-client-configuration#environment-variables-and-configuration-files)
for more details.
"""
...
@staticmethod
async def load_with_login_async(
profile_name: Optional[str] = None,
) -> "QCSClient":
"""
Create a `QCSClient` configuration using an environment-based configuration.

If credentials are not found or stale, a PKCE login redirect flow
will be initialized. Note that this opens up a TCP port on your
system to accept a browser HTTP redirect, so you should not use
this in environments where that is not possible, such as hosted
JupyterLab sessions.

:param profile_name: The QCS setting's profile name to use. If ``None``, the default value configured in your environment is used.

:raises `LoadClientError`: If there is an issue loading the profile details from the environment or if the PKCE login flow fails.

See the [QCS documentation](https://docs.rigetti.com/qcs/references/qcs-client-configuration#environment-variables-and-configuration-files)
for more details.
Expand Down
49 changes: 47 additions & 2 deletions crates/python/src/client.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
use pyo3::{exceptions::PyValueError, pyfunction};
use std::process::Output;

use pyo3::{exceptions::PyValueError, pyfunction, PyAny};
use qcs_api_client_common::configuration::{
AuthServer, ClientConfigurationBuilder, ClientConfigurationBuilderError, ClientCredentials,
ExternallyManaged, OAuthSession, RefreshToken,
};
use rigetti_pyo3::{
create_init_submodule, py_function_sync_async, py_wrap_error, py_wrap_type,
create_init_submodule, py_async, py_function_sync_async, py_wrap_error, py_wrap_type,
pyo3::{
conversion::IntoPy, exceptions::PyRuntimeError, pyclass::CompareOp, pymethods, PyObject,
PyResult, Python,
Expand All @@ -13,6 +15,9 @@
};

use qcs::client::{self, Qcs};
use tokio_util::sync::CancellationToken;

use crate::py_sync;

create_init_submodule! {
classes: [
Expand Down Expand Up @@ -60,7 +65,7 @@
}
}

#[pymethods]

Check warning on line 68 in crates/python/src/client.rs

View workflow job for this annotation

GitHub Actions / publish-docs

non-local `impl` definition, `impl` blocks should be written at the same level as their item

Check warning on line 68 in crates/python/src/client.rs

View workflow job for this annotation

GitHub Actions / Run Checks

non-local `impl` definition, `impl` blocks should be written at the same level as their item
impl PyQcsClient {
#[new]
#[pyo3(signature = (
Expand Down Expand Up @@ -115,6 +120,34 @@
})
}

#[staticmethod]
#[pyo3(signature = (/, profile_name = None))]
fn load_with_login(py: Python<'_>, profile_name: Option<String>) -> PyResult<Self> {
do_until_ctrl_c(move |cancel_token| {
py_sync!(py, async move {
Qcs::with_login(cancel_token, profile_name)
.await
.map(PyQcsClient)
.map_err(RustLoadClientError::from)
.map_err(RustLoadClientError::to_py_err)
})
})
}

#[staticmethod]
#[pyo3(signature = (/, profile_name = None))]
fn load_with_login_async(py: Python<'_>, profile_name: Option<String>) -> PyResult<&PyAny> {
do_until_ctrl_c(move |cancel_token| {
py_async!(py, async move {
Qcs::with_login(cancel_token, profile_name)
.await
.map(PyQcsClient)
.map_err(RustLoadClientError::from)
.map_err(RustLoadClientError::to_py_err)
})
})
}

#[getter]
pub fn api_url(&self) -> String {
self.as_ref().get_config().api_url().to_string()
Expand Down Expand Up @@ -156,3 +189,15 @@

}
}

/// Run the given function with a CancellationToken that is cancelled when `Ctrl+C` is pressed.
fn do_until_ctrl_c<T>(f: impl FnOnce(CancellationToken) -> T) -> T {
let cancel_token = CancellationToken::new();
let cancel_token_ctrl_c = cancel_token.clone();
tokio::spawn(cancel_token.clone().run_until_cancelled_owned(async move {
tokio::signal::ctrl_c().await;

Check warning on line 198 in crates/python/src/client.rs

View workflow job for this annotation

GitHub Actions / publish-docs

unused `Result` that must be used

Check warning on line 198 in crates/python/src/client.rs

View workflow job for this annotation

GitHub Actions / Run Checks

unused `Result` that must be used
cancel_token_ctrl_c.cancel();
}));

f(cancel_token)
}
Loading
Loading