diff --git a/src/AzureClient/AzureClient.cs b/src/AzureClient/AzureClient.cs index 3d941f5233..371ab20c95 100644 --- a/src/AzureClient/AzureClient.cs +++ b/src/AzureClient/AzureClient.cs @@ -1,24 +1,222 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. #nullable enable using System; using System.Collections.Generic; -using System.Text; -using Microsoft.Jupyter.Core; +using System.Linq; +using System.IO; using System.Threading.Tasks; - +using Microsoft.Azure.Quantum.Client; using Microsoft.Identity.Client; using Microsoft.Identity.Client.Extensions.Msal; -using System.Linq; -using System.IO; -using Microsoft.Quantum.Runtime; +using Microsoft.Jupyter.Core; +using Microsoft.Quantum.Simulation.Core; namespace Microsoft.Quantum.IQSharp.AzureClient { /// public class AzureClient : IAzureClient { + private string ConnectionString { get; set; } = string.Empty; + private string ActiveTargetName { get; set; } = string.Empty; + private AuthenticationResult? AuthenticationResult { get; set; } + private IQuantumClient? QuantumClient { get; set; } + private Azure.Quantum.Workspace? ActiveWorkspace { get; set; } + + /// + public async Task ConnectAsync( + IChannel channel, + string subscriptionId, + string resourceGroupName, + string workspaceName, + string storageAccountConnectionString, + bool forceLoginPrompt = false) + { + ConnectionString = storageAccountConnectionString; + + var clientId = "84ba0947-6c53-4dd2-9ca9-b3694761521b"; // Microsoft Quantum Development Kit + var authority = "https://login.microsoftonline.com/common"; + var msalApp = PublicClientApplicationBuilder.Create(clientId).WithAuthority(authority).Build(); + + // Register the token cache for serialization + var cacheFileName = "aad.bin"; + var cacheDirectoryEnvVarName = "AZURE_QUANTUM_TOKEN_CACHE"; + var cacheDirectory = System.Environment.GetEnvironmentVariable(cacheDirectoryEnvVarName); + if (string.IsNullOrEmpty(cacheDirectory)) + { + cacheDirectory = Path.Join(System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), ".azure-quantum"); + } + + var storageCreationProperties = new StorageCreationPropertiesBuilder(cacheFileName, cacheDirectory, clientId).Build(); + var cacheHelper = await MsalCacheHelper.CreateAsync(storageCreationProperties); + cacheHelper.RegisterCache(msalApp.UserTokenCache); + + var scopes = new List() { "https://quantum.microsoft.com/Jobs.ReadWrite" }; + + bool shouldShowLoginPrompt = forceLoginPrompt; + if (!shouldShowLoginPrompt) + { + try + { + var accounts = await msalApp.GetAccountsAsync(); + AuthenticationResult = await msalApp.AcquireTokenSilent( + scopes, accounts.FirstOrDefault()).WithAuthority(msalApp.Authority).ExecuteAsync(); + } + catch (MsalUiRequiredException) + { + shouldShowLoginPrompt = true; + } + } + + if (shouldShowLoginPrompt) + { + AuthenticationResult = await msalApp.AcquireTokenWithDeviceCode(scopes, + deviceCodeResult => + { + channel.Stdout(deviceCodeResult.Message); + return Task.FromResult(0); + }).WithAuthority(msalApp.Authority).ExecuteAsync(); + } + + if (AuthenticationResult == null) + { + return AzureClientError.AuthenticationFailed.ToExecutionResult(); + } + + var credentials = new Rest.TokenCredentials(AuthenticationResult.AccessToken); + QuantumClient = new QuantumClient(credentials) + { + SubscriptionId = subscriptionId, + ResourceGroupName = resourceGroupName, + WorkspaceName = workspaceName + }; + ActiveWorkspace = new Azure.Quantum.Workspace( + QuantumClient.SubscriptionId, QuantumClient.ResourceGroupName, + QuantumClient.WorkspaceName, AuthenticationResult?.AccessToken); + + try + { + var jobsList = await QuantumClient.Jobs.ListAsync(); + channel.Stdout($"Successfully connected to Azure Quantum workspace {workspaceName}."); + } + catch (Exception e) + { + channel.Stderr(e.ToString()); + return AzureClientError.WorkspaceNotFound.ToExecutionResult(); + } + + return QuantumClient.ToJupyterTable().ToExecutionResult(); + } + + /// + public async Task PrintConnectionStatusAsync(IChannel channel) => + QuantumClient == null + ? AzureClientError.NotConnected.ToExecutionResult() + : QuantumClient.ToJupyterTable().ToExecutionResult(); + + /// + public async Task SubmitJobAsync( + IChannel channel, + IOperationResolver operationResolver, + string operationName) + { + if (ActiveWorkspace == null) + { + channel.Stderr("Please call %connect before submitting a job."); + return AzureClientError.NotConnected.ToExecutionResult(); + } + + if (ActiveTargetName == null) + { + channel.Stderr("Please call %target before submitting a job."); + return AzureClientError.NoTarget.ToExecutionResult(); + } + + if (string.IsNullOrEmpty(operationName)) + { + channel.Stderr("Please pass a valid Q# operation name to %submit."); + return AzureClientError.NoOperationName.ToExecutionResult(); + } + + var operationInfo = operationResolver.Resolve(operationName); + var entryPointInfo = new EntryPointInfo(operationInfo.RoslynType); + var entryPointInput = QVoid.Instance; + var machine = Azure.Quantum.QuantumMachineFactory.CreateMachine(ActiveWorkspace, ActiveTargetName, ConnectionString); + if (machine == null) + { + channel.Stderr($"Could not find an execution target for target {ActiveTargetName}."); + return AzureClientError.NoTarget.ToExecutionResult(); + } + + var job = await machine.SubmitAsync(entryPointInfo, entryPointInput); + return job.ToJupyterTable().ToExecutionResult(); + } + + /// + public async Task SetActiveTargetAsync( + IChannel channel, + string targetName) + { + // TODO: Validate that this target name is valid in the workspace. + ActiveTargetName = targetName; + return $"Active target is now {ActiveTargetName}".ToExecutionResult(); + } + + /// + public async Task PrintTargetListAsync( + IChannel channel) + { + if (QuantumClient == null) + { + channel.Stderr("Please call %connect before listing targets."); + return AzureClientError.NotConnected.ToExecutionResult(); + } + + var providersStatus = await QuantumClient.Providers.GetStatusAsync(); + return providersStatus.ToJupyterTable().ToExecutionResult(); + } + + /// + public async Task PrintJobStatusAsync( + IChannel channel, + string jobId) + { + if (QuantumClient == null) + { + channel.Stderr("Please call %connect before getting job status."); + return AzureClientError.NotConnected.ToExecutionResult(); + } + + var jobDetails = await QuantumClient.Jobs.GetAsync(jobId); + if (jobDetails == null) + { + channel.Stderr($"Job ID {jobId} not found in current Azure Quantum workspace."); + return AzureClientError.JobNotFound.ToExecutionResult(); + } + + return jobDetails.ToJupyterTable().ToExecutionResult(); + } + + /// + public async Task PrintJobListAsync( + IChannel channel) + { + if (QuantumClient == null) + { + channel.Stderr("Please call %connect before listing jobs."); + return AzureClientError.NotConnected.ToExecutionResult(); + } + + var jobsList = await QuantumClient.Jobs.ListAsync(); + if (jobsList == null || jobsList.Count() == 0) + { + channel.Stderr("No jobs found in current Azure Quantum workspace."); + return AzureClientError.JobNotFound.ToExecutionResult(); + } + + return jobsList.ToJupyterTable().ToExecutionResult(); + } } } diff --git a/src/AzureClient/AzureClient.csproj b/src/AzureClient/AzureClient.csproj index e4da5c48b2..3b9d29e070 100644 --- a/src/AzureClient/AzureClient.csproj +++ b/src/AzureClient/AzureClient.csproj @@ -1,4 +1,4 @@ - + netstandard2.1 @@ -13,8 +13,9 @@ - - + + + diff --git a/src/AzureClient/Extensions.cs b/src/AzureClient/Extensions.cs index fbfe0a4a8e..7a8e950951 100644 --- a/src/AzureClient/Extensions.cs +++ b/src/AzureClient/Extensions.cs @@ -1,12 +1,18 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. #nullable enable using System; using System.Collections.Generic; -using System.Text; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Azure.Quantum.Client; +using Microsoft.Azure.Quantum.Client.Models; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Jupyter.Core; +using Microsoft.Quantum.Runtime; namespace Microsoft.Quantum.IQSharp.AzureClient { @@ -22,5 +28,96 @@ public static void AddAzureClient(this IServiceCollection services) { services.AddSingleton(); } + + /// + /// Encapsulates a given as the result of an execution. + /// + /// + /// The result of an IAzureClient API call. + /// + public static ExecutionResult ToExecutionResult(this AzureClientError azureClientError) => + new ExecutionResult + { + Status = ExecuteStatus.Error, + Output = azureClientError.ToDescription() + }; + + /// + /// Returns the string value of the for the given + /// enumeration value. + /// + /// + /// + public static string ToDescription(this AzureClientError azureClientError) + { + var attributes = azureClientError + .GetType() + .GetField(azureClientError.ToString()) + .GetCustomAttributes(typeof(DescriptionAttribute), false) as DescriptionAttribute[]; + return attributes?.Length > 0 ? attributes[0].Description : string.Empty; + } + + /// + /// Encapsulates a given as the result of an execution. + /// + /// + /// A task which will return the result of an IAzureClient API call. + /// + public static async Task ToExecutionResult(this Task task) => + (await task).ToExecutionResult(); + + internal static Table ToJupyterTable(this JobDetails jobDetails) => + new List { jobDetails }.ToJupyterTable(); + + internal static Table ToJupyterTable(this IEnumerable jobsList) => + new Table + { + Columns = new List<(string, Func)> + { + ("JobId", jobDetails => jobDetails.Id), + ("JobName", jobDetails => jobDetails.Name), + ("JobStatus", jobDetails => jobDetails.Status), + ("Provider", jobDetails => jobDetails.ProviderId), + ("Target", jobDetails => jobDetails.Target), + }, + Rows = jobsList.ToList() + }; + + internal static Table ToJupyterTable(this IQuantumMachineJob job) => + new Table + { + Columns = new List<(string, Func)> + { + ("JobId", job => job.Id), + ("JobStatus", job => job.Status), + ("JobUri", job => job.Uri.ToString()), + }, + Rows = new List() { job } + }; + + internal static Table ToJupyterTable(this IQuantumClient quantumClient) => + new Table + { + Columns = new List<(string, Func)> + { + ("SubscriptionId", quantumClient => quantumClient.SubscriptionId), + ("ResourceGroupName", quantumClient => quantumClient.ResourceGroupName), + ("WorkspaceName", quantumClient => quantumClient.WorkspaceName), + }, + Rows = new List() { quantumClient } + }; + + internal static Table ToJupyterTable(this IEnumerable providerStatusList) => + new Table + { + Columns = new List<(string, Func)> + { + ("TargetId", target => target.Id), + ("CurrentAvailability", target => target.CurrentAvailability), + ("AverageQueueTime", target => target.AverageQueueTime.ToString()), + ("StatusPage", target => target.StatusPage), + }, + Rows = providerStatusList.SelectMany(provider => provider.Targets).ToList() + }; } } diff --git a/src/AzureClient/IAzureClient.cs b/src/AzureClient/IAzureClient.cs index 8efd69e5a7..d98c420431 100644 --- a/src/AzureClient/IAzureClient.cs +++ b/src/AzureClient/IAzureClient.cs @@ -1,21 +1,117 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. #nullable enable -using System; -using System.Collections.Generic; -using System.Text; -using Microsoft.Jupyter.Core; +using System.ComponentModel; using System.Threading.Tasks; +using Microsoft.Jupyter.Core; namespace Microsoft.Quantum.IQSharp.AzureClient { + /// + /// Describes possible error results from methods. + /// + public enum AzureClientError + { + /// + /// Method completed with an unknown error. + /// + [Description(Resources.AzureClientErrorUnknownError)] + UnknownError = 0, + + /// + /// No connection has been made to any Azure Quantum workspace. + /// + [Description(Resources.AzureClientErrorNotConnected)] + NotConnected = 1, + + /// + /// A target has not yet been configured for job submission. + /// + [Description(Resources.AzureClientErrorNoTarget)] + NoTarget = 2, + + /// + /// A job meeting the specified criteria was not found. + /// + [Description(Resources.AzureClientErrorJobNotFound)] + JobNotFound = 3, + + /// + /// No Q# operation name was provided where one was required. + /// + [Description(Resources.AzureClientErrorNoOperationName)] + NoOperationName = 4, + + /// + /// Authentication with the Azure service failed. + /// + [Description(Resources.AzureClientErrorAuthenticationFailed)] + AuthenticationFailed = 5, + + /// + /// A workspace meeting the specified criteria was not found. + /// + [Description(Resources.AzureClientErrorWorkspaceNotFound)] + WorkspaceNotFound = 6, + } + /// /// This service is capable of connecting to Azure Quantum workspaces /// and submitting jobs. /// public interface IAzureClient { + /// + /// Connects to the specified Azure Quantum workspace, first logging into Azure if necessary. + /// + public Task ConnectAsync( + IChannel channel, + string subscriptionId, + string resourceGroupName, + string workspaceName, + string storageAccountConnectionString, + bool forceLogin = false); + + /// + /// Prints a string describing the current connection status. + /// + public Task PrintConnectionStatusAsync( + IChannel channel); + + /// + /// Submits the specified Q# operation as a job to the currently active target. + /// + public Task SubmitJobAsync( + IChannel channel, + IOperationResolver operationResolver, + string operationName); + + /// + /// Sets the specified target for job submission. + /// + public Task SetActiveTargetAsync( + IChannel channel, + string targetName); + + /// + /// Prints the list of targets currently provisioned in the current workspace. + /// + public Task PrintTargetListAsync( + IChannel channel); + + /// + /// Prints the job status corresponding to the given job ID. + /// + public Task PrintJobStatusAsync( + IChannel channel, + string jobId); + + /// + /// Prints a list of all jobs in the current workspace. + /// + public Task PrintJobListAsync( + IChannel channel); } } diff --git a/src/AzureClient/Magic/AzureClientMagicBase.cs b/src/AzureClient/Magic/AzureClientMagicBase.cs new file mode 100644 index 0000000000..f7eac3b5ce --- /dev/null +++ b/src/AzureClient/Magic/AzureClientMagicBase.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Threading.Tasks; +using Microsoft.Jupyter.Core; +using Microsoft.Quantum.IQSharp.Jupyter; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + /// + /// Base class used for Azure Client magic commands. + /// + public abstract class AzureClientMagicBase : AbstractMagic + { + /// + /// The object used by this magic command to interact with Azure. + /// + public IAzureClient AzureClient { get; } + + /// + /// Constructs the Azure Client magic command with the specified keyword + /// and documentation. + /// + /// The object used to interact with Azure. + /// The name used to invoke the magic command. + /// Documentation describing the usage of this magic command. + public AzureClientMagicBase(IAzureClient azureClient, string keyword, Documentation docs): + base(keyword, docs) + { + this.AzureClient = azureClient; + } + + /// + public override ExecutionResult Run(string input, IChannel channel) => + RunAsync(input, channel).GetAwaiter().GetResult(); + + /// + /// Executes the magic command functionality for the given input. + /// + public abstract Task RunAsync(string input, IChannel channel); + } +} diff --git a/src/AzureClient/Magic/ConnectMagic.cs b/src/AzureClient/Magic/ConnectMagic.cs new file mode 100644 index 0000000000..4a325a923b --- /dev/null +++ b/src/AzureClient/Magic/ConnectMagic.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Jupyter.Core; +using Microsoft.Quantum.IQSharp.Jupyter; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + /// + /// A magic command that can be used to connect to an Azure workspace. + /// + public class ConnectMagic : AzureClientMagicBase + { + private const string + ParameterNameLogin = "login", + ParameterNameStorageAccountConnectionString = "storageAccountConnectionString", + ParameterNameSubscriptionId = "subscriptionId", + ParameterNameResourceGroupName = "resourceGroupName", + ParameterNameWorkspaceName = "workspaceName"; + + /// + /// Constructs a new magic command given an IAzureClient object. + /// + public ConnectMagic(IAzureClient azureClient) : + base(azureClient, + "connect", + new Documentation + { + Summary = "Connects to an Azure workspace or displays current connection status.", + Description = @" + This magic command allows for connecting to an Azure Quantum workspace + as specified by a valid subscription ID, resource group name, workspace name, + and storage account connection string. + ".Dedent(), + Examples = new[] + { + @" + Print information about the current connection: + ``` + In []: %connect + Out[]: Connected to WORKSPACE_NAME + ``` + ".Dedent(), + + $@" + Connect to an Azure Quantum workspace: + ``` + In []: %connect {ParameterNameSubscriptionId}=SUBSCRIPTION_ID + {ParameterNameResourceGroupName}=RESOURCE_GROUP_NAME + {ParameterNameWorkspaceName}=WORKSPACE_NAME + {ParameterNameStorageAccountConnectionString}=CONNECTION_STRING + Out[]: Connected to WORKSPACE_NAME + ``` + ".Dedent(), + + $@" + Connect to an Azure Quantum workspace and force a credential prompt: + ``` + In []: %connect {ParameterNameLogin} + {ParameterNameSubscriptionId}=SUBSCRIPTION_ID + {ParameterNameResourceGroupName}=RESOURCE_GROUP_NAME + {ParameterNameWorkspaceName}=WORKSPACE_NAME + {ParameterNameStorageAccountConnectionString}=CONNECTION_STRING + Out[]: To sign in, use a web browser to open the page https://microsoft.com/devicelogin + and enter the code [login code] to authenticate. + Connected to WORKSPACE_NAME + ``` + Use the `{ParameterNameLogin}` option if you want to bypass any saved or cached + credentials when connecting to Azure. + ".Dedent() + } + }) {} + + /// + /// Connects to an Azure workspace given a subscription ID, resource group name, + /// workspace name, and connection string as a JSON-encoded object. + /// + public override async Task RunAsync(string input, IChannel channel) + { + var inputParameters = ParseInputParameters(input); + + var storageAccountConnectionString = inputParameters.DecodeParameter(ParameterNameStorageAccountConnectionString); + if (string.IsNullOrEmpty(storageAccountConnectionString)) + { + return await AzureClient.PrintConnectionStatusAsync(channel); + } + + var subscriptionId = inputParameters.DecodeParameter(ParameterNameSubscriptionId); + var resourceGroupName = inputParameters.DecodeParameter(ParameterNameResourceGroupName); + var workspaceName = inputParameters.DecodeParameter(ParameterNameWorkspaceName); + var forceLogin = inputParameters.DecodeParameter(ParameterNameLogin, defaultValue: false); + return await AzureClient.ConnectAsync( + channel, + subscriptionId, + resourceGroupName, + workspaceName, + storageAccountConnectionString, + forceLogin); + } + } +} \ No newline at end of file diff --git a/src/AzureClient/Magic/StatusMagic.cs b/src/AzureClient/Magic/StatusMagic.cs new file mode 100644 index 0000000000..d39c6927da --- /dev/null +++ b/src/AzureClient/Magic/StatusMagic.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Jupyter.Core; +using Microsoft.Quantum.IQSharp.Jupyter; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + /// + /// A magic command that can be used to connect to an Azure workspace. + /// + public class StatusMagic : AzureClientMagicBase + { + private const string + ParameterNameJobId = "jobId"; + + /// + /// Constructs a new magic command given an IAzureClient object. + /// + public StatusMagic(IAzureClient azureClient) : + base(azureClient, + "status", + new Documentation + { + Summary = "Displays status for jobs in the current Azure Quantum workspace.", + Description = @" + This magic command allows for displaying status of jobs in the current + Azure Quantum workspace. If a valid job ID is provided as an argument, the + detailed status of that job will be displayed; otherwise, a list of all jobs + created in the current session will be displayed. + ".Dedent(), + Examples = new[] + { + @" + Print status about a specific job: + ``` + In []: %status JOB_ID + Out[]: JOB_ID: + ``` + ".Dedent(), + + @" + Print status about all jobs created in the current session: + ``` + In []: %status + Out[]: + ``` + ".Dedent() + } + }) {} + + /// + /// Displays the status corresponding to a given job ID, if provided, + /// or all jobs in the active workspace. + /// + public override async Task RunAsync(string input, IChannel channel) + { + var inputParameters = ParseInputParameters(input, firstParameterInferredName: ParameterNameJobId); + if (inputParameters.ContainsKey(ParameterNameJobId)) + { + string jobId = inputParameters.DecodeParameter(ParameterNameJobId); + return await AzureClient.PrintJobStatusAsync(channel, jobId); + } + + return await AzureClient.PrintJobListAsync(channel); + } + } +} \ No newline at end of file diff --git a/src/AzureClient/Magic/SubmitMagic.cs b/src/AzureClient/Magic/SubmitMagic.cs new file mode 100644 index 0000000000..d89da233b3 --- /dev/null +++ b/src/AzureClient/Magic/SubmitMagic.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Jupyter.Core; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + /// + /// A magic command that can be used to submit jobs to an Azure Quantum workspace. + /// + public class SubmitMagic : AzureClientMagicBase + { + /// + /// The symbol resolver used by this magic command to find + /// operations or functions to be simulated. + /// + public IOperationResolver OperationResolver { get; } + + /// + /// Constructs a new magic command given a resolver used to find + /// operations and functions and an IAzureClient object. + /// + public SubmitMagic(IOperationResolver operationResolver, IAzureClient azureClient) : + base(azureClient, + "submit", + new Documentation + { + Summary = "Submits a job to an Azure Quantum workspace.", + Description = @" + This magic command allows for submitting a job to an Azure Quantum workspace + corresponding to the Q# operation provided as an argument. + + The Azure Quantum workspace must previously have been initialized + using the %connect magic command. + ".Dedent(), + Examples = new[] + { + @" + Submit an operation as a new job to the current Azure Quantum workspace: + ``` + In []: %submit OPERATION_NAME + Out[]: Submitted job JOB_ID + ``` + ".Dedent(), + } + }) => + this.OperationResolver = operationResolver; + + /// + /// Submits a new job to an Azure Quantum workspace given a Q# operation + /// name that is present in the current Q# Jupyter workspace. + /// + public override async Task RunAsync(string input, IChannel channel) + { + Dictionary keyValuePairs = ParseInputParameters(input); + var operationName = keyValuePairs.Keys.FirstOrDefault(); + return await AzureClient.SubmitJobAsync(channel, OperationResolver, operationName); + } + } +} \ No newline at end of file diff --git a/src/AzureClient/Magic/TargetMagic.cs b/src/AzureClient/Magic/TargetMagic.cs new file mode 100644 index 0000000000..4108009cc6 --- /dev/null +++ b/src/AzureClient/Magic/TargetMagic.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Jupyter.Core; +using Microsoft.Quantum.IQSharp.Jupyter; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + /// + /// A magic command that can be used to view or set target information for an Azure Quantum workspace. + /// + public class TargetMagic : AzureClientMagicBase + { + private const string + ParameterNameTargetName = "name"; + + /// + /// Constructs a new magic command given an IAzureClient object. + /// + public TargetMagic(IAzureClient azureClient) : + base(azureClient, + "target", + new Documentation + { + Summary = "Views or sets the target for job submission to an Azure Quantum workspace.", + Description = @" + This magic command allows for specifying a target for job submission + to an Azure Quantum workspace, or viewing the list of all available targets. + + The Azure Quantum workspace must previously have been initialized + using the %connect magic command, and the specified target must be + available in the workspace. + ".Dedent(), + Examples = new[] + { + @" + Set the current target for job submission: + ``` + In []: %target TARGET_NAME + Out[]: Active target is now TARGET_NAME + ``` + ".Dedent(), + @" + View the current target and all available targets in the current Azure Quantum workspace: + ``` + In []: %target + Out[]: + ``` + ".Dedent(), + } + }) + { + } + + /// + /// Sets or views the target for job submission to the current Azure Quantum workspace. + /// + public override async Task RunAsync(string input, IChannel channel) + { + var inputParameters = ParseInputParameters(input, firstParameterInferredName: ParameterNameTargetName); + if (inputParameters.ContainsKey(ParameterNameTargetName)) + { + string targetName = inputParameters.DecodeParameter(ParameterNameTargetName); + return await AzureClient.SetActiveTargetAsync(channel, targetName); + } + + return await AzureClient.PrintTargetListAsync(channel); + } + } +} \ No newline at end of file diff --git a/src/AzureClient/Resources.cs b/src/AzureClient/Resources.cs new file mode 100644 index 0000000000..6c3de47cc0 --- /dev/null +++ b/src/AzureClient/Resources.cs @@ -0,0 +1,31 @@ +#nullable enable + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + /// + /// This class contains resources that will eventually be exposed to localization. + /// + internal static class Resources + { + public const string AzureClientErrorUnknownError = + "An unknown error occurred."; + + public const string AzureClientErrorNotConnected = + "Not connected to any Azure Quantum workspace."; + + public const string AzureClientErrorNoTarget = + "No execution target has been configured for Azure Quantum job submission."; + + public const string AzureClientErrorJobNotFound = + "No job with the given ID was found in the current Azure Quantum workspace."; + + public const string AzureClientErrorNoOperationName = + "No Q# operation name was specified for Azure Quantum job submission."; + + public const string AzureClientErrorAuthenticationFailed = + "Failed to authenticate to the specified Azure Quantum workspace."; + + public const string AzureClientErrorWorkspaceNotFound = + "No Azure Quantum workspace was found that matches the specified criteria."; + } +} diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 249d061dcd..b67e57c334 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -34,9 +34,9 @@ - - - + + + diff --git a/src/Jupyter/Extensions.cs b/src/Jupyter/Extensions.cs index 5a602d89d4..b016d9c328 100644 --- a/src/Jupyter/Extensions.cs +++ b/src/Jupyter/Extensions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +#nullable enable + using System; using System.Collections.Generic; using System.Collections.Immutable; @@ -9,6 +11,7 @@ using Microsoft.Quantum.Simulation.Common; using Microsoft.Quantum.Simulation.Core; using Microsoft.Quantum.Simulation.Simulators; +using Newtonsoft.Json; namespace Microsoft.Quantum.IQSharp.Jupyter { @@ -174,5 +177,17 @@ public static string Dedent(this string text) var leftTrimRegex = new Regex(@$"^[ \t]{{{minWhitespace}}}", RegexOptions.Multiline); return leftTrimRegex.Replace(text, ""); } + + /// + /// Retrieves and JSON-decodes the value for the given parameter name. + /// + public static T DecodeParameter(this Dictionary parameters, string parameterName, T defaultValue = default) + { + if (!parameters.TryGetValue(parameterName, out string parameterValue)) + { + return defaultValue; + } + return (T)(JsonConvert.DeserializeObject(parameterValue)) ?? defaultValue; + } } } diff --git a/src/Kernel/Magic/AbstractMagic.cs b/src/Jupyter/Magic/AbstractMagic.cs similarity index 51% rename from src/Kernel/Magic/AbstractMagic.cs rename to src/Jupyter/Magic/AbstractMagic.cs index 9babaf2ca1..1b0cd24c3a 100644 --- a/src/Kernel/Magic/AbstractMagic.cs +++ b/src/Jupyter/Magic/AbstractMagic.cs @@ -3,12 +3,15 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Linq; using System.Threading.Tasks; using Microsoft.Jupyter.Core; using Microsoft.Quantum.IQSharp.Common; -using Microsoft.Quantum.IQSharp.Kernel; +using Microsoft.Quantum.QsCompiler.Serialization; +using Newtonsoft.Json.Linq; -namespace Microsoft.Quantum.IQSharp.Kernel +namespace Microsoft.Quantum.IQSharp.Jupyter { /// /// Abstract base class for IQ# magic symbols. @@ -78,6 +81,67 @@ public static (string, Dictionary) ParseInput(string input) return (name, args); } + /// + /// Parses the input to a magic command, interpreting the input as + /// a name followed by a JSON-serialized dictionary. + /// + public static Dictionary JsonToDict(string input) => + !string.IsNullOrEmpty(input) ? JsonConverters.JsonToDict(input) : new Dictionary { }; + + /// + /// Parses the input parameters for a given magic symbol and returns a + /// Dictionary with the names and values of the parameters, + /// where the values of the Dictionary are JSON-serialized objects. + /// + public Dictionary ParseInputParameters(string input, string firstParameterInferredName = "") + { + Dictionary inputParameters = new Dictionary(); + + var args = input.Split(null as char[], StringSplitOptions.RemoveEmptyEntries); + + // If we are expecting a first inferred-name parameter, see if it exists. + // If so, serialize it to the dictionary as JSON and remove it from the list of args. + if (args.Length > 0 && + !args[0].StartsWith("{") && + !args[0].Contains("=") && + !string.IsNullOrEmpty(firstParameterInferredName)) + { + using (var writer = new StringWriter()) + { + Json.Serializer.Serialize(writer, args[0]); + inputParameters[firstParameterInferredName] = writer.ToString(); + } + args = args.Where((_, index) => index != 0).ToArray(); + } + + // See if the remaining arguments look like JSON. If so, try to parse as JSON. + // Otherwise, try to parse as key=value pairs and serialize into the dictionary as JSON. + if (args.Length > 0 && args[0].StartsWith("{")) + { + var jsonArgs = JsonToDict(string.Join(" ", args)); + foreach (var (key, jsonValue) in jsonArgs) + { + inputParameters[key] = jsonValue; + } + } + else + { + foreach (string arg in args) + { + var tokens = arg.Split("=", 2); + var key = tokens[0].Trim(); + var value = (tokens.Length == 1) ? true as object : tokens[1].Trim() as object; + using (var writer = new StringWriter()) + { + Json.Serializer.Serialize(writer, value); + inputParameters[key] = writer.ToString(); + } + } + } + + return inputParameters; + } + /// /// A method to be run when the magic command is executed. /// diff --git a/src/Kernel/Magic/LsMagicMagic.cs b/src/Kernel/Magic/LsMagicMagic.cs index 891d2f92b6..05e1037e00 100644 --- a/src/Kernel/Magic/LsMagicMagic.cs +++ b/src/Kernel/Magic/LsMagicMagic.cs @@ -5,7 +5,7 @@ using Microsoft.Jupyter.Core; using Microsoft.Quantum.IQSharp; -using Microsoft.Quantum.IQSharp.Kernel; +using Microsoft.Quantum.IQSharp.Jupyter; namespace Microsoft.Quantum.IQSharp.Kernel { diff --git a/src/Kernel/Magic/Simulate.cs b/src/Kernel/Magic/Simulate.cs index d3cea8b17a..2b94eb9b06 100644 --- a/src/Kernel/Magic/Simulate.cs +++ b/src/Kernel/Magic/Simulate.cs @@ -18,6 +18,9 @@ namespace Microsoft.Quantum.IQSharp.Kernel /// public class SimulateMagic : AbstractMagic { + private const string + ParameterNameOperationName = "operationName"; + /// /// Constructs a new magic command given a resolver used to find /// operations and functions, and a configuration source used to set @@ -55,15 +58,16 @@ public override ExecutionResult Run(string input, IChannel channel) => /// public async Task RunAsync(string input, IChannel channel) { - var (name, args) = ParseInput(input); + var inputParameters = ParseInputParameters(input, firstParameterInferredName: ParameterNameOperationName); + var name = inputParameters.DecodeParameter(ParameterNameOperationName); var symbol = SymbolResolver.Resolve(name) as IQSharpSymbol; if (symbol == null) throw new InvalidOperationException($"Invalid operation name: {name}"); using var qsim = new QuantumSimulator() .WithJupyterDisplay(channel, ConfigurationSource) .WithStackTraceDisplay(channel); - var value = await symbol.Operation.RunAsync(qsim, args); + var value = await symbol.Operation.RunAsync(qsim, inputParameters); return value.ToExecutionResult(); } } diff --git a/src/Kernel/Magic/WhoMagic.cs b/src/Kernel/Magic/WhoMagic.cs index 7efadeddb5..9a8c514f4d 100644 --- a/src/Kernel/Magic/WhoMagic.cs +++ b/src/Kernel/Magic/WhoMagic.cs @@ -5,7 +5,7 @@ using Microsoft.Jupyter.Core; using Microsoft.Quantum.IQSharp; -using Microsoft.Quantum.IQSharp.Kernel; +using Microsoft.Quantum.IQSharp.Jupyter; namespace Microsoft.Quantum.IQSharp.Kernel { diff --git a/src/Kernel/Magic/WorkspaceMagic.cs b/src/Kernel/Magic/WorkspaceMagic.cs index 0f347295d3..7ef9ee2996 100644 --- a/src/Kernel/Magic/WorkspaceMagic.cs +++ b/src/Kernel/Magic/WorkspaceMagic.cs @@ -5,7 +5,7 @@ using Microsoft.Jupyter.Core; using Microsoft.Quantum.IQSharp.Common; -using Microsoft.Quantum.IQSharp.Kernel; +using Microsoft.Quantum.IQSharp.Jupyter; namespace Microsoft.Quantum.IQSharp.Kernel { diff --git a/src/Python/qsharp/azure.py b/src/Python/qsharp/azure.py new file mode 100644 index 0000000000..2182a20b30 --- /dev/null +++ b/src/Python/qsharp/azure.py @@ -0,0 +1,47 @@ +#!/bin/env python +# -*- coding: utf-8 -*- +## +# azure.py: enables using Q# quantum execution on Azure Quantum from Python. +## +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +## + +## IMPORTS ## + +import qsharp +import json +import typing +from typing import List, Dict, Callable, Any + +from qsharp.serialization import map_tuples +from typing import List, Tuple, Dict, Iterable +from enum import Enum + +## LOGGING ## + +import logging +logger = logging.getLogger(__name__) + +## EXPORTS ## + +__all__ = [ + 'connect', + 'target', + 'submit', + 'status' +] + +## FUNCTIONS ## + +def connect(**params) -> Any: + return qsharp.client._execute_magic(f"connect", raise_on_stderr=False, **params) + +def target(name : str = '', **params) -> Any: + return qsharp.client._execute_magic(f"target {name}", raise_on_stderr=False, **params) + +def submit(op, **params) -> Any: + return qsharp.client._execute_callable_magic("submit", op, raise_on_stderr=False, **params) + +def status(jobId : str = '', **params) -> Any: + return qsharp.client._execute_magic(f"status {jobId}", raise_on_stderr=False, **params) diff --git a/src/Tests/AzureClientMagicTests.cs b/src/Tests/AzureClientMagicTests.cs new file mode 100644 index 0000000000..730554ede8 --- /dev/null +++ b/src/Tests/AzureClientMagicTests.cs @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable enable + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Jupyter.Core; +using Microsoft.Quantum.IQSharp; +using Microsoft.Quantum.IQSharp.AzureClient; + +namespace Tests.IQSharp +{ + public static class AzureClientMagicTestExtensions + { + public static void Test(this MagicSymbol magic, string input, ExecuteStatus expected = ExecuteStatus.Ok) + { + var result = magic.Execute(input, new MockChannel()).GetAwaiter().GetResult(); + Assert.IsTrue(result.Status == expected); + } + } + + [TestClass] + public class AzureClientMagicTests + { + private readonly string subscriptionId = "TEST_SUBSCRIPTION_ID"; + private readonly string resourceGroupName = "TEST_RESOURCE_GROUP_NAME"; + private readonly string workspaceName = "TEST_WORKSPACE_NAME"; + private readonly string storageAccountConnectionString = "TEST_CONNECTION_STRING"; + private readonly string jobId = "TEST_JOB_ID"; + private readonly string operationName = "TEST_OPERATION_NAME"; + private readonly string targetName = "TEST_TARGET_NAME"; + + [TestMethod] + public void TestConnectMagic() + { + var azureClient = new MockAzureClient(); + var connectMagic = new ConnectMagic(azureClient); + + // unrecognized input + connectMagic.Test($"invalid"); + Assert.AreEqual(azureClient.LastAction, AzureClientAction.PrintConnectionStatus); + + // valid input + connectMagic.Test( + @$"subscriptionId={subscriptionId} + resourceGroupName={resourceGroupName} + workspaceName={workspaceName} + storageAccountConnectionString={storageAccountConnectionString}"); + Assert.AreEqual(azureClient.LastAction, AzureClientAction.Connect); + Assert.IsFalse(azureClient.ForceLogin); + Assert.AreEqual(azureClient.ConnectionString, storageAccountConnectionString); + + // valid input with forced login + connectMagic.Test( + @$"login subscriptionId={subscriptionId} + resourceGroupName={resourceGroupName} + workspaceName={workspaceName} + storageAccountConnectionString={storageAccountConnectionString}"); + + Assert.IsTrue(azureClient.ForceLogin); + } + + [TestMethod] + public void TestStatusMagic() + { + // no arguments - should print job list + var azureClient = new MockAzureClient(); + var statusMagic = new StatusMagic(azureClient); + statusMagic.Test(string.Empty); + Assert.AreEqual(azureClient.LastAction, AzureClientAction.PrintJobList); + + // single argument - should print job status + azureClient = new MockAzureClient(); + statusMagic = new StatusMagic(azureClient); + statusMagic.Test($"{jobId}"); + Assert.AreEqual(azureClient.LastAction, AzureClientAction.PrintJobStatus); + } + + [TestMethod] + public void TestSubmitMagic() + { + // no arguments + var azureClient = new MockAzureClient(); + var operationResolver = new MockOperationResolver(); + var submitMagic = new SubmitMagic(operationResolver, azureClient); + submitMagic.Test(string.Empty); + Assert.AreEqual(azureClient.LastAction, AzureClientAction.SubmitJob); + + // single argument + submitMagic.Test($"{operationName}"); + Assert.AreEqual(azureClient.LastAction, AzureClientAction.SubmitJob); + Assert.IsTrue(azureClient.SubmittedJobs.Contains(operationName)); + } + + [TestMethod] + public void TestTargetMagic() + { + // no arguments - should print target list + var azureClient = new MockAzureClient(); + var targetMagic = new TargetMagic(azureClient); + targetMagic.Test(string.Empty); + Assert.AreEqual(azureClient.LastAction, AzureClientAction.PrintTargetList); + + // single argument - should set active target + azureClient = new MockAzureClient(); + targetMagic = new TargetMagic(azureClient); + targetMagic.Test(targetName); + Assert.AreEqual(azureClient.LastAction, AzureClientAction.SetActiveTarget); + } + } + + internal enum AzureClientAction + { + None, + Connect, + SetActiveTarget, + SubmitJob, + PrintConnectionStatus, + PrintJobList, + PrintJobStatus, + PrintTargetList, + } + + public class MockAzureClient : IAzureClient + { + internal AzureClientAction LastAction = AzureClientAction.None; + internal string ConnectionString = string.Empty; + internal bool ForceLogin = false; + internal string ActiveTargetName = string.Empty; + internal List SubmittedJobs = new List(); + + public async Task SetActiveTargetAsync(IChannel channel, string targetName) + { + LastAction = AzureClientAction.SetActiveTarget; + ActiveTargetName = targetName; + return ExecuteStatus.Ok.ToExecutionResult(); + } + + public async Task SubmitJobAsync(IChannel channel, IOperationResolver operationResolver, string operationName) + { + LastAction = AzureClientAction.SubmitJob; + SubmittedJobs.Add(operationName); + return ExecuteStatus.Ok.ToExecutionResult(); + } + + public async Task ConnectAsync(IChannel channel, string subscriptionId, string resourceGroupName, string workspaceName, string storageAccountConnectionString, bool forceLogin) + { + LastAction = AzureClientAction.Connect; + ConnectionString = storageAccountConnectionString; + ForceLogin = forceLogin; + return ExecuteStatus.Ok.ToExecutionResult(); + } + + public async Task PrintConnectionStatusAsync(IChannel channel) + { + LastAction = AzureClientAction.PrintConnectionStatus; + return ExecuteStatus.Ok.ToExecutionResult(); + } + + public async Task PrintJobListAsync(IChannel channel) + { + LastAction = AzureClientAction.PrintJobList; + return ExecuteStatus.Ok.ToExecutionResult(); + } + + public async Task PrintJobStatusAsync(IChannel channel, string jobId) + { + LastAction = AzureClientAction.PrintJobStatus; + return ExecuteStatus.Ok.ToExecutionResult(); + } + + public async Task PrintTargetListAsync(IChannel channel) + { + LastAction = AzureClientAction.PrintTargetList; + return ExecuteStatus.Ok.ToExecutionResult(); + } + } +} diff --git a/src/Tests/AzureClientTests.cs b/src/Tests/AzureClientTests.cs index ba18985f1c..6c5eb88334 100644 --- a/src/Tests/AzureClientTests.cs +++ b/src/Tests/AzureClientTests.cs @@ -4,19 +4,37 @@ #nullable enable using Microsoft.VisualStudio.TestTools.UnitTesting; -using Microsoft.Quantum.IQSharp.AzureClient; using System.Linq; -using System.Collections.Generic; +using Microsoft.Jupyter.Core; +using Microsoft.Quantum.IQSharp.AzureClient; namespace Tests.IQSharp { + public static class AzureClientTestExtensions + { + } + [TestClass] public class AzureClientTests { + private readonly string subscriptionId = "TEST_SUBSCRIPTION_ID"; + private readonly string resourceGroupName = "TEST_RESOURCE_GROUP_NAME"; + private readonly string workspaceName = "TEST_WORKSPACE_NAME"; + private readonly string storageAccountConnectionString = "TEST_CONNECTION_STRING"; + private readonly string jobId = "TEST_JOB_ID"; + private readonly string operationName = "TEST_OPERATION_NAME"; + private readonly string targetName = "TEST_TARGET_NAME"; + [TestMethod] - public void TestNothing() + public void TestTargets() { - Assert.IsTrue(true); + var azureClient = new AzureClient(); + + var result = azureClient.SetActiveTargetAsync(new MockChannel(), targetName).GetAwaiter().GetResult(); + Assert.IsTrue(result.Status == ExecuteStatus.Ok); + + result = azureClient.PrintTargetListAsync(new MockChannel()).GetAwaiter().GetResult(); + Assert.IsTrue(result.Status == ExecuteStatus.Error); } } } diff --git a/src/Tests/IQsharpEngineTests.cs b/src/Tests/IQsharpEngineTests.cs index bd9d9b0f64..a4449ada95 100644 --- a/src/Tests/IQsharpEngineTests.cs +++ b/src/Tests/IQsharpEngineTests.cs @@ -15,6 +15,7 @@ using Newtonsoft.Json; using System.Data; +using Microsoft.Quantum.IQSharp.AzureClient; #pragma warning disable VSTHRD200 // Use "Async" suffix for async methods @@ -447,6 +448,12 @@ public void TestResolveMagic() symbol = resolver.Resolve("%foo"); Assert.IsNull(symbol); + + // AzureClient-provided commands + Assert.IsNotNull(resolver.Resolve("%connect")); + Assert.IsNotNull(resolver.Resolve("%status")); + Assert.IsNotNull(resolver.Resolve("%submit")); + Assert.IsNotNull(resolver.Resolve("%target")); } } } diff --git a/src/Tests/Mocks.cs b/src/Tests/Mocks.cs index 11fcb4f645..84d06abe64 100644 --- a/src/Tests/Mocks.cs +++ b/src/Tests/Mocks.cs @@ -117,4 +117,12 @@ public IUpdatableDisplay DisplayUpdatable(object displayable) public void Stdout(string message) => msgs.Add(message); } + + public class MockOperationResolver : IOperationResolver + { + public OperationInfo Resolve(string input) + { + return new OperationInfo(null, null); + } + } } diff --git a/src/Tests/Startup.cs b/src/Tests/Startup.cs index c370d67d10..bc6ac12d15 100644 --- a/src/Tests/Startup.cs +++ b/src/Tests/Startup.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Options; using Microsoft.Jupyter.Core; using Microsoft.Quantum.IQSharp; +using Microsoft.Quantum.IQSharp.AzureClient; using Microsoft.Quantum.IQSharp.Kernel; namespace Tests.IQSharp @@ -34,6 +35,7 @@ internal static ServiceProvider CreateServiceProvider(string workspaceFolder) services.AddTelemetry(); services.AddIQSharp(); services.AddIQSharpKernel(); + services.AddAzureClient(); var serviceProvider = services.BuildServiceProvider(); serviceProvider.GetRequiredService(); diff --git a/src/Tool/appsettings.json b/src/Tool/appsettings.json index 44d378812c..5c8675d093 100644 --- a/src/Tool/appsettings.json +++ b/src/Tool/appsettings.json @@ -6,18 +6,18 @@ }, "AllowedHosts": "*", "DefaultPackageVersions": [ - "Microsoft.Quantum.Compiler::0.11.2004.2825", + "Microsoft.Quantum.Compiler::0.11.2005.1420-beta", - "Microsoft.Quantum.CsharpGeneration::0.11.2004.2825", - "Microsoft.Quantum.Development.Kit::0.11.2004.2825", - "Microsoft.Quantum.Simulators::0.11.2004.2825", - "Microsoft.Quantum.Xunit::0.11.2004.2825", + "Microsoft.Quantum.CsharpGeneration::0.11.2005.1420-beta", + "Microsoft.Quantum.Development.Kit::0.11.2005.1420-beta", + "Microsoft.Quantum.Simulators::0.11.2005.1420-beta", + "Microsoft.Quantum.Xunit::0.11.2005.1420-beta", - "Microsoft.Quantum.Standard::0.11.2004.2825", - "Microsoft.Quantum.Chemistry::0.11.2004.2825", - "Microsoft.Quantum.Chemistry.Jupyter::0.11.2004.2825", - "Microsoft.Quantum.Numerics::0.11.2004.2825", + "Microsoft.Quantum.Standard::0.11.2005.1420-beta", + "Microsoft.Quantum.Chemistry::0.11.2005.1420-beta", + "Microsoft.Quantum.Chemistry.Jupyter::0.11.2005.1420-beta", + "Microsoft.Quantum.Numerics::0.11.2005.1420-beta", - "Microsoft.Quantum.Research::0.11.2004.2825" + "Microsoft.Quantum.Research::0.11.2005.1420-beta" ] }