diff --git a/build/manifest.ps1 b/build/manifest.ps1 index fd8da939e7..3739c01e54 100644 --- a/build/manifest.ps1 +++ b/build/manifest.ps1 @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + #!/usr/bin/env pwsh #Requires -PSEdition Core @@ -11,6 +14,7 @@ ); Assemblies = @( "./src/Tool/bin/$Env:BUILD_CONFIGURATION/netcoreapp3.1/Microsoft.Quantum.IQSharp.dll", + "./src/Tool/bin/$Env:BUILD_CONFIGURATION/netcoreapp3.1/Microsoft.Quantum.IQSharp.AzureClient.dll", "./src/Tool/bin/$Env:BUILD_CONFIGURATION/netcoreapp3.1/Microsoft.Quantum.IQSharp.Core.dll", "./src/Tool/bin/$Env:BUILD_CONFIGURATION/netcoreapp3.1/Microsoft.Quantum.IQSharp.Jupyter.dll", "./src/Tool/bin/$Env:BUILD_CONFIGURATION/netcoreapp3.1/Microsoft.Quantum.IQSharp.Kernel.dll", diff --git a/build/steps.yml b/build/steps.yml index 951478c664..cbf77cecee 100644 --- a/build/steps.yml +++ b/build/steps.yml @@ -24,6 +24,11 @@ steps: - pwsh: .\build.ps1 displayName: "Building IQ#" workingDirectory: '$(System.DefaultWorkingDirectory)/build' + +- pwsh: .\manifest.ps1 + displayName: "List built assemblies" + workingDirectory: '$(System.DefaultWorkingDirectory)/build' + condition: succeededOrFailed() - pwsh: .\test.ps1 displayName: "Testing IQ#" diff --git a/build/test.ps1 b/build/test.ps1 index 7a0c737a21..2dd318fe77 100644 --- a/build/test.ps1 +++ b/build/test.ps1 @@ -41,7 +41,7 @@ function Test-Python { Write-Host "##[info]Testing Python inside $testFolder" Push-Location (Join-Path $PSScriptRoot $testFolder) python --version - pytest -v + pytest -v --log-level=Debug Pop-Location if ($LastExitCode -ne 0) { diff --git a/conda-recipes/iqsharp/build.ps1 b/conda-recipes/iqsharp/build.ps1 index f08bdeb1ee..6c76741f98 100644 --- a/conda-recipes/iqsharp/build.ps1 +++ b/conda-recipes/iqsharp/build.ps1 @@ -17,8 +17,6 @@ if ($IsWindows) { $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "../.."); $ArtifactRoot = Join-Path $RepoRoot "drops"; $SelfContainedDirectory = Join-Path $ArtifactRoot (Join-Path "selfcontained" $RuntimeID) -$NugetsDirectory = Join-Path $ArtifactRoot "nugets" -$NugetConfig = Resolve-Path (Join-Path $PSScriptRoot "NuGet.config"); $TargetDirectory = (Join-Path (Join-Path $Env:PREFIX "opt") "iqsharp"); diff --git a/conda-recipes/iqsharp/test.ps1 b/conda-recipes/iqsharp/test.ps1 index e1f14d46f0..c5cf7bd421 100644 --- a/conda-recipes/iqsharp/test.ps1 +++ b/conda-recipes/iqsharp/test.ps1 @@ -5,6 +5,18 @@ $failed = $false; $Env:IQSHARP_PACKAGE_SOURCE = "$Env:NUGET_OUTDIR" +# Add the prerelease NuGet feed if this isn't a release build. +if ("$Env:BUILD_RELEASETYPE" -ne "release") { + $NuGetDirectory = Resolve-Path ~ + Write-Host "## Writing prerelease NuGet config to $NuGetDirectory ##" + " + + + + + " | Out-File -FilePath $NuGetDirectory/NuGet.Config -Encoding utf8 +} + # Check that iqsharp is installed as a Jupyter kernel. $kernels = jupyter kernelspec list --json | ConvertFrom-Json; if ($null -eq $kernels.kernelspecs.iqsharp) { @@ -13,7 +25,7 @@ if ($null -eq $kernels.kernelspecs.iqsharp) { jupyter kernelspec list } - +# Run the kernel unit tests. Push-Location $PSScriptRoot python test.py if ($LastExitCode -ne 0) { diff --git a/iqsharp.sln b/iqsharp.sln index 67bfcd937c..e2bf548fb7 100644 --- a/iqsharp.sln +++ b/iqsharp.sln @@ -13,6 +13,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tool", "src\Tool\Tool.cspro EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Web", "src\Web\Web.csproj", "{6431E92B-12AA-432C-8D53-C9A7A54BA21B}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AzureClient", "src\AzureClient\AzureClient.csproj", "{E7B60C94-B666-4024-B53E-D12C142DE8DC}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jupyter", "src\Jupyter\Jupyter.csproj", "{19A9E2AB-8842-47E2-8E6A-6DD292B49E97}" EndProject Global @@ -85,6 +87,18 @@ Global {6431E92B-12AA-432C-8D53-C9A7A54BA21B}.Release|x64.Build.0 = Release|Any CPU {6431E92B-12AA-432C-8D53-C9A7A54BA21B}.Release|x86.ActiveCfg = Release|Any CPU {6431E92B-12AA-432C-8D53-C9A7A54BA21B}.Release|x86.Build.0 = Release|Any CPU + {E7B60C94-B666-4024-B53E-D12C142DE8DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E7B60C94-B666-4024-B53E-D12C142DE8DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E7B60C94-B666-4024-B53E-D12C142DE8DC}.Debug|x64.ActiveCfg = Debug|Any CPU + {E7B60C94-B666-4024-B53E-D12C142DE8DC}.Debug|x64.Build.0 = Debug|Any CPU + {E7B60C94-B666-4024-B53E-D12C142DE8DC}.Debug|x86.ActiveCfg = Debug|Any CPU + {E7B60C94-B666-4024-B53E-D12C142DE8DC}.Debug|x86.Build.0 = Debug|Any CPU + {E7B60C94-B666-4024-B53E-D12C142DE8DC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E7B60C94-B666-4024-B53E-D12C142DE8DC}.Release|Any CPU.Build.0 = Release|Any CPU + {E7B60C94-B666-4024-B53E-D12C142DE8DC}.Release|x64.ActiveCfg = Release|Any CPU + {E7B60C94-B666-4024-B53E-D12C142DE8DC}.Release|x64.Build.0 = Release|Any CPU + {E7B60C94-B666-4024-B53E-D12C142DE8DC}.Release|x86.ActiveCfg = Release|Any CPU + {E7B60C94-B666-4024-B53E-D12C142DE8DC}.Release|x86.Build.0 = Release|Any CPU {19A9E2AB-8842-47E2-8E6A-6DD292B49E97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {19A9E2AB-8842-47E2-8E6A-6DD292B49E97}.Debug|Any CPU.Build.0 = Debug|Any CPU {19A9E2AB-8842-47E2-8E6A-6DD292B49E97}.Debug|x64.ActiveCfg = Debug|Any CPU diff --git a/src/AzureClient/AzureClient.cs b/src/AzureClient/AzureClient.cs new file mode 100644 index 0000000000..0abb670a55 --- /dev/null +++ b/src/AzureClient/AzureClient.cs @@ -0,0 +1,406 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.Quantum; +using Microsoft.Azure.Quantum.Client.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Jupyter.Core; +using Microsoft.Quantum.IQSharp.Common; +using Microsoft.Quantum.Simulation.Common; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + /// + public class AzureClient : IAzureClient + { + internal IAzureWorkspace? ActiveWorkspace { get; private set; } + private ILogger Logger { get; } + private IReferences References { get; } + private IEntryPointGenerator EntryPointGenerator { get; } + private IMetadataController MetadataController { get; } + private bool IsPythonUserAgent => MetadataController?.UserAgent?.StartsWith("qsharp.py") ?? false; + private string ConnectionString { get; set; } = string.Empty; + private AzureExecutionTarget? ActiveTarget { get; set; } + private string MostRecentJobId { get; set; } = string.Empty; + private IEnumerable? AvailableProviders { get; set; } + private IEnumerable? AvailableTargets => AvailableProviders?.SelectMany(provider => provider.Targets); + private IEnumerable? ValidExecutionTargets => AvailableTargets?.Where(target => AzureExecutionTarget.IsValid(target.Id)); + private string ValidExecutionTargetsDisplayText => + ValidExecutionTargets == null + ? "(no execution targets available)" + : string.Join(", ", ValidExecutionTargets.Select(target => target.Id)); + + public AzureClient( + IExecutionEngine engine, + IReferences references, + IEntryPointGenerator entryPointGenerator, + IMetadataController metadataController, + ILogger logger, + IEventService eventService) + { + References = references; + EntryPointGenerator = entryPointGenerator; + MetadataController = metadataController; + Logger = logger; + eventService?.TriggerServiceInitialized(this); + + if (engine is BaseEngine baseEngine) + { + baseEngine.RegisterDisplayEncoder(new CloudJobToHtmlEncoder()); + baseEngine.RegisterDisplayEncoder(new CloudJobToTextEncoder()); + baseEngine.RegisterDisplayEncoder(new TargetStatusToHtmlEncoder()); + baseEngine.RegisterDisplayEncoder(new TargetStatusToTextEncoder()); + baseEngine.RegisterDisplayEncoder(new HistogramToHtmlEncoder()); + baseEngine.RegisterDisplayEncoder(new HistogramToTextEncoder()); + baseEngine.RegisterDisplayEncoder(new AzureClientErrorToHtmlEncoder()); + baseEngine.RegisterDisplayEncoder(new AzureClientErrorToTextEncoder()); + } + } + + /// + public async Task ConnectAsync(IChannel channel, + string subscriptionId, + string resourceGroupName, + string workspaceName, + string storageAccountConnectionString, + bool refreshCredentials = false) + { + var azureEnvironment = AzureEnvironment.Create(subscriptionId); + IAzureWorkspace? workspace = null; + try + { + workspace = await azureEnvironment.GetAuthenticatedWorkspaceAsync(channel, resourceGroupName, workspaceName, refreshCredentials); + } + catch (Exception e) + { + channel.Stderr($"The connection to the Azure Quantum workspace could not be completed. Please check the provided parameters and try again."); + channel.Stderr($"Error details: {e.Message}"); + return AzureClientError.WorkspaceNotFound.ToExecutionResult(); + } + + if (workspace == null) + { + return AzureClientError.AuthenticationFailed.ToExecutionResult(); + } + + var providers = await workspace.GetProvidersAsync(); + if (providers == null) + { + return AzureClientError.WorkspaceNotFound.ToExecutionResult(); + } + + ActiveWorkspace = workspace; + AvailableProviders = providers; + ConnectionString = storageAccountConnectionString; + ActiveTarget = null; + MostRecentJobId = string.Empty; + + channel.Stdout($"Connected to Azure Quantum workspace {ActiveWorkspace.Name}."); + + if (ValidExecutionTargets.Count() == 0) + { + channel.Stderr($"No valid Q# execution targets found in Azure Quantum workspace {ActiveWorkspace.Name}."); + } + + return ValidExecutionTargets.ToExecutionResult(); + } + + /// + public async Task GetConnectionStatusAsync(IChannel channel) + { + if (ActiveWorkspace == null || AvailableProviders == null) + { + return AzureClientError.NotConnected.ToExecutionResult(); + } + + channel.Stdout($"Connected to Azure Quantum workspace {ActiveWorkspace.Name}."); + + return ValidExecutionTargets.ToExecutionResult(); + } + + private async Task SubmitOrExecuteJobAsync( + IChannel channel, + AzureSubmissionContext submissionContext, + bool execute, + CancellationToken cancellationToken) + { + if (ActiveWorkspace == null) + { + channel.Stderr($"Please call {GetCommandDisplayName("connect")} before submitting a job."); + return AzureClientError.NotConnected.ToExecutionResult(); + } + + if (ActiveTarget == null) + { + channel.Stderr($"Please call {GetCommandDisplayName("target")} before submitting a job."); + return AzureClientError.NoTarget.ToExecutionResult(); + } + + if (string.IsNullOrEmpty(submissionContext.OperationName)) + { + channel.Stderr($"Please pass a valid Q# operation name to {GetCommandDisplayName(execute ? "execute" : "submit")}."); + return AzureClientError.NoOperationName.ToExecutionResult(); + } + + var machine = ActiveWorkspace.CreateQuantumMachine(ActiveTarget.TargetId, ConnectionString); + if (machine == null) + { + // We should never get here, since ActiveTarget should have already been validated at the time it was set. + channel.Stderr($"Unexpected error while preparing job for execution on target {ActiveTarget.TargetId}."); + return AzureClientError.InvalidTarget.ToExecutionResult(); + } + + channel.Stdout($"Submitting {submissionContext.OperationName} to target {ActiveTarget.TargetId}..."); + + IEntryPoint? entryPoint = null; + try + { + entryPoint = EntryPointGenerator.Generate(submissionContext.OperationName, ActiveTarget.TargetId); + } + catch (UnsupportedOperationException e) + { + channel.Stderr($"{submissionContext.OperationName} is not a recognized Q# operation name."); + return AzureClientError.UnrecognizedOperationName.ToExecutionResult(); + } + catch (CompilationErrorsException e) + { + channel.Stderr($"The Q# operation {submissionContext.OperationName} could not be compiled as an entry point for job execution."); + foreach (var message in e.Errors) channel.Stderr(message); + return AzureClientError.InvalidEntryPoint.ToExecutionResult(); + } + + try + { + var job = await entryPoint.SubmitAsync(machine, submissionContext); + channel.Stdout($"Job successfully submitted for {submissionContext.Shots} shots."); + channel.Stdout($" Job name: {submissionContext.FriendlyName}"); + channel.Stdout($" Job ID: {job.Id}"); + MostRecentJobId = job.Id; + } + catch (ArgumentException e) + { + channel.Stderr($"Failed to parse all expected parameters for Q# operation {submissionContext.OperationName}."); + channel.Stderr(e.Message); + return AzureClientError.JobSubmissionFailed.ToExecutionResult(); + } + catch (Exception e) + { + channel.Stderr($"Failed to submit Q# operation {submissionContext.OperationName} for execution."); + channel.Stderr(e.InnerException?.Message ?? e.Message); + return AzureClientError.JobSubmissionFailed.ToExecutionResult(); + } + + // If the command was not %azure.execute, simply return the job status. + if (!execute) + { + return await GetJobStatusAsync(channel, MostRecentJobId); + } + + // If the command was %azure.execute, wait for the job to complete and return the job output. + channel.Stdout($"Waiting up to {submissionContext.ExecutionTimeout} seconds for Azure Quantum job to complete..."); + + using var executionTimeoutTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(submissionContext.ExecutionTimeout)); + using var executionCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(executionTimeoutTokenSource.Token, cancellationToken); + { + try + { + CloudJob? cloudJob = null; + while (cloudJob == null || cloudJob.InProgress) + { + executionCancellationTokenSource.Token.ThrowIfCancellationRequested(); + await Task.Delay(TimeSpan.FromSeconds(submissionContext.ExecutionPollingInterval), executionCancellationTokenSource.Token); + cloudJob = await ActiveWorkspace.GetJobAsync(MostRecentJobId); + channel.Stdout($"[{DateTime.Now.ToLongTimeString()}] Current job status: {cloudJob?.Status ?? "Unknown"}"); + } + } + catch (Exception e) when (e is TaskCanceledException || e is OperationCanceledException) + { + Logger?.LogInformation($"Operation canceled while waiting for job execution to complete: {e.Message}"); + } + } + + return await GetJobResultAsync(channel, MostRecentJobId); + } + + /// + public async Task SubmitJobAsync(IChannel channel, AzureSubmissionContext submissionContext, CancellationToken? cancellationToken = null) => + await SubmitOrExecuteJobAsync(channel, submissionContext, execute: false, cancellationToken ?? CancellationToken.None); + + /// + public async Task ExecuteJobAsync(IChannel channel, AzureSubmissionContext submissionContext, CancellationToken? cancellationToken = null) => + await SubmitOrExecuteJobAsync(channel, submissionContext, execute: true, cancellationToken ?? CancellationToken.None); + + /// + public async Task GetActiveTargetAsync(IChannel channel) + { + if (AvailableProviders == null) + { + channel.Stderr($"Please call {GetCommandDisplayName("connect")} before getting the execution target."); + return AzureClientError.NotConnected.ToExecutionResult(); + } + + if (ActiveTarget == null) + { + channel.Stderr($"No execution target has been specified. To specify one, call {GetCommandDisplayName("target")} with the target ID."); + channel.Stdout($"Available execution targets: {ValidExecutionTargetsDisplayText}"); + return AzureClientError.NoTarget.ToExecutionResult(); + } + + channel.Stdout($"Current execution target: {ActiveTarget.TargetId}"); + channel.Stdout($"Available execution targets: {ValidExecutionTargetsDisplayText}"); + + return AvailableTargets.First(target => target.Id == ActiveTarget.TargetId).ToExecutionResult(); + } + + /// + public async Task SetActiveTargetAsync(IChannel channel, string targetId) + { + if (AvailableProviders == null) + { + channel.Stderr($"Please call {GetCommandDisplayName("connect")} before setting an execution target."); + return AzureClientError.NotConnected.ToExecutionResult(); + } + + // Validate that this target is valid in the workspace. + if (!AvailableTargets.Any(target => targetId == target.Id)) + { + channel.Stderr($"Target {targetId} is not available in the current Azure Quantum workspace."); + channel.Stdout($"Available execution targets: {ValidExecutionTargetsDisplayText}"); + return AzureClientError.InvalidTarget.ToExecutionResult(); + } + + // Validate that we know which package to load for this target. + var executionTarget = AzureExecutionTarget.Create(targetId); + if (executionTarget == null) + { + channel.Stderr($"Target {targetId} does not support executing Q# jobs."); + channel.Stdout($"Available execution targets: {ValidExecutionTargetsDisplayText}"); + return AzureClientError.InvalidTarget.ToExecutionResult(); + } + + // Set the active target and load the package. + ActiveTarget = executionTarget; + + channel.Stdout($"Loading package {ActiveTarget.PackageName} and dependencies..."); + await References.AddPackage(ActiveTarget.PackageName); + + channel.Stdout($"Active target is now {ActiveTarget.TargetId}"); + + return AvailableTargets.First(target => target.Id == ActiveTarget.TargetId).ToExecutionResult(); + } + + /// + public async Task GetJobResultAsync(IChannel channel, string jobId) + { + if (ActiveWorkspace == null) + { + channel.Stderr($"Please call {GetCommandDisplayName("connect")} before getting job results."); + return AzureClientError.NotConnected.ToExecutionResult(); + } + + if (string.IsNullOrEmpty(jobId)) + { + if (string.IsNullOrEmpty(MostRecentJobId)) + { + channel.Stderr("No job ID was specified. Please submit a job first or specify a job ID."); + return AzureClientError.JobNotFound.ToExecutionResult(); + } + + jobId = MostRecentJobId; + } + + var job = await ActiveWorkspace.GetJobAsync(jobId); + if (job == null) + { + channel.Stderr($"Job ID {jobId} not found in current Azure Quantum workspace."); + return AzureClientError.JobNotFound.ToExecutionResult(); + } + + if (!job.Succeeded || string.IsNullOrEmpty(job.Details.OutputDataUri)) + { + channel.Stderr($"Job ID {jobId} has not completed. To check the status, call {GetCommandDisplayName("status")} with the job ID."); + return AzureClientError.JobNotCompleted.ToExecutionResult(); + } + + try + { + var request = WebRequest.Create(job.Details.OutputDataUri); + using var responseStream = request.GetResponse().GetResponseStream(); + return responseStream.ToHistogram().ToExecutionResult(); + } + catch (Exception e) + { + channel.Stderr($"Failed to retrieve results for job ID {jobId}."); + Logger?.LogError(e, $"Failed to download the job output for the specified Azure Quantum job: {e.Message}"); + return AzureClientError.JobOutputDownloadFailed.ToExecutionResult(); + } + } + + /// + public async Task GetJobStatusAsync(IChannel channel, string jobId) + { + if (ActiveWorkspace == null) + { + channel.Stderr($"Please call {GetCommandDisplayName("connect")} before getting job status."); + return AzureClientError.NotConnected.ToExecutionResult(); + } + + if (string.IsNullOrEmpty(jobId)) + { + if (string.IsNullOrEmpty(MostRecentJobId)) + { + channel.Stderr("No job ID was specified. Please submit a job first or specify a job ID."); + return AzureClientError.JobNotFound.ToExecutionResult(); + } + + jobId = MostRecentJobId; + } + + var job = await ActiveWorkspace.GetJobAsync(jobId); + if (job == null) + { + channel.Stderr($"Job ID {jobId} not found in current Azure Quantum workspace."); + return AzureClientError.JobNotFound.ToExecutionResult(); + } + + return job.ToExecutionResult(); + } + + /// + public async Task GetJobListAsync(IChannel channel, string filter) + { + if (ActiveWorkspace == null) + { + channel.Stderr($"Please call {GetCommandDisplayName("connect")} before listing jobs."); + return AzureClientError.NotConnected.ToExecutionResult(); + } + + var jobs = await ActiveWorkspace.ListJobsAsync() ?? new List(); + if (jobs.Count() == 0) + { + channel.Stderr("No jobs found in current Azure Quantum workspace."); + } + else + { + jobs = jobs.Where(job => job.Matches(filter)); + if (jobs.Count() == 0) + { + channel.Stderr($"No jobs matching \"{filter}\" found in current Azure Quantum workspace."); + } + } + + return jobs.ToExecutionResult(); + } + + private string GetCommandDisplayName(string commandName) => + IsPythonUserAgent ? $"qsharp.azure.{commandName}()" : $"%azure.{commandName}"; + } +} diff --git a/src/AzureClient/AzureClient.csproj b/src/AzureClient/AzureClient.csproj new file mode 100644 index 0000000000..8d7b029f32 --- /dev/null +++ b/src/AzureClient/AzureClient.csproj @@ -0,0 +1,27 @@ + + + + netstandard2.1 + x64 + Microsoft.Quantum.IQSharp.AzureClient + Microsoft.Quantum.IQSharp.AzureClient + true + + + + + + + + + + + + + + + + + + + diff --git a/src/AzureClient/AzureClientError.cs b/src/AzureClient/AzureClientError.cs new file mode 100644 index 0000000000..a5ac286870 --- /dev/null +++ b/src/AzureClient/AzureClientError.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System.ComponentModel; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + /// + /// Describes possible error results from methods. + /// + public enum AzureClientError + { + /// + /// Method completed with an unknown error. + /// + [Description(Resources.AzureClientErrorUnknownError)] + UnknownError = 1000, + + /// + /// No connection has been made to any Azure Quantum workspace. + /// + [Description(Resources.AzureClientErrorNotConnected)] + NotConnected, + + /// + /// A target has not yet been configured for job submission. + /// + [Description(Resources.AzureClientErrorNoTarget)] + NoTarget, + + /// + /// The specified target is not valid for job submission. + /// + [Description(Resources.AzureClientErrorInvalidTarget)] + InvalidTarget, + + /// + /// A job meeting the specified criteria was not found. + /// + [Description(Resources.AzureClientErrorJobNotFound)] + JobNotFound, + + /// + /// The result of a job was requested, but the job has not yet completed. + /// + [Description(Resources.AzureClientErrorJobNotCompleted)] + JobNotCompleted, + + /// + /// The job output failed to be downloaded from the Azure storage location. + /// + [Description(Resources.AzureClientErrorJobOutputDownloadFailed)] + JobOutputDownloadFailed, + + /// + /// No Q# operation name was provided where one was required. + /// + [Description(Resources.AzureClientErrorNoOperationName)] + NoOperationName, + + /// + /// The specified Q# operation name is not recognized. + /// + [Description(Resources.AzureClientErrorUnrecognizedOperationName)] + UnrecognizedOperationName, + + /// + /// The specified Q# operation cannot be used as an entry point. + /// + [Description(Resources.AzureClientErrorInvalidEntryPoint)] + InvalidEntryPoint, + + /// + /// The Azure Quantum job submission failed. + /// + [Description(Resources.AzureClientErrorJobSubmissionFailed)] + JobSubmissionFailed, + + /// + /// Authentication with the Azure service failed. + /// + [Description(Resources.AzureClientErrorAuthenticationFailed)] + AuthenticationFailed, + + /// + /// A workspace meeting the specified criteria was not found. + /// + [Description(Resources.AzureClientErrorWorkspaceNotFound)] + WorkspaceNotFound, + } +} diff --git a/src/AzureClient/AzureEnvironment.cs b/src/AzureClient/AzureEnvironment.cs new file mode 100644 index 0000000000..66dc1de62d --- /dev/null +++ b/src/AzureClient/AzureEnvironment.cs @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Microsoft.Azure.Quantum.Client; +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.Extensions.Msal; +using Microsoft.Jupyter.Core; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + internal enum AzureEnvironmentType { Production, Canary, Dogfood, Mock }; + + internal class AzureEnvironment + { + public static string EnvironmentVariableName => "AZURE_QUANTUM_ENV"; + public AzureEnvironmentType Type { get; private set; } + + private string SubscriptionId { get; set; } = string.Empty; + private string ClientId { get; set; } = string.Empty; + private string Authority { get; set; } = string.Empty; + private List Scopes { get; set; } = new List(); + private Uri? BaseUri { get; set; } + + private AzureEnvironment() + { + } + + public static AzureEnvironment Create(string subscriptionId) + { + var azureEnvironmentName = System.Environment.GetEnvironmentVariable(EnvironmentVariableName); + + if (Enum.TryParse(azureEnvironmentName, true, out AzureEnvironmentType environmentType)) + { + switch (environmentType) + { + case AzureEnvironmentType.Production: + return Production(subscriptionId); + case AzureEnvironmentType.Canary: + return Canary(subscriptionId); + case AzureEnvironmentType.Dogfood: + return Dogfood(subscriptionId); + case AzureEnvironmentType.Mock: + return Mock(); + default: + throw new InvalidOperationException("Unexpected EnvironmentType value."); + } + } + + return Production(subscriptionId); + } + + public async Task GetAuthenticatedWorkspaceAsync(IChannel channel, string resourceGroupName, string workspaceName, bool refreshCredentials) + { + if (Type == AzureEnvironmentType.Mock) + { + channel.Stdout("AZURE_QUANTUM_ENV set to Mock. Using mock Azure workspace rather than connecting to the real service."); + return new MockAzureWorkspace(workspaceName); + } + + // Find the token cache folder + 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"); + } + + // Register the token cache for serialization + var cacheFileName = "aad.bin"; + var storageCreationProperties = new StorageCreationPropertiesBuilder(cacheFileName, cacheDirectory, ClientId).Build(); + var cacheHelper = await MsalCacheHelper.CreateAsync(storageCreationProperties); + var msalApp = PublicClientApplicationBuilder.Create(ClientId).WithAuthority(Authority).Build(); + cacheHelper.RegisterCache(msalApp.UserTokenCache); + + // Perform the authentication + bool shouldShowLoginPrompt = refreshCredentials; + AuthenticationResult? authenticationResult = null; + 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 null; + } + + // Construct and return the AzureWorkspace object + var credentials = new Rest.TokenCredentials(authenticationResult.AccessToken); + var azureQuantumClient = new QuantumClient(credentials) + { + SubscriptionId = SubscriptionId, + ResourceGroupName = resourceGroupName, + WorkspaceName = workspaceName, + BaseUri = BaseUri, + }; + var azureQuantumWorkspace = new Azure.Quantum.Workspace( + azureQuantumClient.SubscriptionId, + azureQuantumClient.ResourceGroupName, + azureQuantumClient.WorkspaceName, + authenticationResult?.AccessToken, + BaseUri); + + return new AzureWorkspace(azureQuantumClient, azureQuantumWorkspace); + } + + private static AzureEnvironment Production(string subscriptionId) => + new AzureEnvironment() + { + Type = AzureEnvironmentType.Production, + ClientId = "84ba0947-6c53-4dd2-9ca9-b3694761521b", // QDK client ID + Authority = "https://login.microsoftonline.com/common", + Scopes = new List() { "https://quantum.microsoft.com/Jobs.ReadWrite" }, + BaseUri = new Uri("https://app-jobscheduler-prod.azurewebsites.net/"), + SubscriptionId = subscriptionId, + }; + + private static AzureEnvironment Dogfood(string subscriptionId) => + new AzureEnvironment() + { + Type = AzureEnvironmentType.Dogfood, + ClientId = "46a998aa-43d0-4281-9cbb-5709a507ac36", // QDK dogfood client ID + Authority = GetDogfoodAuthority(subscriptionId), + Scopes = new List() { "api://dogfood.azure-quantum/Jobs.ReadWrite" }, + BaseUri = new Uri("https://app-jobscheduler-test.azurewebsites.net/"), + SubscriptionId = subscriptionId, + }; + + private static AzureEnvironment Canary(string subscriptionId) + { + var canary = Production(subscriptionId); + canary.Type = AzureEnvironmentType.Canary; + canary.BaseUri = new Uri("https://app-jobs-canarysouthcentralus.azurewebsites.net/"); + return canary; + } + + private static AzureEnvironment Mock() => + new AzureEnvironment() { Type = AzureEnvironmentType.Mock }; + + private static string GetDogfoodAuthority(string subscriptionId) + { + try + { + var armBaseUrl = "https://api-dogfood.resources.windows-int.net"; + var requestUrl = $"{armBaseUrl}/subscriptions/{subscriptionId}?api-version=2018-01-01"; + + WebResponse? response = null; + try + { + response = WebRequest.Create(requestUrl).GetResponse(); + } + catch (WebException webException) + { + response = webException.Response; + } + + var authHeader = response.Headers["WWW-Authenticate"]; + var headerParts = authHeader.Substring("Bearer ".Length).Split(','); + foreach (var headerPart in headerParts) + { + var parts = headerPart.Split("=", 2); + if (parts[0] == "authorization_uri") + { + var quotedAuthority = parts[1]; + return quotedAuthority[1..^1]; + } + } + + throw new InvalidOperationException($"Dogfood authority not found in ARM header response for subscription ID {subscriptionId}."); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to construct dogfood authority for subscription ID {subscriptionId}.", ex); + } + } + } +} diff --git a/src/AzureClient/AzureExecutionTarget.cs b/src/AzureClient/AzureExecutionTarget.cs new file mode 100644 index 0000000000..f2cebf24f0 --- /dev/null +++ b/src/AzureClient/AzureExecutionTarget.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + internal enum AzureProvider { IonQ, Honeywell, QCI } + + internal class AzureExecutionTarget + { + public string TargetId { get; private set; } = string.Empty; + public string PackageName => $"Microsoft.Quantum.Providers.{GetProvider(TargetId)}"; + + public static bool IsValid(string targetId) => GetProvider(targetId) != null; + + public static AzureExecutionTarget? Create(string targetId) => + IsValid(targetId) + ? new AzureExecutionTarget() { TargetId = targetId } + : null; + + /// + /// Gets the Azure Quantum provider corresponding to the given execution target. + /// + /// The Azure Quantum execution target ID. + /// The enum value representing the provider. + /// + /// Valid target IDs are structured as "provider.target". + /// For example, "ionq.simulator" or "honeywell.qpu". + /// + private static AzureProvider? GetProvider(string targetId) + { + var parts = targetId.Split('.', 2); + if (Enum.TryParse(parts[0], true, out AzureProvider provider)) + { + return provider; + } + + return null; + } + } +} diff --git a/src/AzureClient/AzureSubmissionContext.cs b/src/AzureClient/AzureSubmissionContext.cs new file mode 100644 index 0000000000..f299d90694 --- /dev/null +++ b/src/AzureClient/AzureSubmissionContext.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Quantum.IQSharp.Jupyter; +using Microsoft.Quantum.Runtime; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + /// + /// Represents the configuration settings for a job submission to Azure Quantum. + /// + public sealed class AzureSubmissionContext : IQuantumMachineSubmissionContext + { + private static readonly int DefaultShots = 500; + private static readonly int DefaultExecutionTimeoutInSeconds = 30; + private static readonly int DefaultExecutionPollingIntervalInSeconds = 5; + + internal static readonly string ParameterNameOperationName = "__operationName__"; + internal static readonly string ParameterNameJobName = "jobName"; + internal static readonly string ParameterNameShots = "shots"; + internal static readonly string ParameterNameTimeout = "timeout"; + internal static readonly string ParameterNamePollingInterval = "poll"; + + /// + public string FriendlyName { get; set; } = string.Empty; + + /// + public int Shots { get; set; } = DefaultShots; + + /// + /// The Q# operation name to be executed as part of this job. + /// + public string OperationName { get; set; } = string.Empty; + + /// + /// The input parameters to be provided to the specified Q# operation. + /// + public Dictionary InputParameters { get; set; } = new Dictionary(); + + /// + /// The execution timeout for the job, expressed in seconds. + /// + /// + /// This setting only applies to %azure.execute. It is ignored for %azure.submit. + /// The timeout determines how long the IQ# kernel will wait for the job to complete; + /// the Azure Quantum job itself will continue to execute until it is completed. + /// + public int ExecutionTimeout { get; set; } = DefaultExecutionTimeoutInSeconds; + + /// + /// The polling interval, in seconds, to check for job status updates + /// while waiting for an Azure Quantum job to complete execution. + /// + /// + /// This setting only applies to %azure.execute. It is ignored for %azure.submit. + /// + public int ExecutionPollingInterval { get; set; } = DefaultExecutionPollingIntervalInSeconds; + + /// + /// Parses the input from a magic command into an object + /// suitable for job submission via . + /// + public static AzureSubmissionContext Parse(string inputCommand) + { + var inputParameters = AbstractMagic.ParseInputParameters(inputCommand, firstParameterInferredName: ParameterNameOperationName); + var operationName = inputParameters.DecodeParameter(ParameterNameOperationName); + var jobName = inputParameters.DecodeParameter(ParameterNameJobName, defaultValue: operationName); + var shots = inputParameters.DecodeParameter(ParameterNameShots, defaultValue: DefaultShots); + var timeout = inputParameters.DecodeParameter(ParameterNameTimeout, defaultValue: DefaultExecutionTimeoutInSeconds); + var pollingInterval = inputParameters.DecodeParameter(ParameterNamePollingInterval, defaultValue: DefaultExecutionPollingIntervalInSeconds); + + var decodedParameters = inputParameters.ToDictionary( + item => item.Key, + item => inputParameters.DecodeParameter(item.Key)); + + return new AzureSubmissionContext() + { + FriendlyName = jobName, + Shots = shots, + OperationName = operationName, + InputParameters = decodedParameters, + ExecutionTimeout = timeout, + ExecutionPollingInterval = pollingInterval, + }; + } + } +} diff --git a/src/AzureClient/AzureWorkspace.cs b/src/AzureClient/AzureWorkspace.cs new file mode 100644 index 0000000000..a5083c442b --- /dev/null +++ b/src/AzureClient/AzureWorkspace.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Azure.Quantum; +using Microsoft.Azure.Quantum.Client; +using Microsoft.Azure.Quantum.Client.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Quantum.Runtime; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + internal class AzureWorkspace : IAzureWorkspace + { + public string? Name => AzureQuantumClient?.WorkspaceName; + + private Azure.Quantum.IWorkspace AzureQuantumWorkspace { get; set; } + private QuantumClient AzureQuantumClient { get; set; } + private ILogger Logger { get; } = new LoggerFactory().CreateLogger(); + + public AzureWorkspace(QuantumClient azureQuantumClient, Azure.Quantum.Workspace azureQuantumWorkspace) + { + AzureQuantumClient = azureQuantumClient; + AzureQuantumWorkspace = azureQuantumWorkspace; + } + + public async Task?> GetProvidersAsync() + { + try + { + return await AzureQuantumClient.Providers.GetStatusAsync(); + } + catch (Exception e) + { + Logger.LogError(e, $"Failed to retrieve the providers list from the Azure Quantum workspace: {e.Message}"); + } + + return null; + } + + public async Task GetJobAsync(string jobId) + { + try + { + return await AzureQuantumWorkspace.GetJobAsync(jobId); + } + catch (Exception e) + { + Logger.LogError(e, $"Failed to retrieve the specified Azure Quantum job: {e.Message}"); + } + + return null; + } + + public async Task?> ListJobsAsync() + { + try + { + return await AzureQuantumWorkspace.ListJobsAsync(); + } + catch (Exception e) + { + Logger.LogError(e, $"Failed to retrieve the list of jobs from the Azure Quantum workspace: {e.Message}"); + } + + return null; + } + + public IQuantumMachine? CreateQuantumMachine(string targetId, string storageAccountConnectionString) + { + return QuantumMachineFactory.CreateMachine(AzureQuantumWorkspace, targetId, storageAccountConnectionString); + } + } +} diff --git a/src/AzureClient/EntryPoint/EntryPoint.cs b/src/AzureClient/EntryPoint/EntryPoint.cs new file mode 100644 index 0000000000..17d10806ed --- /dev/null +++ b/src/AzureClient/EntryPoint/EntryPoint.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Quantum.Runtime; +using Microsoft.Quantum.Simulation.Core; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + /// + internal class EntryPoint : IEntryPoint + { + private object EntryPointInfo { get; } + private Type InputType { get; } + private Type OutputType { get; } + private OperationInfo OperationInfo { get; } + + /// + /// Creates an object used to submit jobs to Azure Quantum. + /// + /// Must be an object with type + /// parameters specified by the types in the entryPointInputbeginWords argument. + /// Specifies the input parameter type for the + /// object provided as the entryPointInfo argument. + /// Specifies the output parameter type for the + /// object provided as the entryPointInfo argument. + /// Information about the Q# operation to be used as the entry point. + public EntryPoint(object entryPointInfo, Type inputType, Type outputType, OperationInfo operationInfo) + { + EntryPointInfo = entryPointInfo; + InputType = inputType; + OutputType = outputType; + OperationInfo = operationInfo; + } + + /// + public Task SubmitAsync(IQuantumMachine machine, AzureSubmissionContext submissionContext) + { + var parameterTypes = new List(); + var parameterValues = new List(); + foreach (var parameter in OperationInfo.RoslynParameters) + { + if (!submissionContext.InputParameters.ContainsKey(parameter.Name)) + { + throw new ArgumentException($"Required parameter {parameter.Name} was not specified."); + } + + string rawParameterValue = submissionContext.InputParameters[parameter.Name]; + object? parameterValue = null; + try + { + parameterValue = System.Convert.ChangeType(rawParameterValue, parameter.ParameterType); + } + catch (Exception e) + { + throw new ArgumentException($"The value {rawParameterValue} provided for parameter {parameter.Name} could not be converted to the expected type: {e.Message}"); + } + + parameterTypes.Add(parameter.ParameterType); + parameterValues.Add(parameterValue); + } + + var entryPointInput = parameterValues.Count switch + { + 0 => QVoid.Instance, + 1 => parameterValues.Single(), + _ => InputType.GetConstructor(parameterTypes.ToArray()).Invoke(parameterValues.ToArray()) + }; + + // Find and invoke the method on IQuantumMachine that is declared as: + // Task SubmitAsync(EntryPointInfo info, TInput input, SubmissionContext context) + var submitMethod = typeof(IQuantumMachine) + .GetMethods() + .Single(method => + method.Name == "SubmitAsync" + && method.IsGenericMethodDefinition + && method.GetParameters().Length == 3 + && method.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == EntryPointInfo.GetType().GetGenericTypeDefinition() + && method.GetParameters()[1].ParameterType.IsGenericMethodParameter + && method.GetParameters()[2].ParameterType == typeof(IQuantumMachineSubmissionContext)) + .MakeGenericMethod(new Type[] { InputType, OutputType }); + var submitParameters = new object[] { EntryPointInfo, entryPointInput, submissionContext }; + return (Task)submitMethod.Invoke(machine, submitParameters); + } + } +} diff --git a/src/AzureClient/EntryPoint/EntryPointGenerator.cs b/src/AzureClient/EntryPoint/EntryPointGenerator.cs new file mode 100644 index 0000000000..31f997f40f --- /dev/null +++ b/src/AzureClient/EntryPoint/EntryPointGenerator.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Loader; +using Microsoft.Extensions.Logging; +using Microsoft.Quantum.IQSharp.Common; +using Microsoft.Quantum.Simulation.Common; +using Microsoft.Quantum.Simulation.Core; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + /// + internal class EntryPointGenerator : IEntryPointGenerator + { + private ICompilerService Compiler { get; } + private ILogger Logger { get; } + private IWorkspace Workspace { get; } + private ISnippets Snippets { get; } + public IReferences References { get; } + public AssemblyInfo? WorkspaceAssemblyInfo { get; set; } + public AssemblyInfo? SnippetsAssemblyInfo { get; set; } + public AssemblyInfo? EntryPointAssemblyInfo { get; set; } + + public EntryPointGenerator( + ICompilerService compiler, + IWorkspace workspace, + ISnippets snippets, + IReferences references, + ILogger logger, + IEventService eventService) + { + Compiler = compiler; + Workspace = workspace; + Snippets = snippets; + References = references; + Logger = logger; + + AssemblyLoadContext.Default.Resolving += Resolve; + + eventService?.TriggerServiceInitialized(this); + } + + /// + /// Because the assemblies are loaded into memory, we need to provide this method to the AssemblyLoadContext + /// such that the Workspace assembly or this assembly is correctly resolved when it is executed for simulation. + /// + public Assembly? Resolve(AssemblyLoadContext context, AssemblyName name) => name.Name switch + { + var s when s == Path.GetFileNameWithoutExtension(EntryPointAssemblyInfo?.Location) => EntryPointAssemblyInfo?.Assembly, + var s when s == Path.GetFileNameWithoutExtension(SnippetsAssemblyInfo?.Location) => SnippetsAssemblyInfo?.Assembly, + var s when s == Path.GetFileNameWithoutExtension(WorkspaceAssemblyInfo?.Location) => WorkspaceAssemblyInfo?.Assembly, + _ => null + }; + + public IEntryPoint Generate(string operationName, string? executionTarget) + { + Logger?.LogDebug($"Generating entry point: operationName={operationName}, executionTarget={executionTarget}"); + + var logger = new QSharpLogger(Logger); + var compilerMetadata = References.CompilerMetadata; + + // Clear references to previously-built assemblies + WorkspaceAssemblyInfo = null; + SnippetsAssemblyInfo = null; + EntryPointAssemblyInfo = null; + + // Compile the workspace against the provided execution target + var workspaceFiles = Workspace.SourceFiles.ToArray(); + if (workspaceFiles.Any()) + { + Logger?.LogDebug($"{workspaceFiles.Length} files found in workspace. Compiling."); + WorkspaceAssemblyInfo = Compiler.BuildFiles( + workspaceFiles, compilerMetadata, logger, Path.Combine(Workspace.CacheFolder, "__entrypoint__workspace__.dll"), executionTarget); + if (WorkspaceAssemblyInfo == null || logger.HasErrors) + { + Logger?.LogError($"Error compiling workspace."); + throw new CompilationErrorsException(logger.Errors.ToArray()); + } + + compilerMetadata = compilerMetadata.WithAssemblies(WorkspaceAssemblyInfo); + } + + // Compile the snippets against the provided execution target + var snippets = Snippets.Items.ToArray(); + if (snippets.Any()) + { + Logger?.LogDebug($"{snippets.Length} items found in snippets. Compiling."); + SnippetsAssemblyInfo = Compiler.BuildSnippets( + snippets, compilerMetadata, logger, Path.Combine(Workspace.CacheFolder, "__entrypoint__snippets__.dll"), executionTarget); + if (SnippetsAssemblyInfo == null || logger.HasErrors) + { + Logger?.LogError($"Error compiling snippets."); + throw new CompilationErrorsException(logger.Errors.ToArray()); + } + + compilerMetadata = compilerMetadata.WithAssemblies(SnippetsAssemblyInfo); + } + + // Build the entry point assembly + var operationInfo = new EntryPointOperationResolver(this).Resolve(operationName); + if (operationInfo == null) + { + Logger?.LogError($"{operationName} is not a recognized Q# operation name."); + throw new UnsupportedOperationException(operationName); + } + + EntryPointAssemblyInfo = Compiler.BuildEntryPoint( + operationInfo, compilerMetadata, logger, Path.Combine(Workspace.CacheFolder, "__entrypoint__.dll"), executionTarget); + if (EntryPointAssemblyInfo == null || logger.HasErrors) + { + Logger?.LogError($"Error compiling entry point for operation {operationName}."); + throw new CompilationErrorsException(logger.Errors.ToArray()); + } + + var entryPointOperationInfo = EntryPointAssemblyInfo.Operations.Single(); + + // Construct the EntryPointInfo<,> object + var parameterTypes = entryPointOperationInfo.RoslynParameters.Select(p => p.ParameterType).ToArray(); + var typeCount = parameterTypes.Length; + Type entryPointInputType = typeCount switch + { + 0 => typeof(QVoid), + 1 => parameterTypes.Single(), + _ => PartialMapper.TupleTypes[typeCount].MakeGenericType(parameterTypes) + }; + Type entryPointOutputType = entryPointOperationInfo.ReturnType; + + Type entryPointInfoType = typeof(EntryPointInfo<,>).MakeGenericType(new Type[] { entryPointInputType, entryPointOutputType }); + var entryPointInfo = entryPointInfoType.GetConstructor(new Type[] { typeof(Type) }) + .Invoke(new object[] { entryPointOperationInfo.RoslynType }); + + return new EntryPoint(entryPointInfo, entryPointInputType, entryPointOutputType, entryPointOperationInfo); + } + } +} diff --git a/src/AzureClient/EntryPoint/EntryPointOperationResolver.cs b/src/AzureClient/EntryPoint/EntryPointOperationResolver.cs new file mode 100644 index 0000000000..28dcda3f4c --- /dev/null +++ b/src/AzureClient/EntryPoint/EntryPointOperationResolver.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + internal class EntryPointOperationResolver : IOperationResolver + { + private IEntryPointGenerator EntryPointGenerator { get; } + + public EntryPointOperationResolver(IEntryPointGenerator entryPointGenerator) => + EntryPointGenerator = entryPointGenerator; + + public OperationInfo Resolve(string name) => OperationResolver.ResolveFromAssemblies(name, RelevantAssemblies()); + + private IEnumerable RelevantAssemblies() + { + if (EntryPointGenerator.SnippetsAssemblyInfo != null) yield return EntryPointGenerator.SnippetsAssemblyInfo; + if (EntryPointGenerator.WorkspaceAssemblyInfo != null) yield return EntryPointGenerator.WorkspaceAssemblyInfo; + + foreach (var asm in EntryPointGenerator.References.Assemblies) + { + yield return asm; + } + } + } +} diff --git a/src/AzureClient/EntryPoint/IEntryPoint.cs b/src/AzureClient/EntryPoint/IEntryPoint.cs new file mode 100644 index 0000000000..aa3f78caa5 --- /dev/null +++ b/src/AzureClient/EntryPoint/IEntryPoint.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Quantum.Runtime; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + /// + /// Represents a Q# entry point that can be submitted + /// for execution to Azure Quantum. + /// + public interface IEntryPoint + { + /// + /// Submits the entry point for execution to Azure Quantum. + /// + /// The object representing the job submission target. + /// The object representing the submission context for the job. + /// The details of the submitted job. + public Task SubmitAsync(IQuantumMachine machine, AzureSubmissionContext submissionContext); + } +} diff --git a/src/AzureClient/EntryPoint/IEntryPointGenerator.cs b/src/AzureClient/EntryPoint/IEntryPointGenerator.cs new file mode 100644 index 0000000000..1ed8b2a87a --- /dev/null +++ b/src/AzureClient/EntryPoint/IEntryPointGenerator.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + /// + /// This service is capable of generating entry points for + /// job submission to Azure Quantum. + /// + public interface IEntryPointGenerator + { + /// + /// Gets the compiled workspace assembly for the most recently-generated entry point. + /// + public AssemblyInfo? WorkspaceAssemblyInfo { get; } + + /// + /// Gets the compiled snippets assembly for the most recently-generated entry point. + /// + public AssemblyInfo? SnippetsAssemblyInfo { get; } + + /// + /// Gets the compiled entry point assembly for the most recently-generated entry point. + /// + public AssemblyInfo? EntryPointAssemblyInfo { get; } + + /// + /// Gets the references used for compilation of the entry point assembly. + /// + public IReferences References { get; } + + /// + /// Compiles an assembly and returns the object + /// representing an entry point that wraps the specified operation. + /// + /// The name of the operation to wrap in an entry point. + /// The intended execution target for the compiled entry point. + /// The generated entry point. + public IEntryPoint Generate(string operationName, string? executionTarget); + } +} diff --git a/src/AzureClient/Extensions.cs b/src/AzureClient/Extensions.cs new file mode 100644 index 0000000000..87ddf1673d --- /dev/null +++ b/src/AzureClient/Extensions.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading.Tasks; +using Microsoft.Azure.Quantum; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Jupyter.Core; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + /// + /// Extension methods to be used with various IQ# and AzureClient objects. + /// + public static class Extensions + { + /// + /// Adds services required for the AzureClient to a given service collection. + /// + public static void AddAzureClient(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + } + + /// + /// Encapsulates a given as the result of an execution. + /// + /// + /// The result of an IAzureClient API call. + /// + internal static ExecutionResult ToExecutionResult(this AzureClientError azureClientError) => + new ExecutionResult + { + Status = ExecuteStatus.Error, + Output = azureClientError, + }; + + /// + /// Encapsulates a given as the result of an execution. + /// + /// + /// A task which will return the result of an IAzureClient API call. + /// + internal static async Task ToExecutionResult(this Task task) => + (await task).ToExecutionResult(); + + /// + /// Returns the provided argument as an enumeration of the specified type. + /// + /// + /// If the argument is already an of the specified type, + /// the argument is returned. If the argument is of type T, then an + /// enumeration is returned with this argument as the only element. + /// Otherwise, null is returned. + /// + internal static IEnumerable? AsEnumerableOf(this object? source) => + source is T singleton ? new List { singleton } : + source is IEnumerable collection ? collection : + null; + + /// + /// Determines whether the given matches the given filter. + /// + internal static bool Matches(this CloudJob job, string filter) => + (job.Id != null && job.Id.Contains(filter, StringComparison.OrdinalIgnoreCase)) || + (job.Details.Name != null && job.Details.Name.Contains(filter, StringComparison.OrdinalIgnoreCase)) || + (job.Details.Target != null && job.Details.Target.Contains(filter, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/AzureClient/IAzureClient.cs b/src/AzureClient/IAzureClient.cs new file mode 100644 index 0000000000..b73995f9ff --- /dev/null +++ b/src/AzureClient/IAzureClient.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Jupyter.Core; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + /// + /// 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. + /// + /// + /// The list of execution targets available in the Azure Quantum workspace. + /// + public Task ConnectAsync(IChannel channel, + string subscriptionId, + string resourceGroupName, + string workspaceName, + string storageAccountConnectionString, + bool refreshCredentials = false); + + /// + /// Gets the current connection status to an Azure Quantum workspace. + /// + /// + /// The list of execution targets available in the Azure Quantum workspace, + /// or an error if the Azure Quantum workspace connection has not yet been created. + /// + public Task GetConnectionStatusAsync(IChannel channel); + + /// + /// Submits the specified Q# operation as a job to the currently active target. + /// + /// + /// Details of the submitted job, or an error if submission failed. + /// + public Task SubmitJobAsync(IChannel channel, AzureSubmissionContext submissionContext, CancellationToken? token); + + /// + /// Executes the specified Q# operation as a job to the currently active target + /// and waits for execution to complete before returning. + /// + /// + /// The result of the executed job, or an error if execution failed. + /// + public Task ExecuteJobAsync(IChannel channel, AzureSubmissionContext submissionContext, CancellationToken? token); + + /// + /// Sets the specified target for job submission. + /// + /// + /// Success if the target is valid, or an error if the target cannot be set. + /// + public Task SetActiveTargetAsync(IChannel channel, string targetId); + + /// + /// Gets the currently specified target for job submission. + /// + /// + /// The target ID. + /// + public Task GetActiveTargetAsync(IChannel channel); + + /// + /// Gets the result of a specified job. + /// + /// + /// The job result corresponding to the given job ID, + /// or for the most recently-submitted job if no job ID is provided. + /// + public Task GetJobResultAsync(IChannel channel, string jobId); + + /// + /// Gets the status of a specified job. + /// + /// + /// The job status corresponding to the given job ID, + /// or for the most recently-submitted job if no job ID is provided. + /// + public Task GetJobStatusAsync(IChannel channel, string jobId); + + /// + /// Gets a list of all jobs in the current Azure Quantum workspace. + /// + /// + /// A list of all jobs in the current workspace, optionally filtered + /// to jobs with fields containing filter using a case-insensitive + /// comparison. + /// + public Task GetJobListAsync(IChannel channel, string filter); + } +} diff --git a/src/AzureClient/IAzureWorkspace.cs b/src/AzureClient/IAzureWorkspace.cs new file mode 100644 index 0000000000..1c2c71b813 --- /dev/null +++ b/src/AzureClient/IAzureWorkspace.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Azure.Quantum; +using Microsoft.Azure.Quantum.Client.Models; +using Microsoft.Quantum.Runtime; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + internal interface IAzureWorkspace + { + public string? Name { get; } + + public Task?> GetProvidersAsync(); + public Task GetJobAsync(string jobId); + public Task?> ListJobsAsync(); + public IQuantumMachine? CreateQuantumMachine(string targetId, string storageAccountConnectionString); + } +} \ No newline at end of file diff --git a/src/AzureClient/Magic/AzureClientMagicBase.cs b/src/AzureClient/Magic/AzureClientMagicBase.cs new file mode 100644 index 0000000000..da17815756 --- /dev/null +++ b/src/AzureClient/Magic/AzureClientMagicBase.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Threading; +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) => + RunCancellable(input, channel, CancellationToken.None); + + /// + public override ExecutionResult RunCancellable(string input, IChannel channel, CancellationToken cancellationToken) => + RunAsync(input, channel, cancellationToken).GetAwaiter().GetResult(); + + /// + /// Executes the magic command functionality for the given input. + /// + public abstract Task RunAsync(string input, IChannel channel, CancellationToken cancellationToken); + } +} diff --git a/src/AzureClient/Magic/ConnectMagic.cs b/src/AzureClient/Magic/ConnectMagic.cs new file mode 100644 index 0000000000..12114f48fb --- /dev/null +++ b/src/AzureClient/Magic/ConnectMagic.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +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 ParameterNameRefresh = "refresh"; + private const string ParameterNameStorageAccountConnectionString = "storage"; + private const string ParameterNameSubscriptionId = "subscription"; + private const string ParameterNameResourceGroupName = "resourceGroup"; + private const string ParameterNameWorkspaceName = "workspace"; + private const string ParameterNameResourceId = "resourceId"; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The object to use for Azure functionality. + /// + public ConnectMagic(IAzureClient azureClient) + : base( + azureClient, + "azure.connect", + new Documentation + { + Summary = "Connects to an Azure Quantum workspace or displays current connection status.", + Description = @" + This magic command allows for connecting to an Azure Quantum workspace + as specified by the resource ID of the workspace or by a combination of + subscription ID, resource group name, and workspace name. + + If the connection is successful, a list of the available Q# execution targets + in the Azure Quantum workspace will be displayed. + ".Dedent(), + Examples = new[] + { + $@" + Connect to an Azure Quantum workspace using its resource ID: + ``` + In []: %azure.connect {ParameterNameResourceId}=""/subscriptions/f846b2bd-d0e2-4a1d-8141-4c6944a9d387/resourceGroups/RESOURCE_GROUP_NAME/providers/Microsoft.Quantum/Workspaces/WORKSPACE_NAME"" + Out[]: Connected to Azure Quantum workspace WORKSPACE_NAME. + + ``` + ".Dedent(), + + $@" + Connect to an Azure Quantum workspace using its resource ID and a storage account connection string, + which is required for workspaces that do not have a linked storage account: + ``` + In []: %azure.connect {ParameterNameResourceId}=""/subscriptions/f846b2bd-d0e2-4a1d-8141-4c6944a9d387/resourceGroups/RESOURCE_GROUP_NAME/providers/Microsoft.Quantum/Workspaces/WORKSPACE_NAME"" + {ParameterNameStorageAccountConnectionString}=""STORAGE_ACCOUNT_CONNECTION_STRING"" + Out[]: Connected to Azure Quantum workspace WORKSPACE_NAME. + + ``` + ".Dedent(), + + $@" + Connect to an Azure Quantum workspace using individual parameters: + ``` + In []: %azure.connect {ParameterNameSubscriptionId}=""SUBSCRIPTION_ID"" + {ParameterNameResourceGroupName}=""RESOURCE_GROUP_NAME"" + {ParameterNameWorkspaceName}=""WORKSPACE_NAME"" + {ParameterNameStorageAccountConnectionString}=""STORAGE_ACCOUNT_CONNECTION_STRING"" + Out[]: Connected to Azure Quantum workspace WORKSPACE_NAME. + + ``` + The `{ParameterNameStorageAccountConnectionString}` parameter is necessary only if the + specified Azure Quantum workspace was not linked to a storage account at creation time. + ".Dedent(), + + $@" + Connect to an Azure Quantum workspace and force a credential prompt using + the `{ParameterNameRefresh}` option: + ``` + In []: %azure.connect {ParameterNameRefresh} {ParameterNameResourceId}=""/subscriptions/f846b2bd-d0e2-4a1d-8141-4c6944a9d387/resourceGroups/RESOURCE_GROUP_NAME/providers/Microsoft.Quantum/Workspaces/WORKSPACE_NAME"" + 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 Azure Quantum workspace WORKSPACE_NAME. + + ``` + The `{ParameterNameRefresh}` option bypasses any saved or cached + credentials when connecting to Azure. + ".Dedent(), + + @" + Print information about the current connection: + ``` + In []: %azure.connect + Out[]: Connected to Azure Quantum workspace WORKSPACE_NAME. + + ``` + ".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, CancellationToken cancellationToken) + { + var inputParameters = ParseInputParameters(input); + if (!inputParameters.Any()) + { + return await AzureClient.GetConnectionStatusAsync(channel); + } + + var resourceId = inputParameters.DecodeParameter(ParameterNameResourceId, defaultValue: string.Empty); + var subscriptionId = string.Empty; + var resourceGroupName = string.Empty; + var workspaceName = string.Empty; + + // A valid resource ID looks like: + // /subscriptions/f846b2bd-d0e2-4a1d-8141-4c6944a9d387/resourceGroups/RESOURCE_GROUP_NAME/providers/Microsoft.Quantum/Workspaces/WORKSPACE_NAME + var match = Regex.Match(resourceId, + @"^/subscriptions/([a-fA-F0-9-]*)/resourceGroups/([^\s/]*)/providers/Microsoft\.Quantum/Workspaces/([^\s/]*)$"); + if (match.Success) + { + // match.Groups will be a GroupCollection containing four Group objects: + // -> match.Groups[0]: The full resource ID for the Azure Quantum workspace + // -> match.Groups[1]: The Azure subscription ID + // -> match.Groups[2]: The Azure resource group name + // -> match.Groups[3]: The Azure Quantum workspace name + subscriptionId = match.Groups[1].Value; + resourceGroupName = match.Groups[2].Value; + workspaceName = match.Groups[3].Value; + } + else + { + // look for each of the parameters individually + subscriptionId = inputParameters.DecodeParameter(ParameterNameSubscriptionId, defaultValue: string.Empty); + resourceGroupName = inputParameters.DecodeParameter(ParameterNameResourceGroupName, defaultValue: string.Empty); + workspaceName = inputParameters.DecodeParameter(ParameterNameWorkspaceName, defaultValue: string.Empty); + } + + var storageAccountConnectionString = inputParameters.DecodeParameter(ParameterNameStorageAccountConnectionString, defaultValue: string.Empty); + var refreshCredentials = inputParameters.DecodeParameter(ParameterNameRefresh, defaultValue: false); + return await AzureClient.ConnectAsync( + channel, + subscriptionId, + resourceGroupName, + workspaceName, + storageAccountConnectionString, + refreshCredentials); + } + } +} diff --git a/src/AzureClient/Magic/ExecuteMagic.cs b/src/AzureClient/Magic/ExecuteMagic.cs new file mode 100644 index 0000000000..6a675e9f3e --- /dev/null +++ b/src/AzureClient/Magic/ExecuteMagic.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Threading; +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 submit jobs to an Azure Quantum workspace. + /// + public class ExecuteMagic : AzureClientMagicBase + { + /// + /// Initializes a new instance of the class. + /// + /// + /// The object to use for Azure functionality. + /// + public ExecuteMagic(IAzureClient azureClient) + : base( + azureClient, + "azure.execute", + new Documentation + { + Summary = "Executes a job in an Azure Quantum workspace.", + Description = @" + This magic command allows for executing a job in an Azure Quantum workspace + corresponding to the Q# operation provided as an argument, and it waits + for the job to complete before returning. + + The Azure Quantum workspace must previously have been initialized + using the %azure.connect magic command. + ".Dedent(), + Examples = new[] + { + @" + Execute an operation in the current Azure Quantum workspace: + ``` + In []: %azure.execute OPERATION_NAME + Out[]: Executing job on target TARGET_NAME... + + ``` + ".Dedent(), + }, + }) + { } + + /// + /// Executes a new job in an Azure Quantum workspace given a Q# operation + /// name that is present in the current Q# Jupyter workspace, and + /// waits for the job to complete before returning. + /// + public override async Task RunAsync(string input, IChannel channel, CancellationToken cancellationToken) + { + return await AzureClient.ExecuteJobAsync(channel, AzureSubmissionContext.Parse(input), cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/AzureClient/Magic/JobsMagic.cs b/src/AzureClient/Magic/JobsMagic.cs new file mode 100644 index 0000000000..3444a0be57 --- /dev/null +++ b/src/AzureClient/Magic/JobsMagic.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +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 list jobs in an Azure Quantum workspace. + /// + public class JobsMagic : AzureClientMagicBase + { + private const string ParameterNameFilter = "__filter__"; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The object to use for Azure functionality. + /// + public JobsMagic(IAzureClient azureClient) + : base( + azureClient, + "azure.jobs", + new Documentation + { + Summary = "Displays a list of jobs in the current Azure Quantum workspace.", + Description = @" + This magic command allows for displaying the list of jobs in the current + Azure Quantum workspace, optionally filtering the list to jobs which + have an ID, name, or target containing the provided filter parameter. + + The Azure Quantum workspace must previously have been initialized + using the %azure.connect magic command. + ".Dedent(), + Examples = new[] + { + @" + Print the list of jobs: + ``` + In []: %azure.jobs + Out[]: + ``` + ".Dedent(), + + @" + Print the list of jobs whose ID, name, or target contains ""MyJob"": + ``` + In []: %azure.jobs ""MyJob"" + Out[]: + ``` + ".Dedent(), + }, + }) {} + + /// + /// Lists all jobs in the active workspace, optionally filtered by a provided parameter. + /// + public override async Task RunAsync(string input, IChannel channel, CancellationToken cancellationToken) + { + var inputParameters = ParseInputParameters(input, firstParameterInferredName: ParameterNameFilter); + var filter = inputParameters.DecodeParameter(ParameterNameFilter, defaultValue: string.Empty); + return await AzureClient.GetJobListAsync(channel, filter); + } + } +} \ No newline at end of file diff --git a/src/AzureClient/Magic/OutputMagic.cs b/src/AzureClient/Magic/OutputMagic.cs new file mode 100644 index 0000000000..624b4a5a89 --- /dev/null +++ b/src/AzureClient/Magic/OutputMagic.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +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 display the results of an Azure Quantum job. + /// + public class OutputMagic : AzureClientMagicBase + { + private const string ParameterNameJobId = "id"; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The object to use for Azure functionality. + /// + public OutputMagic(IAzureClient azureClient) + : base( + azureClient, + "azure.output", + new Documentation + { + Summary = "Displays results for jobs in the current Azure Quantum workspace.", + Description = @" + This magic command allows for displaying results of jobs in the current + Azure Quantum workspace. If a valid job ID is provided as an argument, and the + job has completed, the output of that job will be displayed. If no job ID is + provided, the job ID from the most recent call to `%azure.submit` or + `%azure.execute` will be used. + + If the job has not yet completed, an error message will be displayed. + + The Azure Quantum workspace must previously have been initialized + using the %azure.connect magic command. + ".Dedent(), + Examples = new[] + { + @" + Print results of a specific job: + ``` + In []: %azure.output JOB_ID + Out[]: + ``` + ".Dedent(), + + @" + Print results of the most recently-submitted job: + ``` + In []: %azure.output + Out[]: + ``` + ".Dedent(), + }, + }) {} + + /// + /// Displays the output of a given completed job ID, if provided, + /// or all jobs submitted in the current session. + /// + public override async Task RunAsync(string input, IChannel channel, CancellationToken cancellationToken) + { + var inputParameters = ParseInputParameters(input, firstParameterInferredName: ParameterNameJobId); + string jobId = inputParameters.DecodeParameter(ParameterNameJobId); + return await AzureClient.GetJobResultAsync(channel, jobId); + } + } +} \ 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..2ba8bcd0be --- /dev/null +++ b/src/AzureClient/Magic/StatusMagic.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +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 = "id"; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The object to use for Azure functionality. + /// + public StatusMagic(IAzureClient azureClient) + : base( + azureClient, + "azure.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. If no job ID is + provided, the job ID from the most recent call to `%azure.submit` or + `%azure.execute` will be used. + + The Azure Quantum workspace must previously have been initialized + using the %azure.connect magic command. + ".Dedent(), + Examples = new[] + { + @" + Print status of a specific job: + ``` + In []: %azure.status JOB_ID + Out[]: + ``` + ".Dedent(), + + @" + Print status of the most recently-submitted job: + ``` + In []: %azure.status + Out[]: + ``` + ".Dedent(), + }, + }) {} + + /// + /// Displays the status corresponding to a given job ID, if provided, + /// or the most recently-submitted job in the current session. + /// + public override async Task RunAsync(string input, IChannel channel, CancellationToken cancellationToken) + { + var inputParameters = ParseInputParameters(input, firstParameterInferredName: ParameterNameJobId); + string jobId = inputParameters.DecodeParameter(ParameterNameJobId); + return await AzureClient.GetJobStatusAsync(channel, jobId); + } + } +} \ 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..2a46a9c03b --- /dev/null +++ b/src/AzureClient/Magic/SubmitMagic.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System.Collections.Generic; +using System.Threading; +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 submit jobs to an Azure Quantum workspace. + /// + public class SubmitMagic : AzureClientMagicBase + { + /// + /// Initializes a new instance of the class. + /// + /// + /// The object to use for Azure functionality. + /// + public SubmitMagic(IAzureClient azureClient) + : base( + azureClient, + "azure.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 %azure.connect magic command. + ".Dedent(), + Examples = new[] + { + @" + Submit an operation as a new job to the current Azure Quantum workspace: + ``` + In []: %azure.submit OPERATION_NAME + Out[]: Submitted job JOB_ID + ``` + ".Dedent(), + }, + }) + { } + + /// + /// 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, CancellationToken cancellationToken) + { + return await AzureClient.SubmitJobAsync(channel, AzureSubmissionContext.Parse(input), cancellationToken); + } + } +} \ 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..d5c4f8b801 --- /dev/null +++ b/src/AzureClient/Magic/TargetMagic.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +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 ParameterNameTargetId = "id"; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The object to use for Azure functionality. + /// + public TargetMagic(IAzureClient azureClient) + : base( + azureClient, + "azure.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 %azure.connect magic command, and the specified target must be + available in the workspace. + ".Dedent(), + Examples = new[] + { + @" + Set the current target for job submission: + ``` + In []: %azure.target TARGET_ID + Out[]: Active target is now TARGET_ID + ``` + ".Dedent(), + @" + View the current target and all available targets in the current Azure Quantum workspace: + ``` + In []: %azure.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, CancellationToken cancellationToken) + { + var inputParameters = ParseInputParameters(input, firstParameterInferredName: ParameterNameTargetId); + if (inputParameters.ContainsKey(ParameterNameTargetId)) + { + string targetId = inputParameters.DecodeParameter(ParameterNameTargetId); + return await AzureClient.SetActiveTargetAsync(channel, targetId); + } + + return await AzureClient.GetActiveTargetAsync(channel); + } + } +} \ No newline at end of file diff --git a/src/AzureClient/Mocks/MockAzureWorkspace.cs b/src/AzureClient/Mocks/MockAzureWorkspace.cs new file mode 100644 index 0000000000..212d605714 --- /dev/null +++ b/src/AzureClient/Mocks/MockAzureWorkspace.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Azure.Quantum; +using Microsoft.Azure.Quantum.Client.Models; +using Microsoft.Quantum.Runtime; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + internal class MockAzureWorkspace : IAzureWorkspace + { + public const string NameWithMockProviders = "WorkspaceNameWithMockProviders"; + + public string Name { get; private set; } + + public List Providers { get; } = new List(); + + public List Jobs { get; } = new List(); + + public MockAzureWorkspace(string workspaceName) + { + Name = workspaceName; + if (Name == NameWithMockProviders) + { + // add a mock target for each provider: "ionq.mock", "honeywell.mock", etc. + AddMockTargets( + Enum.GetNames(typeof(AzureProvider)) + .Select(provider => $"{provider.ToLowerInvariant()}.mock") + .ToArray()); + } + } + + public async Task GetJobAsync(string jobId) => Jobs.FirstOrDefault(job => job.Id == jobId); + + public async Task?> GetProvidersAsync() => Providers; + + public async Task?> ListJobsAsync() => Jobs; + + public IQuantumMachine? CreateQuantumMachine(string targetId, string storageAccountConnectionString) => new MockQuantumMachine(this); + + public void AddMockJobs(params string[] jobIds) + { + foreach (var jobId in jobIds) + { + var mockJob = new MockCloudJob(); + mockJob.Details.Id = jobId; + Jobs.Add(mockJob); + } + } + + public void AddMockTargets(params string[] targetIds) + { + var targets = targetIds.Select(id => new TargetStatus(id)).ToList(); + Providers.Add(new ProviderStatus(null, null, targets)); + } + } +} \ No newline at end of file diff --git a/src/AzureClient/Mocks/MockCloudJob.cs b/src/AzureClient/Mocks/MockCloudJob.cs new file mode 100644 index 0000000000..73d6cc789d --- /dev/null +++ b/src/AzureClient/Mocks/MockCloudJob.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using Microsoft.Azure.Quantum; +using Microsoft.Azure.Quantum.Client.Models; +using System; +using System.IO; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + internal class MockCloudJob : CloudJob + { + public MockCloudJob() + : base( + new Azure.Quantum.Workspace("mockSubscriptionId", "mockResourceGroupName", "mockWorkspaceName"), + new JobDetails( + containerUri: null, + inputDataFormat: null, + providerId: null, + target: null, + id: Guid.NewGuid().ToString(), + status: "Succeeded", + outputDataUri: CreateMockOutputFileUri() + )) + { + } + + private static string CreateMockOutputFileUri() + { + var tempFilePath = Path.GetTempFileName(); + using var outputFile = new StreamWriter(tempFilePath); + outputFile.WriteLine(@"{'Histogram':['0',0.5,'1',0.5]}"); + return new Uri(tempFilePath).AbsoluteUri; + } + } +} \ No newline at end of file diff --git a/src/AzureClient/Mocks/MockQuantumMachine.cs b/src/AzureClient/Mocks/MockQuantumMachine.cs new file mode 100644 index 0000000000..9e6c28411f --- /dev/null +++ b/src/AzureClient/Mocks/MockQuantumMachine.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Quantum.Runtime; +using Microsoft.Quantum.Simulation.Core; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + internal class MockQuantumMachine : IQuantumMachine + { + public string ProviderId => throw new NotImplementedException(); + + public string Target => throw new NotImplementedException(); + + private MockAzureWorkspace? Workspace { get; } + + public MockQuantumMachine(MockAzureWorkspace? workspace = null) => Workspace = workspace; + + public Task> ExecuteAsync(EntryPointInfo info, TInput input) + => ExecuteAsync(info, input, null as IQuantumMachineSubmissionContext); + + public Task> ExecuteAsync(EntryPointInfo info, TInput input, IQuantumMachineSubmissionContext? submissionContext) + => ExecuteAsync(info, input, submissionContext, null as IQuantumMachine.ConfigureJob); + + public Task> ExecuteAsync(EntryPointInfo info, TInput input, IQuantumMachineSubmissionContext? submissionContext, IQuantumMachine.ConfigureJob? configureJobCallback) + => ExecuteAsync(info, input, submissionContext, null, configureJobCallback); + + public Task> ExecuteAsync(EntryPointInfo info, TInput input, IQuantumMachineExecutionContext? executionContext) + => ExecuteAsync(info, input, executionContext, null); + + public Task> ExecuteAsync(EntryPointInfo info, TInput input, IQuantumMachineExecutionContext? executionContext, IQuantumMachine.ConfigureJob? configureJobCallback) + => ExecuteAsync(info, input, null, executionContext); + + public Task> ExecuteAsync(EntryPointInfo info, TInput input, IQuantumMachineSubmissionContext? submissionContext, IQuantumMachineExecutionContext? executionContext) + => ExecuteAsync(info, input, submissionContext, executionContext, null); + + public Task> ExecuteAsync(EntryPointInfo info, TInput input, IQuantumMachineSubmissionContext? submissionContext, IQuantumMachineExecutionContext? executionContext, IQuantumMachine.ConfigureJob? configureJobCallback) + => throw new NotImplementedException(); + + public Task SubmitAsync(EntryPointInfo info, TInput input) + => SubmitAsync(info, input, null); + + public Task SubmitAsync(EntryPointInfo info, TInput input, IQuantumMachineSubmissionContext? submissionContext) + => SubmitAsync(info, input, submissionContext, null); + + public Task SubmitAsync(EntryPointInfo info, TInput input, IQuantumMachineSubmissionContext? submissionContext, IQuantumMachine.ConfigureJob? configureJobCallback) + { + var job = new MockCloudJob(); + Workspace?.AddMockJobs(job.Id); + return Task.FromResult(job as IQuantumMachineJob); + } + + public (bool IsValid, string Message) Validate(EntryPointInfo info, TInput input) + => throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/AzureClient/Properties/AssemblyInfo.cs b/src/AzureClient/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..bb672ac9dd --- /dev/null +++ b/src/AzureClient/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Tests.IQsharp" + SigningConstants.PUBLIC_KEY)] diff --git a/src/AzureClient/Resources.cs b/src/AzureClient/Resources.cs new file mode 100644 index 0000000000..f77ad445d9 --- /dev/null +++ b/src/AzureClient/Resources.cs @@ -0,0 +1,49 @@ +#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 AzureClientErrorInvalidTarget = + "The specified execution target is not valid for Q# job submission in the current Azure Quantum workspace."; + + public const string AzureClientErrorJobNotFound = + "No job with the given ID was found in the current Azure Quantum workspace."; + + public const string AzureClientErrorJobNotCompleted = + "The specified Azure Quantum job has not yet completed."; + + public const string AzureClientErrorJobOutputDownloadFailed = + "Failed to download results for the specified Azure Quantum job."; + + public const string AzureClientErrorNoOperationName = + "No Q# operation name was specified for Azure Quantum job submission."; + + public const string AzureClientErrorUnrecognizedOperationName = + "The specified Q# operation name was not recognized."; + + public const string AzureClientErrorInvalidEntryPoint = + "The specified Q# operation cannot be used as an entry point for Azure Quantum job submission."; + + public const string AzureClientErrorJobSubmissionFailed = + "Failed to submit the job to the Azure Quantum workspace."; + + 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/AzureClient/Visualization/AzureClientErrorEncoders.cs b/src/AzureClient/Visualization/AzureClientErrorEncoders.cs new file mode 100644 index 0000000000..4848d7a8a3 --- /dev/null +++ b/src/AzureClient/Visualization/AzureClientErrorEncoders.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using Microsoft.Jupyter.Core; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + internal static class AzureClientErrorExtensions + { + /// + /// Returns the string value of the for the given + /// enumeration value. + /// + internal static string ToDescription(this AzureClientError error) + { + var attributes = error + .GetType() + .GetField(error.ToString()) + .GetCustomAttributes(typeof(DescriptionAttribute), false) as DescriptionAttribute[]; + return attributes?.Length > 0 ? attributes[0].Description : string.Empty; + } + + /// + /// Returns a dictionary representing the properties of the . + /// + internal static Dictionary ToDictionary(this AzureClientError error) => + new Dictionary() + { + ["error_code"] = System.Convert.ToInt32(error), + ["error_name"] = error.ToString(), + ["error_description"] = error.ToDescription(), + }; + } + + public class AzureClientErrorToHtmlEncoder : IResultEncoder + { + public string MimeType => MimeTypes.Html; + + public EncodedData? Encode(object displayable) => (displayable as AzureClientError?)?.ToDescription().ToEncodedData(); + } + + public class AzureClientErrorToTextEncoder : IResultEncoder + { + public string MimeType => MimeTypes.PlainText; + + public EncodedData? Encode(object displayable) => (displayable as AzureClientError?)?.ToDescription().ToEncodedData(); + } + + public class AzureClientErrorJsonConverter : JsonConverter + { + public override AzureClientError ReadJson(JsonReader reader, Type objectType, AzureClientError existingValue, bool hasExistingValue, JsonSerializer serializer) => + throw new NotImplementedException(); + + public override void WriteJson(JsonWriter writer, AzureClientError value, JsonSerializer serializer) => + JToken.FromObject(value.ToDictionary()).WriteTo(writer); + } +} diff --git a/src/AzureClient/Visualization/CloudJobEncoders.cs b/src/AzureClient/Visualization/CloudJobEncoders.cs new file mode 100644 index 0000000000..a8e66af5ce --- /dev/null +++ b/src/AzureClient/Visualization/CloudJobEncoders.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using Microsoft.Azure.Quantum; +using Microsoft.Jupyter.Core; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + internal static class CloudJobExtensions + { + private static DateTime? ToDateTime(this string serializedDateTime) => + DateTime.TryParse(serializedDateTime, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime dateTime) + ? dateTime + : null as DateTime?; + + internal static Dictionary ToDictionary(this CloudJob cloudJob) => + new Dictionary() + { + // TODO: add cloudJob.Uri after https://github.com/microsoft/qsharp-runtime/issues/236 is fixed. + ["id"] = cloudJob.Id, + ["name"] = cloudJob.Details.Name, + ["status"] = cloudJob.Status, + ["provider"] = cloudJob.Details.ProviderId, + ["target"] = cloudJob.Details.Target, + ["creation_time"] = cloudJob.Details.CreationTime.ToDateTime()?.ToUniversalTime(), + ["begin_execution_time"] = cloudJob.Details.BeginExecutionTime.ToDateTime()?.ToUniversalTime(), + ["end_execution_time"] = cloudJob.Details.EndExecutionTime.ToDateTime()?.ToUniversalTime(), + }; + + internal static Table ToJupyterTable(this IEnumerable jobsList) => + new Table + { + Columns = new List<(string, Func)> + { + // TODO: add cloudJob.Uri after https://github.com/microsoft/qsharp-runtime/issues/236 is fixed. + ("Job Name", cloudJob => cloudJob.Details.Name), + ("Job ID", cloudJob => cloudJob.Id), + ("Job Status", cloudJob => cloudJob.Status), + ("Target", cloudJob => cloudJob.Details.Target), + ("Creation Time", cloudJob => cloudJob.Details.CreationTime.ToDateTime()?.ToString() ?? string.Empty), + ("Begin Execution Time", cloudJob => cloudJob.Details.BeginExecutionTime.ToDateTime()?.ToString() ?? string.Empty), + ("End Execution Time", cloudJob => cloudJob.Details.EndExecutionTime.ToDateTime()?.ToString() ?? string.Empty), + }, + Rows = jobsList.OrderByDescending(job => job.Details.CreationTime).ToList(), + }; + } + + public class CloudJobToHtmlEncoder : IResultEncoder + { + private static readonly IResultEncoder tableEncoder = new TableToHtmlDisplayEncoder(); + + public string MimeType => MimeTypes.Html; + + public EncodedData? Encode(object displayable) => + displayable.AsEnumerableOf() is IEnumerable jobs + ? tableEncoder.Encode(jobs.ToJupyterTable()) + : null; + } + + public class CloudJobToTextEncoder : IResultEncoder + { + private static readonly IResultEncoder tableEncoder = new TableToTextDisplayEncoder(); + + public string MimeType => MimeTypes.PlainText; + + public EncodedData? Encode(object displayable) => + displayable.AsEnumerableOf() is IEnumerable jobs + ? tableEncoder.Encode(jobs.ToJupyterTable()) + : null; + } + + public class CloudJobJsonConverter : JsonConverter + { + public override CloudJob ReadJson(JsonReader reader, Type objectType, CloudJob existingValue, bool hasExistingValue, JsonSerializer serializer) + => throw new NotImplementedException(); + + public override void WriteJson(JsonWriter writer, CloudJob value, JsonSerializer serializer) => + JToken.FromObject(value.ToDictionary()).WriteTo(writer); + } + + public class CloudJobListJsonConverter : JsonConverter> + { + public override IEnumerable ReadJson(JsonReader reader, Type objectType, IEnumerable existingValue, bool hasExistingValue, JsonSerializer serializer) + => throw new NotImplementedException(); + + public override void WriteJson(JsonWriter writer, IEnumerable value, JsonSerializer serializer) => + JToken.FromObject(value.Select(job => job.ToDictionary())).WriteTo(writer); + } +} diff --git a/src/AzureClient/Visualization/HistogramEncoders.cs b/src/AzureClient/Visualization/HistogramEncoders.cs new file mode 100644 index 0000000000..41f3f0698b --- /dev/null +++ b/src/AzureClient/Visualization/HistogramEncoders.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Jupyter.Core; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + internal class Histogram : Dictionary + { + } + + internal static class HistogramExtensions + { + internal static Histogram ToHistogram(this Stream stream) + { + var output = new StreamReader(stream).ReadToEnd(); + var deserializedOutput = JsonConvert.DeserializeObject>(output); + var deserializedHistogram = deserializedOutput["Histogram"] as JArray; + + var histogram = new Histogram(); + for (var i = 0; i < deserializedHistogram.Count - 1; i += 2) + { + var key = deserializedHistogram[i].ToObject(); + var value = deserializedHistogram[i + 1].ToObject(); + histogram[key] = value; + } + + return histogram; + } + + internal static Table> ToJupyterTable(this Histogram histogram) => + new Table> + { + Columns = new List<(string, Func, string>)> + { + ("Result", entry => entry.Key), + ("Frequency", entry => entry.Value.ToString()), + }, + Rows = histogram.ToList() + }; + } + + public class HistogramToHtmlEncoder : IResultEncoder + { + private static readonly IResultEncoder tableEncoder = new TableToHtmlDisplayEncoder(); + + public string MimeType => MimeTypes.Html; + + public EncodedData? Encode(object displayable) + { + if (displayable is Histogram histogram) + { + var style = "text-align: left"; + var columnStyle = $"{style}; width: 25ch"; + var lastColumnStyle = $"{style}; width: calc(100% - 25ch - 25ch)"; + + // Make the HTML table body by formatting everything as individual rows. + var formattedData = string.Join("\n", + histogram.Select(entry => + { + var result = entry.Key; + var frequency = entry.Value; + + return FormattableString.Invariant($@" + + {result} + {frequency} + + + + + "); + }) + ); + + // Construct and return the table. + var outputTable = $@" + + + + + + + + + + {formattedData} + +
ResultFrequencyHistogram
+ "; + return outputTable.ToEncodedData(); + } + else return null; + } + } + + public class HistogramToTextEncoder : IResultEncoder + { + private static readonly IResultEncoder tableEncoder = new TableToTextDisplayEncoder(); + + public string MimeType => MimeTypes.PlainText; + + public EncodedData? Encode(object displayable) => + displayable is Histogram histogram + ? tableEncoder.Encode(histogram.ToJupyterTable()) + : null; + } +} diff --git a/src/AzureClient/Visualization/JsonConverters.cs b/src/AzureClient/Visualization/JsonConverters.cs new file mode 100644 index 0000000000..394370155a --- /dev/null +++ b/src/AzureClient/Visualization/JsonConverters.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; + +using Newtonsoft.Json; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + public static class JsonConverters + { + private static readonly ImmutableList allConverters = ImmutableList.Create( + new CloudJobJsonConverter(), + new CloudJobListJsonConverter(), + new TargetStatusJsonConverter(), + new TargetStatusListJsonConverter(), + new AzureClientErrorJsonConverter() + ); + + public static JsonConverter[] AllConverters => allConverters.ToArray(); + } +} diff --git a/src/AzureClient/Visualization/TargetStatusEncoders.cs b/src/AzureClient/Visualization/TargetStatusEncoders.cs new file mode 100644 index 0000000000..6925163fcb --- /dev/null +++ b/src/AzureClient/Visualization/TargetStatusEncoders.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.Azure.Quantum.Client.Models; +using Microsoft.Jupyter.Core; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Quantum.IQSharp.AzureClient +{ + internal static class TargetStatusExtensions + { + internal static Dictionary ToDictionary(this TargetStatus target) => + new Dictionary() + { + ["id"] = target.Id, + ["current_availability"] = target.CurrentAvailability, + ["average_queue_time"] = target.AverageQueueTime, + }; + + internal static Table ToJupyterTable(this IEnumerable targets) => + new Table + { + Columns = new List<(string, Func)> + { + ("Target ID", target => target.Id), + ("Current Availability", target => target.CurrentAvailability), + ("Average Queue Time (Seconds)", target => target.AverageQueueTime.ToString()), + }, + Rows = targets.ToList() + }; + } + + public class TargetStatusToHtmlEncoder : IResultEncoder + { + private static readonly IResultEncoder tableEncoder = new TableToHtmlDisplayEncoder(); + + public string MimeType => MimeTypes.Html; + + public EncodedData? Encode(object displayable) => + displayable.AsEnumerableOf() is IEnumerable targets + ? tableEncoder.Encode(targets.ToJupyterTable()) + : null; + } + + public class TargetStatusToTextEncoder : IResultEncoder + { + private static readonly IResultEncoder tableEncoder = new TableToTextDisplayEncoder(); + + public string MimeType => MimeTypes.PlainText; + + public EncodedData? Encode(object displayable) => + displayable.AsEnumerableOf() is IEnumerable targets + ? tableEncoder.Encode(targets.ToJupyterTable()) + : null; + } + + public class TargetStatusJsonConverter : JsonConverter + { + public override TargetStatus ReadJson(JsonReader reader, Type objectType, TargetStatus existingValue, bool hasExistingValue, JsonSerializer serializer) + => throw new NotImplementedException(); + + public override void WriteJson(JsonWriter writer, TargetStatus value, JsonSerializer serializer) => + JToken.FromObject(value.ToDictionary()).WriteTo(writer); + } + + public class TargetStatusListJsonConverter : JsonConverter> + { + public override IEnumerable ReadJson(JsonReader reader, Type objectType, IEnumerable existingValue, bool hasExistingValue, JsonSerializer serializer) + => throw new NotImplementedException(); + + public override void WriteJson(JsonWriter writer, IEnumerable value, JsonSerializer serializer) => + JToken.FromObject(value.Select(job => job.ToDictionary())).WriteTo(writer); + } +} diff --git a/src/Core/Compiler/CompilerService.cs b/src/Core/Compiler/CompilerService.cs index 2869592270..6ac40cdd4b 100644 --- a/src/Core/Compiler/CompilerService.cs +++ b/src/Core/Compiler/CompilerService.cs @@ -16,9 +16,12 @@ using Microsoft.Quantum.QsCompiler.CompilationBuilder; using Microsoft.Quantum.QsCompiler.CsharpGeneration; using Microsoft.Quantum.QsCompiler.DataTypes; +using Microsoft.Quantum.QsCompiler.ReservedKeywords; using Microsoft.Quantum.QsCompiler.Serialization; +using Microsoft.Quantum.QsCompiler.SyntaxProcessing; using Microsoft.Quantum.QsCompiler.SyntaxTree; using Microsoft.Quantum.QsCompiler.Transformations.BasicTransformations; +using Microsoft.Quantum.QsCompiler.Transformations.QsCodeOutput; using Newtonsoft.Json.Bson; using QsReferences = Microsoft.Quantum.QsCompiler.CompilationBuilder.References; @@ -67,33 +70,61 @@ private QsCompilation UpdateCompilation(ImmutableDictionary sources return loaded.CompilationOutput; } + /// + public AssemblyInfo BuildEntryPoint(OperationInfo operation, CompilerMetadata metadatas, QSharpLogger logger, string dllName, string executionTarget = null) + { + var signature = operation.Header.PrintSignature(); + var argumentTuple = SyntaxTreeToQsharp.ArgumentTuple(operation.Header.ArgumentTuple, type => type.ToString(), symbolsOnly: true); + + var entryPointUri = new Uri(Path.GetFullPath(Path.Combine("/", $"entrypoint.qs"))); + var entryPointSnippet = @$"namespace ENTRYPOINT + {{ + open {operation.Header.QualifiedName.Namespace.Value}; + @{BuiltIn.EntryPoint.FullName}() + operation {signature} + {{ + return {operation.Header.QualifiedName}{argumentTuple}; + }} + }}"; + + var sources = new Dictionary() {{ entryPointUri, entryPointSnippet }}.ToImmutableDictionary(); + return BuildAssembly(sources, metadatas, logger, dllName, compileAsExecutable: true, executionTarget: executionTarget); + } + /// /// Builds the corresponding .net core assembly from the code in the given Q# Snippets. /// Each snippet code is wrapped inside the 'SNIPPETS_NAMESPACE' namespace and processed as a file /// with the same name as the snippet id. /// - public AssemblyInfo BuildSnippets(Snippet[] snippets, CompilerMetadata metadatas, QSharpLogger logger, string dllName) + public AssemblyInfo BuildSnippets(Snippet[] snippets, CompilerMetadata metadatas, QSharpLogger logger, string dllName, string executionTarget = null) { string WrapInNamespace(Snippet s) => $"namespace {Snippets.SNIPPETS_NAMESPACE} {{ open Microsoft.Quantum.Intrinsic; open Microsoft.Quantum.Canon; {s.code} }}"; + // Ignore any @EntryPoint() attributes found in snippets. + logger.ErrorCodesToIgnore.Add(QsCompiler.Diagnostics.ErrorCode.EntryPointInLibrary); + var sources = snippets.ToImmutableDictionary(s => s.Uri, WrapInNamespace); - return BuildAssembly(sources, metadatas, logger, dllName, compileAsExecutable: false); + var assembly = BuildAssembly(sources, metadatas, logger, dllName, compileAsExecutable: false, executionTarget: executionTarget); + + logger.ErrorCodesToIgnore.Remove(QsCompiler.Diagnostics.ErrorCode.EntryPointInLibrary); + + return assembly; } /// /// Builds the corresponding .net core assembly from the code in the given files. /// - public AssemblyInfo BuildFiles(string[] files, CompilerMetadata metadatas, QSharpLogger logger, string dllName) + public AssemblyInfo BuildFiles(string[] files, CompilerMetadata metadatas, QSharpLogger logger, string dllName, string executionTarget = null) { var sources = ProjectManager.LoadSourceFiles(files, d => logger?.Log(d), ex => logger?.Log(ex)); - return BuildAssembly(sources, metadatas, logger, dllName, compileAsExecutable: true); + return BuildAssembly(sources, metadatas, logger, dllName, compileAsExecutable: true, executionTarget: executionTarget); } /// /// Builds the corresponding .net core assembly from the Q# syntax tree. /// - private AssemblyInfo BuildAssembly(ImmutableDictionary sources, CompilerMetadata metadata, QSharpLogger logger, string dllName, bool compileAsExecutable) + private AssemblyInfo BuildAssembly(ImmutableDictionary sources, CompilerMetadata metadata, QSharpLogger logger, string dllName, bool compileAsExecutable, string executionTarget) { logger.LogDebug($"Compiling the following Q# files: {string.Join(",", sources.Keys.Select(f => f.LocalPath))}"); @@ -103,12 +134,15 @@ private AssemblyInfo BuildAssembly(ImmutableDictionary sources, Com try { // Generate C# simulation code from Q# syntax tree and convert it into C# syntax trees: - var trees = new List(); + var trees = new List(); NonNullable GetFileId(Uri uri) => CompilationUnitManager.TryGetFileId(uri, out var id) ? id : NonNullable.New(uri.AbsolutePath); foreach (var file in sources.Keys) { var sourceFile = GetFileId(file); - var code = SimulationCode.generate(sourceFile, CodegenContext.Create(qsCompilation.Namespaces)); + var codegenContext = string.IsNullOrEmpty(executionTarget) + ? CodegenContext.Create(qsCompilation.Namespaces) + : CodegenContext.Create(qsCompilation.Namespaces, new Dictionary() { { AssemblyConstants.ExecutionTarget, executionTarget } }); + var code = SimulationCode.generate(sourceFile, codegenContext); var tree = CSharpSyntaxTree.ParseText(code, encoding: UTF8Encoding.UTF8); trees.Add(tree); logger.LogDebug($"Generated the following C# code for {sourceFile.Value}:\n=============\n{code}\n=============\n"); diff --git a/src/Core/Compiler/ICompilerService.cs b/src/Core/Compiler/ICompilerService.cs index 7a9548a61f..76f95f6d0c 100644 --- a/src/Core/Compiler/ICompilerService.cs +++ b/src/Core/Compiler/ICompilerService.cs @@ -12,15 +12,21 @@ namespace Microsoft.Quantum.IQSharp /// public interface ICompilerService { + /// + /// Builds an executable assembly with an entry point that invokes the Q# operation specified + /// by the provided object. + /// + AssemblyInfo BuildEntryPoint(OperationInfo operation, CompilerMetadata metadatas, QSharpLogger logger, string dllName, string executionTarget = null); + /// /// Builds the corresponding .net core assembly from the code in the given Q# Snippets. /// - AssemblyInfo BuildSnippets(Snippet[] snippets, CompilerMetadata metadatas, QSharpLogger logger, string dllName); + AssemblyInfo BuildSnippets(Snippet[] snippets, CompilerMetadata metadatas, QSharpLogger logger, string dllName, string executionTarget = null); /// /// Builds the corresponding .net core assembly from the code in the given files. /// - AssemblyInfo BuildFiles(string[] files, CompilerMetadata metadatas, QSharpLogger logger, string dllName); + AssemblyInfo BuildFiles(string[] files, CompilerMetadata metadatas, QSharpLogger logger, string dllName, string executionTarget = null); /// /// Returns the names of all declared callables and types. diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 036b02f2ca..872d3a96af 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -32,11 +32,11 @@ - - - - - + + + + + diff --git a/src/Core/Extensions/String.cs b/src/Core/Extensions/String.cs new file mode 100644 index 0000000000..ea85828f63 --- /dev/null +++ b/src/Core/Extensions/String.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Text.RegularExpressions; + +namespace Microsoft.Quantum.IQSharp +{ + public static partial class Extensions + { + /// + /// Removes common indents from each line in a string, + /// similarly to Python's textwrap.dedent() function. + /// + public static string Dedent(this string text) + { + // First, start by finding the length of common indents, + // disregarding lines that are only whitespace. + var leadingWhitespaceRegex = new Regex(@"^[ \t]*"); + var minWhitespace = int.MaxValue; + foreach (var line in text.Split("\n")) + { + if (!string.IsNullOrWhiteSpace(line)) + { + var match = leadingWhitespaceRegex.Match(line); + minWhitespace = match.Success + ? System.Math.Min(minWhitespace, match.Value.Length) + : minWhitespace = 0; + } + } + + // We can use that to build a new regex that strips + // out common indenting. + var leftTrimRegex = new Regex(@$"^[ \t]{{{minWhitespace}}}", RegexOptions.Multiline); + return leftTrimRegex.Replace(text, ""); + } + } +} diff --git a/src/Core/Loggers/QsharpLogger.cs b/src/Core/Loggers/QsharpLogger.cs index 247227fc72..6afbb7ebb9 100644 --- a/src/Core/Loggers/QsharpLogger.cs +++ b/src/Core/Loggers/QsharpLogger.cs @@ -22,13 +22,12 @@ public class QSharpLogger : QsCompiler.Diagnostics.LogTracker public List Logs { get; } - public List ErrorCodesToIgnore { get; } + public List ErrorCodesToIgnore { get; } = new List(); - public QSharpLogger(ILogger logger, List errorCodesToIgnore = null) + public QSharpLogger(ILogger logger) { this.Logger = logger; this.Logs = new List(); - this.ErrorCodesToIgnore = errorCodesToIgnore ?? new List(); } public static LogLevel MapLevel(LSP.DiagnosticSeverity original) diff --git a/src/Core/OperationInfo.cs b/src/Core/OperationInfo.cs index caaeddbd5b..ebb9141f04 100644 --- a/src/Core/OperationInfo.cs +++ b/src/Core/OperationInfo.cs @@ -26,12 +26,14 @@ public class OperationInfo { private Lazy> _params; private Lazy _roslynParams; + private Lazy _returnType; internal OperationInfo(Type roslynType, CallableDeclarationHeader header) { this.Header = header ?? throw new ArgumentNullException(nameof(header)); RoslynType = roslynType; _roslynParams = new Lazy(() => RoslynType?.GetMethod("Run").GetParameters().Skip(1).ToArray()); + _returnType = new Lazy(() => RoslynType?.GetMethod("Run").ReturnType.GenericTypeArguments.Single()); _params = new Lazy>(() => RoslynParameters?.ToDictionary(p => p.Name, p => p.ParameterType.Name)); } @@ -60,6 +62,12 @@ internal OperationInfo(Type roslynType, CallableDeclarationHeader header) [JsonIgnore] public ParameterInfo[] RoslynParameters => _roslynParams.Value; + /// + /// The return type for the underlying compiled .NET Type for this Q# operation + /// + [JsonIgnore] + public Type ReturnType => _returnType.Value; + public override string ToString() => FullName; } diff --git a/src/Core/Resolver/OperationResolver.cs b/src/Core/Resolver/OperationResolver.cs index 8be04f2933..2bf52f5b16 100644 --- a/src/Core/Resolver/OperationResolver.cs +++ b/src/Core/Resolver/OperationResolver.cs @@ -71,11 +71,12 @@ private IEnumerable RelevantAssemblies() /// Symbol names without a dot are resolved to the first symbol /// whose base name matches the given name. /// - public OperationInfo Resolve(string name) + public OperationInfo Resolve(string name) => ResolveFromAssemblies(name, RelevantAssemblies()); + + public static OperationInfo ResolveFromAssemblies(string name, IEnumerable assemblies) { var isQualified = name.Contains('.'); - var relevant = RelevantAssemblies(); - foreach (var operation in relevant.SelectMany(asm => asm.Operations)) + foreach (var operation in assemblies.SelectMany(asm => asm.Operations)) { if (name == (isQualified ? operation.FullName : operation.Header.QualifiedName.Name.Value)) { diff --git a/src/Core/Snippets/ISnippets.cs b/src/Core/Snippets/ISnippets.cs index f8393cb6b3..60e52fb2ea 100644 --- a/src/Core/Snippets/ISnippets.cs +++ b/src/Core/Snippets/ISnippets.cs @@ -55,6 +55,11 @@ public interface ISnippets /// AssemblyInfo AssemblyInfo { get; } + /// + /// The list of currently available snippets. + /// + IEnumerable Items { get; set; } + /// /// Adds or updates a snippet of code. If successful, this updates the AssemblyInfo /// with the new operations found in the Snippet and returns a new Snippet diff --git a/src/Core/Snippets/Snippets.cs b/src/Core/Snippets/Snippets.cs index bf3f0cf189..f6afa05204 100644 --- a/src/Core/Snippets/Snippets.cs +++ b/src/Core/Snippets/Snippets.cs @@ -104,7 +104,7 @@ private void OnWorkspaceReloaded(object sender, ReloadedEventArgs e) /// /// The list of currently available snippets. /// - internal IEnumerable Items { get; set; } + public IEnumerable Items { get; set; } /// /// The list of Q# operations available across all snippets. @@ -144,11 +144,7 @@ public Snippet Compile(string code) if (string.IsNullOrWhiteSpace(code)) throw new ArgumentNullException(nameof(code)); var duration = Stopwatch.StartNew(); - var errorCodesToIgnore = new List() - { - QsCompiler.Diagnostics.ErrorCode.EntryPointInLibrary, // Ignore any @EntryPoint() attributes found in snippets. - }; - var logger = new QSharpLogger(Logger, errorCodesToIgnore); + var logger = new QSharpLogger(Logger); try { diff --git a/src/Core/Workspace/IWorkspace.cs b/src/Core/Workspace/IWorkspace.cs index 4bc0e806ca..f950f1ca79 100644 --- a/src/Core/Workspace/IWorkspace.cs +++ b/src/Core/Workspace/IWorkspace.cs @@ -64,6 +64,11 @@ public interface IWorkspace /// string Root { get; } + /// + /// Gets the source files to be built for this Workspace. + /// + public IEnumerable SourceFiles { get; } + /// /// The folder where the assembly is permanently saved for cache. /// diff --git a/src/Jupyter/Extensions.cs b/src/Jupyter/Extensions.cs index 5a602d89d4..7b2c1bb9bf 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)System.Convert.ChangeType(JsonConvert.DeserializeObject(parameterValue), typeof(T)) ?? defaultValue; + } } } diff --git a/src/Jupyter/Jupyter.csproj b/src/Jupyter/Jupyter.csproj index 356fdb5131..b092621bf2 100644 --- a/src/Jupyter/Jupyter.csproj +++ b/src/Jupyter/Jupyter.csproj @@ -35,7 +35,8 @@ - + + diff --git a/src/Jupyter/Magic/AbstractMagic.cs b/src/Jupyter/Magic/AbstractMagic.cs new file mode 100644 index 0000000000..5e53235dbe --- /dev/null +++ b/src/Jupyter/Magic/AbstractMagic.cs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Jupyter.Core; +using Microsoft.Quantum.IQSharp.Common; +using Microsoft.Quantum.QsCompiler.Serialization; + +namespace Microsoft.Quantum.IQSharp.Jupyter +{ + /// + /// Abstract base class for IQ# magic symbols. + /// + public abstract class AbstractMagic : CancellableMagicSymbol + { + /// + /// Constructs a new magic symbol given its name and documentation. + /// + public AbstractMagic(string keyword, Documentation docs) + { + this.Name = $"%{keyword}"; + this.Documentation = docs; + + this.Kind = SymbolKind.Magic; + this.ExecuteCancellable = this.SafeExecute(this.RunCancellable); + } + + /// + /// Given a function representing the execution of a magic command, + /// returns a new function that executes + /// and catches any exceptions that occur during execution. The + /// returned execution function displays the given exceptions to its + /// display channel. + /// + public Func> SafeExecute( + Func magic) => + async (input, channel, cancellationToken) => + { + channel = channel.WithNewLines(); + + try + { + return magic(input, channel, cancellationToken); + } + catch (InvalidWorkspaceException ws) + { + foreach (var m in ws.Errors) channel.Stderr(m); + return ExecuteStatus.Error.ToExecutionResult(); + } + catch (AggregateException agg) + { + foreach (var e in agg.InnerExceptions) channel.Stderr(e?.Message); + return ExecuteStatus.Error.ToExecutionResult(); + } + catch (Exception e) + { + channel.Stderr(e.Message); + return ExecuteStatus.Error.ToExecutionResult(); + } + }; + + /// + /// 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 static Dictionary ParseInputParameters(string input, string firstParameterInferredName = "") + { + Dictionary inputParameters = new Dictionary(); + + // This regex looks for four types of matches: + // 1. (\{.*\}) + // Matches anything enclosed in matching curly braces. + // 2. [^\s"]+(?:\s*=\s*)(?:"[^"]*"|[^\s"]*)* + // Matches things that look like key=value, allowing whitespace around the equals sign, + // and allowing value to be a quoted string, e.g., key="value". + // 3. [^\s"]+(?:"[^"]*"[^\s"]*)* + // Matches things that are single words, not inside quotes. + // 4. (?:"[^"]*"[^\s"]*)+ + // Matches quoted strings. + var regex = new Regex(@"(\{.*\})|[^\s""]+(?:\s*=\s*)(?:""[^""]*""|[^\s""]*)*|[^\s""]+(?:""[^""]*""[^\s""]*)*|(?:""[^""]*""[^\s""]*)+"); + var args = regex.Matches(input).Select(match => match.Value); + + var regexBeginEndQuotes = new Regex(@"^['""]|['""]$"); + + // 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.Any() && + !args.First().StartsWith("{") && + !args.First().Contains("=") && + !string.IsNullOrEmpty(firstParameterInferredName)) + { + using var writer = new StringWriter(); + Json.Serializer.Serialize(writer, regexBeginEndQuotes.Replace(args.First(), string.Empty)); + inputParameters[firstParameterInferredName] = writer.ToString(); + args = args.Skip(1); + } + + // See if the remaining arguments look like JSON. If so, parse as JSON. + if (args.Any() && args.First().StartsWith("{")) + { + var jsonArgs = JsonToDict(args.First()); + foreach (var (key, jsonValue) in jsonArgs) + { + inputParameters[key] = jsonValue; + } + + return inputParameters; + } + + // Otherwise, try to parse as key=value pairs and serialize into the dictionary as JSON. + foreach (string arg in args) + { + var tokens = arg.Split("=", 2); + var key = regexBeginEndQuotes.Replace(tokens[0].Trim(), string.Empty); + var value = tokens.Length switch + { + // If there was no value provided explicitly, treat it as an implicit "true" value + 1 => true as object, + + // Trim whitespace and also enclosing single-quotes or double-quotes before returning + 2 => regexBeginEndQuotes.Replace(tokens[1].Trim(), string.Empty) as object, + + // We called arg.Split("=", 2), so there should never be more than 2 + _ => throw new InvalidOperationException() + }; + 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. + /// + public abstract ExecutionResult Run(string input, IChannel channel); + + /// + /// A method to be run when the magic command is executed, including a cancellation + /// token to use for requesting cancellation. + /// + /// + /// The default implementation in ignores the cancellation token. + /// Derived classes should override this method and monitor the cancellation token if they + /// wish to support cancellation. + /// + public virtual ExecutionResult RunCancellable(string input, IChannel channel, CancellationToken cancellationToken) => + Run(input, channel); + } +} diff --git a/src/Kernel/IQSharpEngine.cs b/src/Kernel/IQSharpEngine.cs index 759b89114b..88122f5271 100644 --- a/src/Kernel/IQSharpEngine.cs +++ b/src/Kernel/IQSharpEngine.cs @@ -60,7 +60,11 @@ IMagicSymbolResolver magicSymbolResolver RegisterDisplayEncoder(new DataTableToTextEncoder()); RegisterDisplayEncoder(new DisplayableExceptionToHtmlEncoder()); RegisterDisplayEncoder(new DisplayableExceptionToTextEncoder()); - RegisterJsonEncoder(JsonConverters.AllConverters); + + RegisterJsonEncoder( + JsonConverters.AllConverters + .Concat(AzureClient.JsonConverters.AllConverters) + .ToArray()); RegisterSymbolResolver(this.SymbolsResolver); RegisterSymbolResolver(this.MagicResolver); diff --git a/src/Kernel/Kernel.csproj b/src/Kernel/Kernel.csproj index 7ab5e7838a..b42ee4c7be 100644 --- a/src/Kernel/Kernel.csproj +++ b/src/Kernel/Kernel.csproj @@ -22,7 +22,6 @@ - @@ -39,6 +38,7 @@ + diff --git a/src/Kernel/Magic/AbstractMagic.cs b/src/Kernel/Magic/AbstractMagic.cs deleted file mode 100644 index 9babaf2ca1..0000000000 --- a/src/Kernel/Magic/AbstractMagic.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.Jupyter.Core; -using Microsoft.Quantum.IQSharp.Common; -using Microsoft.Quantum.IQSharp.Kernel; - -namespace Microsoft.Quantum.IQSharp.Kernel -{ - /// - /// Abstract base class for IQ# magic symbols. - /// - public abstract class AbstractMagic : MagicSymbol - { - /// - /// Constructs a new magic symbol given its name and documentation. - /// - public AbstractMagic(string keyword, Documentation docs) - { - this.Name = $"%{keyword}"; - this.Documentation = docs; - - this.Kind = SymbolKind.Magic; - this.Execute = SafeExecute(this.Run); - } - - /// - /// Given a function representing the execution of a magic command, - /// returns a new function that executes - /// and catches any exceptions that occur during execution. The - /// returned execution function displays the given exceptions to its - /// display channel. - /// - public Func> SafeExecute(Func magic) => - async (input, channel) => - { - channel = channel.WithNewLines(); - - try - { - return magic(input, channel); - } - catch (InvalidWorkspaceException ws) - { - foreach (var m in ws.Errors) channel.Stderr(m); - return ExecuteStatus.Error.ToExecutionResult(); - } - catch (AggregateException agg) - { - foreach (var e in agg.InnerExceptions) channel.Stderr(e?.Message); - return ExecuteStatus.Error.ToExecutionResult(); - } - catch (Exception e) - { - channel.Stderr(e.Message); - return ExecuteStatus.Error.ToExecutionResult(); - } - }; - - /// - /// Parses the input to a magic command, interpreting the input as - /// a name followed by a JSON-serialized dictionary. - /// - public static (string, Dictionary) ParseInput(string input) - { - if (input == null) return (string.Empty, new Dictionary { }); - var BLANK_SPACE = new char[1] { ' ' }; - - var inputParts = input.Split(BLANK_SPACE, 2, StringSplitOptions.RemoveEmptyEntries); - var name = inputParts.Length > 0 ? inputParts[0] : string.Empty; - var args = inputParts.Length > 1 - ? JsonConverters.JsonToDict(inputParts[1]) - : new Dictionary { }; - - return (name, args); - } - - /// - /// A method to be run when the magic command is executed. - /// - public abstract ExecutionResult Run(string input, IChannel channel); - } -} diff --git a/src/Kernel/Magic/ConfigMagic.cs b/src/Kernel/Magic/ConfigMagic.cs index 6a020af780..586a8a6cbb 100644 --- a/src/Kernel/Magic/ConfigMagic.cs +++ b/src/Kernel/Magic/ConfigMagic.cs @@ -45,7 +45,7 @@ save those options to a JSON file in the current working dump.basisStateLabelingConvention ""BigEndian"" dump.truncateSmallAmplitudes true ``` - ", + ".Dedent(), @" Configure the `DumpMachine` and `DumpRegister` callables diff --git a/src/Kernel/Magic/EstimateMagic.cs b/src/Kernel/Magic/EstimateMagic.cs index 7e227003b7..54e58f81d0 100644 --- a/src/Kernel/Magic/EstimateMagic.cs +++ b/src/Kernel/Magic/EstimateMagic.cs @@ -19,6 +19,8 @@ namespace Microsoft.Quantum.IQSharp.Kernel /// public class EstimateMagic : AbstractMagic { + private const string ParameterNameOperationName = "__operationName__"; + /// /// Given a symbol resolver that can be used to locate operations, /// constructs a new magic command that performs resource estimation @@ -52,15 +54,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}"); var qsim = new ResourcesEstimator().WithStackTraceDisplay(channel); qsim.DisableLogToConsole(); - await symbol.Operation.RunAsync(qsim, args); + await symbol.Operation.RunAsync(qsim, inputParameters); return qsim.Data.ToExecutionResult(); } 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/PackageMagic.cs b/src/Kernel/Magic/PackageMagic.cs index 11a635e462..8ca8aa2d43 100644 --- a/src/Kernel/Magic/PackageMagic.cs +++ b/src/Kernel/Magic/PackageMagic.cs @@ -17,6 +17,8 @@ namespace Microsoft.Quantum.IQSharp.Kernel /// public class PackageMagic : AbstractMagic { + private const string ParameterNamePackageName = "__packageName__"; + /// /// Constructs a new magic command that adds package references to /// a given references collection. @@ -39,7 +41,8 @@ public PackageMagic(IReferences references) : base( /// public override ExecutionResult Run(string input, IChannel channel) { - var (name, _) = ParseInput(input); + var inputParameters = ParseInputParameters(input, firstParameterInferredName: ParameterNamePackageName); + var name = inputParameters.DecodeParameter(ParameterNamePackageName); var status = new Jupyter.TaskStatus($"Adding package {name}"); var statusUpdater = channel.DisplayUpdatable(status); void Update() => statusUpdater.Update(status); diff --git a/src/Kernel/Magic/Resolution/MagicResolver.cs b/src/Kernel/Magic/Resolution/MagicResolver.cs index 2768c00a19..5ed668c476 100644 --- a/src/Kernel/Magic/Resolution/MagicResolver.cs +++ b/src/Kernel/Magic/Resolution/MagicResolver.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Jupyter.Core; +using Microsoft.Quantum.IQSharp.AzureClient; using Microsoft.Quantum.IQSharp.Common; using Newtonsoft.Json; @@ -22,7 +23,7 @@ namespace Microsoft.Quantum.IQSharp.Kernel /// public class MagicSymbolResolver : IMagicSymbolResolver { - private AssemblyInfo kernelAssembly; + private AssemblyInfo[] kernelAssemblies; private Dictionary cache; private IServiceProvider services; private IReferences references; @@ -38,7 +39,11 @@ public MagicSymbolResolver(IServiceProvider services, ILogger(); this.logger = logger; - this.kernelAssembly = new AssemblyInfo(typeof(MagicSymbolResolver).Assembly); + this.kernelAssemblies = new[] + { + new AssemblyInfo(typeof(MagicSymbolResolver).Assembly), + new AssemblyInfo(typeof(AzureClient.AzureClient).Assembly) + }; this.services = services; this.references = services.GetService(); } @@ -50,7 +55,10 @@ public MagicSymbolResolver(IServiceProvider services, ILogger private IEnumerable RelevantAssemblies() { - yield return this.kernelAssembly; + foreach (var asm in this.kernelAssemblies) + { + yield return asm; + } foreach (var asm in references.Assemblies) { @@ -80,7 +88,7 @@ public MagicSymbol Resolve(string symbolName) foreach (var magic in FindAllMagicSymbols()) { - if (symbolName.StartsWith(magic.Name)) + if (symbolName == magic.Name) { this.logger.LogDebug($"Using magic {magic.Name}"); return magic; diff --git a/src/Kernel/Magic/Simulate.cs b/src/Kernel/Magic/Simulate.cs index d3cea8b17a..5e552541b8 100644 --- a/src/Kernel/Magic/Simulate.cs +++ b/src/Kernel/Magic/Simulate.cs @@ -18,6 +18,8 @@ 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 +57,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/ToffoliMagic.cs b/src/Kernel/Magic/ToffoliMagic.cs index bb46a50e8f..48160c2bef 100644 --- a/src/Kernel/Magic/ToffoliMagic.cs +++ b/src/Kernel/Magic/ToffoliMagic.cs @@ -16,6 +16,8 @@ namespace Microsoft.Quantum.IQSharp.Kernel /// public class ToffoliMagic : AbstractMagic { + private const string ParameterNameOperationName = "__operationName__"; + /// /// Default constructor. /// @@ -43,8 +45,9 @@ 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}"); @@ -52,7 +55,7 @@ public async Task RunAsync(string input, IChannel channel) qsim.DisableLogToConsole(); qsim.OnLog += channel.Stdout; - 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..22b089f7b7 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 { @@ -14,6 +14,8 @@ namespace Microsoft.Quantum.IQSharp.Kernel /// public class WorkspaceMagic : AbstractMagic { + private const string ParameterNameCommand = "__command__"; + /// /// Given a workspace, constructs a new magic symbol to control /// that workspace. @@ -51,7 +53,8 @@ public void CheckIfReady() /// public override ExecutionResult Run(string input, IChannel channel) { - var (command, _) = ParseInput(input); + var inputParameters = ParseInputParameters(input, firstParameterInferredName: ParameterNameCommand); + var command = inputParameters.DecodeParameter(ParameterNameCommand); if (string.IsNullOrWhiteSpace(command)) { diff --git a/src/Kernel/client/kernel.ts b/src/Kernel/client/kernel.ts index 3be307a020..7590ddc03b 100644 --- a/src/Kernel/client/kernel.ts +++ b/src/Kernel/client/kernel.ts @@ -69,6 +69,12 @@ function defineQSharpMode() { regex: String.raw`(%(config|estimate|lsmagic|package|performance|simulate|toffoli|version|who|workspace))\b`, beginWord: true, }, + { + // Azure magic commands + token: "builtin", + regex: String.raw`(%azure\.(connect|execute|jobs|output|status|submit|target))\b`, + beginWord: true, + }, { // chemistry magic commands token: "builtin", diff --git a/src/Python/qsharp/azure.py b/src/Python/qsharp/azure.py new file mode 100644 index 0000000000..32036f528a --- /dev/null +++ b/src/Python/qsharp/azure.py @@ -0,0 +1,136 @@ +#!/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 +from typing import List, Dict, Callable, Any, Union +from enum import Enum + +## LOGGING ## + +import logging +logger = logging.getLogger(__name__) + +## EXPORTS ## + +__all__ = [ + 'connect', + 'target', + 'submit', + 'execute', + 'status', + 'output', + 'jobs' + 'AzureTarget', + 'AzureJob', + 'AzureError' +] + +## CLASSES ## + +class AzureTarget(object): + """ + Represents an instance of an Azure Quantum execution target for Q# job submission. + """ + def __init__(self, data: Dict): + self.__dict__ = data + self.id = data["id"] + self.current_availability = data["current_availability"] + self.average_queue_time = data["average_queue_time"] + + def __repr__(self) -> str: + return self.__dict__.__repr__() + + def __eq__(self, other) -> bool: + if not isinstance(other, AzureTarget): + # don't attempt to compare against unrelated types + return NotImplemented + return self.__dict__ == other.__dict__ + +class AzureJob(object): + """ + Represents an instance of an Azure Quantum job. + """ + def __init__(self, data: Dict): + self.__dict__ = data + self.id = data["id"] + self.name = data["name"] + self.status = data["status"] + self.provider = data["provider"] + self.target = data["target"] + self.creation_time = data["creation_time"] + self.begin_execution_time = data["begin_execution_time"] + self.end_execution_time = data["end_execution_time"] + + def __repr__(self) -> str: + return self.__dict__.__repr__() + + def __eq__(self, other) -> bool: + if not isinstance(other, AzureJob): + # don't attempt to compare against unrelated types + return NotImplemented + return self.__dict__ == other.__dict__ + +class AzureError(Exception): + """ + Contains error information resulting from an attempt to interact with Azure. + """ + def __init__(self, data: Dict): + self.__dict__ = data + self.error_code = data["error_code"] + self.error_name = data["error_name"] + self.error_description = data["error_description"] + + def __repr__(self) -> str: + return self.__dict__.__repr__() + + def __eq__(self, other) -> bool: + if not isinstance(other, AzureError): + # don't attempt to compare against unrelated types + return NotImplemented + return self.__dict__ == other.__dict__ + +## FUNCTIONS ## + +def connect(**params) -> List[AzureTarget]: + result = qsharp.client._execute_magic(f"azure.connect", raise_on_stderr=False, **params) + if "error_code" in result: raise AzureError(result) + return [AzureTarget(target) for target in result] + +def target(name : str = '', **params) -> AzureTarget: + result = qsharp.client._execute_magic(f"azure.target {name}", raise_on_stderr=False, **params) + if "error_code" in result: raise AzureError(result) + return AzureTarget(result) + +def submit(op, **params) -> AzureJob: + result = qsharp.client._execute_callable_magic("azure.submit", op, raise_on_stderr=False, **params) + if "error_code" in result: raise AzureError(result) + return AzureJob(result) + +def execute(op, **params) -> Dict: + result = qsharp.client._execute_callable_magic("azure.execute", op, raise_on_stderr=False, **params) + if "error_code" in result: raise AzureError(result) + return result + +def status(jobId : str = '', **params) -> AzureJob: + result = qsharp.client._execute_magic(f"azure.status {jobId}", raise_on_stderr=False, **params) + if "error_code" in result: raise AzureError(result) + return AzureJob(result) + +def output(jobId : str = '', **params) -> Dict: + result = qsharp.client._execute_magic(f"azure.output {jobId}", raise_on_stderr=False, **params) + if "error_code" in result: raise AzureError(result) + return result + +def jobs(filter : str = '', **params) -> List[AzureJob]: + result = qsharp.client._execute_magic(f"azure.jobs \"{filter}\"", raise_on_stderr=False, **params) + if "error_code" in result: raise AzureError(result) + return [AzureJob(job) for job in result] diff --git a/src/Python/qsharp/tests/test_azure.py b/src/Python/qsharp/tests/test_azure.py new file mode 100644 index 0000000000..13ba002958 --- /dev/null +++ b/src/Python/qsharp/tests/test_azure.py @@ -0,0 +1,150 @@ +#!/bin/env python +# -*- coding: utf-8 -*- +## +# test_azure.py: Tests Azure Quantum functionality against a mock workspace. +## +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +## + +## IMPORTS ## + +import importlib +import os +import pytest +import qsharp +from qsharp.azure import AzureError, AzureJob, AzureTarget +import sys + +## SETUP ## + +@pytest.fixture(scope="session", autouse=True) +def set_environment_variables(): + # Need to restart the IQ# kernel after setting the environment variable + os.environ["AZURE_QUANTUM_ENV"] = "mock" + importlib.reload(qsharp) + if "qsharp.chemistry" in sys.modules: + importlib.reload(qsharp.chemistry) + +## TESTS ## + +def test_empty_workspace(): + """ + Tests behavior of a mock workspace with no providers. + """ + with pytest.raises(AzureError) as exception_info: + qsharp.azure.target() + assert exception_info.value.error_name == "NotConnected" + + targets = qsharp.azure.connect( + storage="test", + subscription="test", + resourceGroup="test", + workspace="test" + ) + assert targets == [] + + with pytest.raises(AzureError) as exception_info: + qsharp.azure.target("invalid.target") + assert exception_info.value.error_name == "InvalidTarget" + + jobs = qsharp.azure.jobs() + assert jobs == [] + +def test_workspace_create_with_parameters(): + """ + Tests behavior of a mock workspace with providers, using parameters to connect. + """ + targets = qsharp.azure.connect( + storage="test", + subscription="test", + resourceGroup="test", + workspace="WorkspaceNameWithMockProviders" + ) + assert isinstance(targets, list) + assert len(targets) > 0 + + _test_workspace_with_providers_after_connection() + +def test_workspace_create_with_resource_id(): + """ + Tests behavior of a mock workspace with providers, using resource ID to connect. + """ + subscriptionId = "f846b2bd-d0e2-4a1d-8141-4c6944a9d387" + resourceGroupName = "test" + workspaceName = "WorkspaceNameWithMockProviders" + targets = qsharp.azure.connect( + resourceId=f"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Quantum/Workspaces/{workspaceName}") + assert isinstance(targets, list) + assert len(targets) > 0 + + _test_workspace_with_providers_after_connection() + _test_workspace_job_execution() + +def test_workspace_create_with_resource_id_and_storage(): + """ + Tests behavior of a mock workspace with providers, using resource ID and storage connection string to connect. + """ + subscriptionId = "f846b2bd-d0e2-4a1d-8141-4c6944a9d387" + resourceGroupName = "test" + workspaceName = "WorkspaceNameWithMockProviders" + storageAccountConnectionString = "test" + targets = qsharp.azure.connect( + resourceId=f"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Quantum/Workspaces/{workspaceName}", + storage=storageAccountConnectionString) + assert isinstance(targets, list) + assert len(targets) > 0 + + _test_workspace_with_providers_after_connection() + +def _test_workspace_with_providers_after_connection(): + with pytest.raises(AzureError) as exception_info: + qsharp.azure.target() + assert exception_info.value.error_name == "NoTarget" + + targets = qsharp.azure.connect() + for target in targets: + active_target = qsharp.azure.target(target.id) + assert isinstance(active_target, AzureTarget) + assert active_target == target + + # Submit a snippet operation without parameters + op = qsharp.compile(""" + operation HelloQ() : Result + { + Message($"Hello from quantum world!"); + return Zero; + } + """) + + job = qsharp.azure.submit(op) + assert isinstance(job, AzureJob) + + retrieved_job = qsharp.azure.status(job.id) + assert isinstance(retrieved_job, AzureJob) + assert job.id == retrieved_job.id + +def _test_workspace_job_execution(): + # Execute a workspace operation with parameters + op = qsharp.QSharpCallable("Microsoft.Quantum.SanityTests.HelloAgain", None) + + with pytest.raises(AzureError) as exception_info: + qsharp.azure.execute(op) + assert exception_info.value.error_name == "JobSubmissionFailed" + + histogram = qsharp.azure.execute(op, count=3, name="test", timeout=3, poll=0.5) + assert isinstance(histogram, dict) + + retrieved_histogram = qsharp.azure.output() + assert isinstance(retrieved_histogram, dict) + assert histogram == retrieved_histogram + + # Check that both submitted jobs exist in the workspace + jobs = qsharp.azure.jobs() + assert isinstance(jobs, list) + assert len(jobs) == 2 + + # Check that job filtering works + jobs = qsharp.azure.jobs(jobs[0].id) + assert isinstance(jobs, list) + assert len(jobs) == 1 diff --git a/src/Tests/AzureClientEntryPointTests.cs b/src/Tests/AzureClientEntryPointTests.cs new file mode 100644 index 0000000000..a4a121c994 --- /dev/null +++ b/src/Tests/AzureClientEntryPointTests.cs @@ -0,0 +1,135 @@ +// 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; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Quantum.IQSharp; +using Microsoft.Quantum.IQSharp.AzureClient; +using Microsoft.Quantum.IQSharp.Common; +using Microsoft.Quantum.Runtime; +using Microsoft.Quantum.Simulation.Common; +using Microsoft.Quantum.Simulation.Core; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Tests.IQSharp +{ + [TestClass] + public class AzureClientEntryPointTests + { + private IEntryPointGenerator Init(string workspace, IEnumerable? codeSnippets = null) + { + var services = Startup.CreateServiceProvider(workspace); + + if (codeSnippets != null) + { + var snippets = services.GetService(); + snippets.Items = codeSnippets.Select(codeSnippet => new Snippet() { code = codeSnippet }); + } + + return services.GetService(); + } + + [TestMethod] + public async Task FromSnippet() + { + var entryPointGenerator = Init("Workspace", new string[] { SNIPPETS.HelloQ }); + var entryPoint = entryPointGenerator.Generate("HelloQ", null); + Assert.IsNotNull(entryPoint); + + var job = await entryPoint.SubmitAsync( + new MockQuantumMachine(), + new AzureSubmissionContext()); + Assert.IsNotNull(job); + } + + [TestMethod] + public async Task FromBrokenSnippet() + { + var entryPointGenerator = Init("Workspace", new string[] { SNIPPETS.TwoErrors }); + Assert.ThrowsException(() => + entryPointGenerator.Generate("TwoErrors", null)); + } + + [TestMethod] + public async Task FromWorkspace() + { + var entryPointGenerator = Init("Workspace"); + var entryPoint = entryPointGenerator.Generate("Tests.qss.HelloAgain", null); + Assert.IsNotNull(entryPoint); + + var job = await entryPoint.SubmitAsync( + new MockQuantumMachine(), + new AzureSubmissionContext() { InputParameters = new Dictionary() { ["count"] = "2", ["name"] = "test" } }); + Assert.IsNotNull(job); + } + + [TestMethod] + public async Task FromWorkspaceMissingArgument() + { + var entryPointGenerator = Init("Workspace"); + var entryPoint = entryPointGenerator.Generate("Tests.qss.HelloAgain", null); + Assert.IsNotNull(entryPoint); + + Assert.ThrowsException(() => + entryPoint.SubmitAsync( + new MockQuantumMachine(), + new AzureSubmissionContext() { InputParameters = new Dictionary() { ["count"] = "2" } })); + } + + [TestMethod] + public async Task FromWorkspaceIncorrectArgumentType() + { + var entryPointGenerator = Init("Workspace"); + var entryPoint = entryPointGenerator.Generate("Tests.qss.HelloAgain", null); + Assert.IsNotNull(entryPoint); + + Assert.ThrowsException(() => + entryPoint.SubmitAsync( + new MockQuantumMachine(), + new AzureSubmissionContext() { InputParameters = new Dictionary() { ["count"] = "NaN", ["name"] = "test" } })); + } + + [TestMethod] + public async Task FromBrokenWorkspace() + { + var entryPointGenerator = Init("Workspace.Broken"); + Assert.ThrowsException(() => + entryPointGenerator.Generate("Tests.qss.HelloAgain", null)); + } + + [TestMethod] + public async Task FromSnippetDependsOnWorkspace() + { + var entryPointGenerator = Init("Workspace", new string[] { SNIPPETS.DependsOnWorkspace }); + var entryPoint = entryPointGenerator.Generate("DependsOnWorkspace", null); + Assert.IsNotNull(entryPoint); + + var job = await entryPoint.SubmitAsync( + new MockQuantumMachine(), + new AzureSubmissionContext()); + Assert.IsNotNull(job); + } + + [TestMethod] + public async Task InvalidOperationName() + { + var entryPointGenerator = Init("Workspace"); + Assert.ThrowsException(() => + entryPointGenerator.Generate("InvalidOperationName", null)); + } + + [TestMethod] + public async Task InvalidEntryPointOperation() + { + var entryPointGenerator = Init("Workspace", new string[] { SNIPPETS.InvalidEntryPoint }); + Assert.ThrowsException(() => + entryPointGenerator.Generate("InvalidEntryPoint", null)); + } + } +} diff --git a/src/Tests/AzureClientMagicTests.cs b/src/Tests/AzureClientMagicTests.cs new file mode 100644 index 0000000000..e3ac7f2cf0 --- /dev/null +++ b/src/Tests/AzureClientMagicTests.cs @@ -0,0 +1,307 @@ +// 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; +using System.Threading.Tasks; +using Microsoft.Jupyter.Core; +using Microsoft.Quantum.IQSharp; +using Microsoft.Quantum.IQSharp.AzureClient; +using Microsoft.Extensions.DependencyInjection; +using System; + +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 = Guid.NewGuid().ToString(); + 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 targetId = "TEST_TARGET_ID"; + + [TestMethod] + public void TestConnectMagic() + { + var azureClient = new MockAzureClient(); + var connectMagic = new ConnectMagic(azureClient); + + // no input + connectMagic.Test(string.Empty); + Assert.AreEqual(AzureClientAction.GetConnectionStatus, azureClient.LastAction); + + // unrecognized input + connectMagic.Test($"invalid"); + Assert.AreEqual(AzureClientAction.Connect, azureClient.LastAction); + + // valid input with resource ID + connectMagic.Test($"resourceId=/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Quantum/Workspaces/{workspaceName}"); + Assert.AreEqual(AzureClientAction.Connect, azureClient.LastAction); + Assert.IsFalse(azureClient.RefreshCredentials); + Assert.AreEqual(subscriptionId, azureClient.SubscriptionId); + Assert.AreEqual(resourceGroupName, azureClient.ResourceGroupName); + Assert.AreEqual(workspaceName, azureClient.WorkspaceName); + Assert.AreEqual(string.Empty, azureClient.ConnectionString); + + // valid input with resource ID and storage account connection string + connectMagic.Test( + @$"resourceId=/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Quantum/Workspaces/{workspaceName} + storage={storageAccountConnectionString}"); + Assert.AreEqual(AzureClientAction.Connect, azureClient.LastAction); + Assert.IsFalse(azureClient.RefreshCredentials); + Assert.AreEqual(subscriptionId, azureClient.SubscriptionId); + Assert.AreEqual(resourceGroupName, azureClient.ResourceGroupName); + Assert.AreEqual(workspaceName, azureClient.WorkspaceName); + Assert.AreEqual(storageAccountConnectionString, azureClient.ConnectionString); + + // valid input with individual parameters + connectMagic.Test( + @$"subscription={subscriptionId} + resourceGroup={resourceGroupName} + workspace={workspaceName} + storage={storageAccountConnectionString}"); + Assert.AreEqual(AzureClientAction.Connect, azureClient.LastAction); + Assert.IsFalse(azureClient.RefreshCredentials); + Assert.AreEqual(subscriptionId, azureClient.SubscriptionId); + Assert.AreEqual(resourceGroupName, azureClient.ResourceGroupName); + Assert.AreEqual(workspaceName, azureClient.WorkspaceName); + Assert.AreEqual(storageAccountConnectionString, azureClient.ConnectionString); + + // valid input with extra whitespace and quotes + connectMagic.Test( + @$"subscription = {subscriptionId} + resourceGroup= ""{resourceGroupName}"" + workspace ={workspaceName} + storage = '{storageAccountConnectionString}'"); + Assert.AreEqual(AzureClientAction.Connect, azureClient.LastAction); + Assert.IsFalse(azureClient.RefreshCredentials); + Assert.AreEqual(subscriptionId, azureClient.SubscriptionId); + Assert.AreEqual(resourceGroupName, azureClient.ResourceGroupName); + Assert.AreEqual(workspaceName, azureClient.WorkspaceName); + Assert.AreEqual(storageAccountConnectionString, azureClient.ConnectionString); + + // valid input with forced login + connectMagic.Test( + @$"refresh subscription={subscriptionId} + resourceGroup={resourceGroupName} + workspace={workspaceName} + storage={storageAccountConnectionString}"); + Assert.IsTrue(azureClient.RefreshCredentials); + } + + [TestMethod] + public void TestStatusMagic() + { + // no arguments - should print job status of most recent job + var azureClient = new MockAzureClient(); + var statusMagic = new StatusMagic(azureClient); + statusMagic.Test(string.Empty); + Assert.AreEqual(AzureClientAction.GetJobStatus, azureClient.LastAction); + + // single argument - should print job status + azureClient = new MockAzureClient(); + statusMagic = new StatusMagic(azureClient); + statusMagic.Test($"{jobId}"); + Assert.AreEqual(AzureClientAction.GetJobStatus, azureClient.LastAction); + + // single argument with quotes - should print job status + azureClient = new MockAzureClient(); + statusMagic = new StatusMagic(azureClient); + statusMagic.Test($"\"{jobId}\""); + Assert.AreEqual(AzureClientAction.GetJobStatus, azureClient.LastAction); + } + + [TestMethod] + public void TestSubmitMagic() + { + // no arguments + var azureClient = new MockAzureClient(); + var submitMagic = new SubmitMagic(azureClient); + submitMagic.Test(string.Empty); + Assert.AreEqual(AzureClientAction.SubmitJob, azureClient.LastAction); + + // single argument + submitMagic.Test($"{operationName}"); + Assert.AreEqual(AzureClientAction.SubmitJob, azureClient.LastAction); + Assert.IsTrue(azureClient.SubmittedJobs.Contains(operationName)); + } + + [TestMethod] + public void TestExecuteMagic() + { + // no arguments + var azureClient = new MockAzureClient(); + var executeMagic = new ExecuteMagic(azureClient); + executeMagic.Test(string.Empty); + Assert.AreEqual(AzureClientAction.ExecuteJob, azureClient.LastAction); + + // single argument + executeMagic.Test($"{operationName}"); + Assert.AreEqual(AzureClientAction.ExecuteJob, azureClient.LastAction); + Assert.IsTrue(azureClient.ExecutedJobs.Contains(operationName)); + } + + [TestMethod] + public void TestOutputMagic() + { + // no arguments - should print job result of most recent job + var azureClient = new MockAzureClient(); + var outputMagic = new OutputMagic(azureClient); + outputMagic.Test(string.Empty); + Assert.AreEqual(AzureClientAction.GetJobResult, azureClient.LastAction); + + // single argument - should print job result + azureClient = new MockAzureClient(); + outputMagic = new OutputMagic(azureClient); + outputMagic.Test($"{jobId}"); + Assert.AreEqual(AzureClientAction.GetJobResult, azureClient.LastAction); + + // single argument with quotes - should print job result + azureClient = new MockAzureClient(); + outputMagic = new OutputMagic(azureClient); + outputMagic.Test($"'{jobId}'"); + Assert.AreEqual(AzureClientAction.GetJobResult, azureClient.LastAction); + } + + [TestMethod] + public void TestJobsMagic() + { + // no arguments - should print job status of all jobs + var azureClient = new MockAzureClient(); + var jobsMagic = new JobsMagic(azureClient); + jobsMagic.Test(string.Empty); + Assert.AreEqual(AzureClientAction.GetJobList, azureClient.LastAction); + + // with arguments - should still print job status + azureClient = new MockAzureClient(); + jobsMagic = new JobsMagic(azureClient); + jobsMagic.Test($"{jobId}"); + Assert.AreEqual(AzureClientAction.GetJobList, azureClient.LastAction); + } + + [TestMethod] + public void TestTargetMagic() + { + // single argument - should set active target + var azureClient = new MockAzureClient(); + var targetMagic = new TargetMagic(azureClient); + targetMagic.Test(targetId); + Assert.AreEqual(AzureClientAction.SetActiveTarget, azureClient.LastAction); + + // single argument with quotes - should set active target + targetMagic = new TargetMagic(azureClient); + targetMagic.Test($"\"{targetId}\""); + Assert.AreEqual(AzureClientAction.SetActiveTarget, azureClient.LastAction); + + // no arguments - should print active target + azureClient = new MockAzureClient(); + targetMagic = new TargetMagic(azureClient); + targetMagic.Test(string.Empty); + Assert.AreEqual(AzureClientAction.GetActiveTarget, azureClient.LastAction); + } + } + + internal enum AzureClientAction + { + None, + Connect, + SetActiveTarget, + GetActiveTarget, + SubmitJob, + ExecuteJob, + GetConnectionStatus, + GetJobList, + GetJobStatus, + GetJobResult, + } + + public class MockAzureClient : IAzureClient + { + internal AzureClientAction LastAction = AzureClientAction.None; + internal string SubscriptionId = string.Empty; + internal string ResourceGroupName = string.Empty; + internal string WorkspaceName = string.Empty; + internal string ConnectionString = string.Empty; + internal bool RefreshCredentials = false; + internal string ActiveTargetId = string.Empty; + internal List SubmittedJobs = new List(); + internal List ExecutedJobs = new List(); + + public async Task SetActiveTargetAsync(IChannel channel, string targetId) + { + LastAction = AzureClientAction.SetActiveTarget; + ActiveTargetId = targetId; + return ExecuteStatus.Ok.ToExecutionResult(); + } + public async Task GetActiveTargetAsync(IChannel channel) + { + LastAction = AzureClientAction.GetActiveTarget; + return ActiveTargetId.ToExecutionResult(); + } + + public async Task SubmitJobAsync(IChannel channel, AzureSubmissionContext submissionContext, CancellationToken? token) + { + LastAction = AzureClientAction.SubmitJob; + SubmittedJobs.Add(submissionContext.OperationName); + return ExecuteStatus.Ok.ToExecutionResult(); + } + + public async Task ExecuteJobAsync(IChannel channel, AzureSubmissionContext submissionContext, CancellationToken? token) + { + LastAction = AzureClientAction.ExecuteJob; + ExecutedJobs.Add(submissionContext.OperationName); + return ExecuteStatus.Ok.ToExecutionResult(); + } + + public async Task ConnectAsync(IChannel channel, string subscriptionId, string resourceGroupName, string workspaceName, string storageAccountConnectionString, bool refreshCredentials) + { + LastAction = AzureClientAction.Connect; + SubscriptionId = subscriptionId; + ResourceGroupName = resourceGroupName; + WorkspaceName = workspaceName; + ConnectionString = storageAccountConnectionString; + RefreshCredentials = refreshCredentials; + return ExecuteStatus.Ok.ToExecutionResult(); + } + + public async Task GetConnectionStatusAsync(IChannel channel) + { + LastAction = AzureClientAction.GetConnectionStatus; + return ExecuteStatus.Ok.ToExecutionResult(); + } + + public async Task GetJobListAsync(IChannel channel, string filter) + { + LastAction = AzureClientAction.GetJobList; + return ExecuteStatus.Ok.ToExecutionResult(); + } + + public async Task GetJobStatusAsync(IChannel channel, string jobId) + { + LastAction = AzureClientAction.GetJobStatus; + return ExecuteStatus.Ok.ToExecutionResult(); + } + + public async Task GetJobResultAsync(IChannel channel, string jobId) + { + LastAction = AzureClientAction.GetJobResult; + return ExecuteStatus.Ok.ToExecutionResult(); + } + } +} diff --git a/src/Tests/AzureClientTests.cs b/src/Tests/AzureClientTests.cs new file mode 100644 index 0000000000..998e01ba62 --- /dev/null +++ b/src/Tests/AzureClientTests.cs @@ -0,0 +1,273 @@ +// 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; +using System.Threading.Tasks; +using Microsoft.Azure.Quantum; +using Microsoft.Azure.Quantum.Client.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Jupyter.Core; +using Microsoft.Quantum.IQSharp.AzureClient; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Tests.IQSharp +{ + [TestClass] + public class AzureClientTests + { + private string originalEnvironmentName = string.Empty; + + [TestInitialize] + public void SetMockEnvironment() + { + originalEnvironmentName = Environment.GetEnvironmentVariable(AzureEnvironment.EnvironmentVariableName) ?? string.Empty; + Environment.SetEnvironmentVariable(AzureEnvironment.EnvironmentVariableName, AzureEnvironmentType.Mock.ToString()); + } + + [TestCleanup] + public void RestoreEnvironment() + { + Environment.SetEnvironmentVariable(AzureEnvironment.EnvironmentVariableName, originalEnvironmentName); + } + + private T ExpectSuccess(Task task) + { + var result = task.GetAwaiter().GetResult(); + Assert.AreEqual(ExecuteStatus.Ok, result.Status); + Assert.IsInstanceOfType(result.Output, typeof(T)); + return (T)result.Output; + } + + private void ExpectError(AzureClientError expectedError, Task task) + { + var result = task.GetAwaiter().GetResult(); + Assert.AreEqual(ExecuteStatus.Error, result.Status); + Assert.IsInstanceOfType(result.Output, typeof(AzureClientError)); + Assert.AreEqual(expectedError, (AzureClientError)result.Output); + } + + private Task ConnectToWorkspaceAsync(IAzureClient azureClient, string workspaceName = "TEST_WORKSPACE_NAME") + { + return azureClient.ConnectAsync( + new MockChannel(), + "TEST_SUBSCRIPTION_ID", + "TEST_RESOURCE_GROUP_NAME", + workspaceName, + "TEST_CONNECTION_STRING"); + } + + [TestMethod] + public void TestAzureEnvironment() + { + // Production environment + Environment.SetEnvironmentVariable(AzureEnvironment.EnvironmentVariableName, AzureEnvironmentType.Production.ToString()); + var environment = AzureEnvironment.Create("TEST_SUBSCRIPTION_ID"); + Assert.AreEqual(AzureEnvironmentType.Production, environment.Type); + + // Dogfood environment cannot be created in test because it requires a service call + Environment.SetEnvironmentVariable(AzureEnvironment.EnvironmentVariableName, AzureEnvironmentType.Dogfood.ToString()); + Assert.ThrowsException(() => AzureEnvironment.Create("TEST_SUBSCRIPTION_ID")); + + // Canary environment + Environment.SetEnvironmentVariable(AzureEnvironment.EnvironmentVariableName, AzureEnvironmentType.Canary.ToString()); + environment = AzureEnvironment.Create("TEST_SUBSCRIPTION_ID"); + Assert.AreEqual(AzureEnvironmentType.Canary, environment.Type); + + // Mock environment + Environment.SetEnvironmentVariable(AzureEnvironment.EnvironmentVariableName, AzureEnvironmentType.Mock.ToString()); + environment = AzureEnvironment.Create("TEST_SUBSCRIPTION_ID"); + Assert.AreEqual(AzureEnvironmentType.Mock, environment.Type); + } + + [TestMethod] + public void TestAzureExecutionTarget() + { + var targetId = "invalidname"; + var executionTarget = AzureExecutionTarget.Create(targetId); + Assert.IsNull(executionTarget); + + targetId = "ionq.targetId"; + executionTarget = AzureExecutionTarget.Create(targetId); + Assert.AreEqual(targetId, executionTarget?.TargetId); + Assert.AreEqual("Microsoft.Quantum.Providers.IonQ", executionTarget?.PackageName); + + targetId = "HonEYWEll.targetId"; + executionTarget = AzureExecutionTarget.Create(targetId); + Assert.AreEqual(targetId, executionTarget?.TargetId); + Assert.AreEqual("Microsoft.Quantum.Providers.Honeywell", executionTarget?.PackageName); + + targetId = "qci.target.name.qpu"; + executionTarget = AzureExecutionTarget.Create(targetId); + Assert.AreEqual(targetId, executionTarget?.TargetId); + Assert.AreEqual("Microsoft.Quantum.Providers.QCI", executionTarget?.PackageName); + } + + [TestMethod] + public void TestJobStatus() + { + var services = Startup.CreateServiceProvider("Workspace"); + var azureClient = (AzureClient)services.GetService(); + + // not connected + ExpectError(AzureClientError.NotConnected, azureClient.GetJobStatusAsync(new MockChannel(), "JOB_ID_1")); + + // connect + var targets = ExpectSuccess>(ConnectToWorkspaceAsync(azureClient)); + Assert.IsFalse(targets.Any()); + + // set up the mock workspace + var azureWorkspace = azureClient.ActiveWorkspace as MockAzureWorkspace; + Assert.IsNotNull(azureWorkspace); + azureWorkspace?.AddMockJobs("JOB_ID_1", "JOB_ID_2"); + + // valid job ID + var job = ExpectSuccess(azureClient.GetJobStatusAsync(new MockChannel(), "JOB_ID_1")); + Assert.AreEqual("JOB_ID_1", job.Id); + + // invalid job ID + ExpectError(AzureClientError.JobNotFound, azureClient.GetJobStatusAsync(new MockChannel(), "JOB_ID_3")); + + // jobs list with no filter + var jobs = ExpectSuccess>(azureClient.GetJobListAsync(new MockChannel(), string.Empty)); + Assert.AreEqual(2, jobs.Count()); + + // jobs list with filter + jobs = ExpectSuccess>(azureClient.GetJobListAsync(new MockChannel(), "JOB_ID_1")); + Assert.AreEqual(1, jobs.Count()); + } + + [TestMethod] + public void TestManualTargets() + { + var services = Startup.CreateServiceProvider("Workspace"); + var azureClient = (AzureClient)services.GetService(); + + // SetActiveTargetAsync with recognized target ID, but not yet connected + ExpectError(AzureClientError.NotConnected, azureClient.SetActiveTargetAsync(new MockChannel(), "ionq.simulator")); + + // GetActiveTargetAsync, but not yet connected + ExpectError(AzureClientError.NotConnected, azureClient.GetActiveTargetAsync(new MockChannel())); + + // connect + var targets = ExpectSuccess>(ConnectToWorkspaceAsync(azureClient)); + Assert.IsFalse(targets.Any()); + + // set up the mock workspace + var azureWorkspace = azureClient.ActiveWorkspace as MockAzureWorkspace; + Assert.IsNotNull(azureWorkspace); + azureWorkspace?.AddMockTargets("ionq.simulator", "honeywell.qpu", "unrecognized.target"); + + // get connection status to verify list of targets + targets = ExpectSuccess>(azureClient.GetConnectionStatusAsync(new MockChannel())); + Assert.AreEqual(2, targets.Count()); // only 2 valid quantum execution targets + + // GetActiveTargetAsync, but no active target set yet + ExpectError(AzureClientError.NoTarget, azureClient.GetActiveTargetAsync(new MockChannel())); + + // SetActiveTargetAsync with target ID not valid for quantum execution + ExpectError(AzureClientError.InvalidTarget, azureClient.SetActiveTargetAsync(new MockChannel(), "unrecognized.target")); + + // SetActiveTargetAsync with valid target ID + var target = ExpectSuccess(azureClient.SetActiveTargetAsync(new MockChannel(), "ionq.simulator")); + Assert.AreEqual("ionq.simulator", target.Id); + + // GetActiveTargetAsync + target = ExpectSuccess(azureClient.GetActiveTargetAsync(new MockChannel())); + Assert.AreEqual("ionq.simulator", target.Id); + } + + [TestMethod] + public void TestAllTargets() + { + var services = Startup.CreateServiceProvider("Workspace"); + var azureClient = (AzureClient)services.GetService(); + + // connect to mock workspace with all providers + var targets = ExpectSuccess>(ConnectToWorkspaceAsync(azureClient, MockAzureWorkspace.NameWithMockProviders)); + Assert.AreEqual(Enum.GetNames(typeof(AzureProvider)).Length, targets.Count()); + + // set each target, which will load the corresponding package + foreach (var target in targets) + { + var returnedTarget = ExpectSuccess(azureClient.SetActiveTargetAsync(new MockChannel(), target.Id)); + Assert.AreEqual(target.Id, returnedTarget.Id); + } + } + + [TestMethod] + public void TestJobSubmission() + { + var services = Startup.CreateServiceProvider("Workspace"); + var azureClient = (AzureClient)services.GetService(); + var submissionContext = new AzureSubmissionContext(); + + // not yet connected + ExpectError(AzureClientError.NotConnected, azureClient.SubmitJobAsync(new MockChannel(), submissionContext, CancellationToken.None)); + + // connect + var targets = ExpectSuccess>(ConnectToWorkspaceAsync(azureClient)); + Assert.IsFalse(targets.Any()); + + // no target yet + ExpectError(AzureClientError.NoTarget, azureClient.SubmitJobAsync(new MockChannel(), submissionContext, CancellationToken.None)); + + // add a target + var azureWorkspace = azureClient.ActiveWorkspace as MockAzureWorkspace; + Assert.IsNotNull(azureWorkspace); + azureWorkspace?.AddMockTargets("ionq.simulator"); + + // set the active target + var target = ExpectSuccess(azureClient.SetActiveTargetAsync(new MockChannel(), "ionq.simulator")); + Assert.AreEqual("ionq.simulator", target.Id); + + // no operation name specified + ExpectError(AzureClientError.NoOperationName, azureClient.SubmitJobAsync(new MockChannel(), submissionContext, CancellationToken.None)); + + // specify an operation name, but have missing parameters + submissionContext.OperationName = "Tests.qss.HelloAgain"; + ExpectError(AzureClientError.JobSubmissionFailed, azureClient.SubmitJobAsync(new MockChannel(), submissionContext, CancellationToken.None)); + + // specify input parameters and verify that the job was submitted + submissionContext.InputParameters = new Dictionary() { ["count"] = "3", ["name"] = "testing" }; + var job = ExpectSuccess(azureClient.SubmitJobAsync(new MockChannel(), submissionContext, CancellationToken.None)); + var retrievedJob = ExpectSuccess(azureClient.GetJobStatusAsync(new MockChannel(), job.Id)); + Assert.AreEqual(job.Id, retrievedJob.Id); + } + + [TestMethod] + public void TestJobExecution() + { + var services = Startup.CreateServiceProvider("Workspace"); + var azureClient = (AzureClient)services.GetService(); + + // connect + var targets = ExpectSuccess>(ConnectToWorkspaceAsync(azureClient)); + Assert.IsFalse(targets.Any()); + + // add a target + var azureWorkspace = azureClient.ActiveWorkspace as MockAzureWorkspace; + Assert.IsNotNull(azureWorkspace); + azureWorkspace?.AddMockTargets("ionq.simulator"); + + // set the active target + var target = ExpectSuccess(azureClient.SetActiveTargetAsync(new MockChannel(), "ionq.simulator")); + Assert.AreEqual("ionq.simulator", target.Id); + + // execute the job and verify that the results are retrieved successfully + var submissionContext = new AzureSubmissionContext() + { + OperationName = "Tests.qss.HelloAgain", + InputParameters = new Dictionary() { ["count"] = "3", ["name"] = "testing" }, + ExecutionTimeout = 5, + ExecutionPollingInterval = 1, + }; + var histogram = ExpectSuccess(azureClient.ExecuteJobAsync(new MockChannel(), submissionContext, CancellationToken.None)); + Assert.IsNotNull(histogram); + } + } +} diff --git a/src/Tests/HttpServerIntegrationTests.cs b/src/Tests/HttpServerIntegrationTests.cs index 61eddced03..10534f5a4f 100644 --- a/src/Tests/HttpServerIntegrationTests.cs +++ b/src/Tests/HttpServerIntegrationTests.cs @@ -9,9 +9,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Tests.IQSharp; -namespace Tests.IQsharp +namespace Tests.IQSharp { [TestClass] public class HttpServerIntegrationTests diff --git a/src/Tests/IQsharpEngineTests.cs b/src/Tests/IQsharpEngineTests.cs index bd9d9b0f64..2d800b3bb9 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,15 @@ public void TestResolveMagic() symbol = resolver.Resolve("%foo"); Assert.IsNull(symbol); + + // AzureClient-provided commands + Assert.IsNotNull(resolver.Resolve("%azure.connect")); + Assert.IsNotNull(resolver.Resolve("%azure.target")); + Assert.IsNotNull(resolver.Resolve("%azure.submit")); + Assert.IsNotNull(resolver.Resolve("%azure.execute")); + Assert.IsNotNull(resolver.Resolve("%azure.status")); + Assert.IsNotNull(resolver.Resolve("%azure.output")); + Assert.IsNotNull(resolver.Resolve("%azure.jobs")); } } } 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/SNIPPETS.cs b/src/Tests/SNIPPETS.cs index 8aff3f3214..ef29cf6628 100644 --- a/src/Tests/SNIPPETS.cs +++ b/src/Tests/SNIPPETS.cs @@ -207,6 +207,15 @@ operation InvalidFunctor(q: Qubit) : Unit { } "; + public static string InvalidEntryPoint = +@" + /// # Summary + /// This script has an operation that is not valid to be marked as an entry point. + operation InvalidEntryPoint(q : Qubit) : Unit { + H(q); + } +"; + public static string Reverse = @" /// # Summary 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/Startup.cs b/src/Tool/Startup.cs index 5d5390b0ff..87c3831317 100644 --- a/src/Tool/Startup.cs +++ b/src/Tool/Startup.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Quantum.IQSharp.Kernel; +using Microsoft.Quantum.IQSharp.AzureClient; using System; namespace Microsoft.Quantum.IQSharp @@ -24,6 +25,7 @@ public virtual void ConfigureServices(IServiceCollection services) services.AddSingleton(typeof(ITelemetryService), GetTelemetryServiceType()); services.AddIQSharp(); services.AddIQSharpKernel(); + services.AddAzureClient(); var assembly = typeof(PackagesController).Assembly; services.AddControllers() diff --git a/src/Tool/Tool.csproj b/src/Tool/Tool.csproj index 4297acd592..ce02b98403 100644 --- a/src/Tool/Tool.csproj +++ b/src/Tool/Tool.csproj @@ -36,6 +36,8 @@ + + diff --git a/src/Tool/appsettings.json b/src/Tool/appsettings.json index 7eeb65f8a6..d5cbc6ab1e 100644 --- a/src/Tool/appsettings.json +++ b/src/Tool/appsettings.json @@ -6,21 +6,25 @@ }, "AllowedHosts": "*", "DefaultPackageVersions": [ - "Microsoft.Quantum.Compiler::0.11.2006.403", + "Microsoft.Quantum.Compiler::0.11.2006.1615-beta", - "Microsoft.Quantum.CsharpGeneration::0.11.2006.403", - "Microsoft.Quantum.Development.Kit::0.11.2006.403", - "Microsoft.Quantum.Simulators::0.11.2006.403", - "Microsoft.Quantum.Xunit::0.11.2006.403", + "Microsoft.Quantum.CsharpGeneration::0.11.2006.1615-beta", + "Microsoft.Quantum.Development.Kit::0.11.2006.1615-beta", + "Microsoft.Quantum.Simulators::0.11.2006.1615-beta", + "Microsoft.Quantum.Xunit::0.11.2006.1615-beta", - "Microsoft.Quantum.Standard::0.11.2006.403", - "Microsoft.Quantum.Chemistry::0.11.2006.403", - "Microsoft.Quantum.Chemistry.Jupyter::0.11.2006.403", - "Microsoft.Quantum.MachineLearning::0.11.2006.403", - "Microsoft.Quantum.Numerics::0.11.2006.403", + "Microsoft.Quantum.Standard::0.11.2006.1615-beta", + "Microsoft.Quantum.Chemistry::0.11.2006.1615-beta", + "Microsoft.Quantum.Chemistry.Jupyter::0.11.2006.1615-beta", + "Microsoft.Quantum.MachineLearning::0.11.2006.1615-beta", + "Microsoft.Quantum.Numerics::0.11.2006.1615-beta", - "Microsoft.Quantum.Katas::0.11.2006.403", + "Microsoft.Quantum.Katas::0.11.2006.1615-beta", - "Microsoft.Quantum.Research::0.11.2006.403" + "Microsoft.Quantum.Research::0.11.2006.1615-beta", + + "Microsoft.Quantum.Providers.IonQ::0.11.2006.1615-beta", + "Microsoft.Quantum.Providers.Honeywell::0.11.2006.1615-beta", + "Microsoft.Quantum.Providers.QCI::0.11.2006.1615-beta", ] }