From 2ba55215ab9dc102abb2af28034235a02e590169 Mon Sep 17 00:00:00 2001 From: "Stefan J. Wernli" Date: Fri, 6 Dec 2024 12:18:51 -0800 Subject: [PATCH] Expose user-defined callables into Python This change adds new interop capabilities for the qsharp Python module. Any user-defined global callables with types supported by interop will automatically be surfaced into the `qsharp.env` module and available for invocation or import directly in Python. --- compiler/qsc/src/interpret.rs | 95 ++++++++- compiler/qsc/src/interpret/tests.rs | 215 ++++++++++++++++++++ compiler/qsc/src/lib.rs | 3 +- compiler/qsc_eval/src/lib.rs | 45 +++++ compiler/qsc_frontend/src/error.rs | 17 +- compiler/qsc_frontend/src/error/tests.rs | 4 +- compiler/qsc_hir/src/global.rs | 11 ++ language_service/src/state.rs | 4 +- pip/qsharp/__init__.py | 5 +- pip/qsharp/_native.pyi | 27 +++ pip/qsharp/_qsharp.py | 52 ++++- pip/qsharp/env/__init__.py | 2 + pip/src/interpreter.rs | 241 ++++++++++++++++++++++- pip/tests/test_qsharp.py | 76 +++++++ wasm/src/diagnostic.rs | 4 +- 15 files changed, 778 insertions(+), 23 deletions(-) create mode 100644 pip/qsharp/env/__init__.py diff --git a/compiler/qsc/src/interpret.rs b/compiler/qsc/src/interpret.rs index e1560bac14..fd19fdb462 100644 --- a/compiler/qsc/src/interpret.rs +++ b/compiler/qsc/src/interpret.rs @@ -11,6 +11,8 @@ mod package_tests; #[cfg(test)] mod tests; +use std::rc::Rc; + pub use qsc_eval::{ debug::Frame, noise::PauliNoise, @@ -21,6 +23,7 @@ pub use qsc_eval::{ val::Value, StepAction, StepResult, }; +use qsc_hir::{global, ty}; use qsc_linter::{HirLint, Lint, LintKind, LintLevel}; use qsc_lowerer::{map_fir_package_to_hir, map_hir_package_to_fir}; use qsc_partial_eval::ProgramEntry; @@ -315,6 +318,70 @@ impl Interpreter { }) } + /// Given a package ID, returns all the global items in the package. + /// Note this does not currently include re-exports. + pub fn get_global_items( + &self, + package_id: PackageId, + ) -> Vec<(Vec>, Rc, fir::StoreItemId)> { + let mut exported_items = Vec::new(); + let package = &self + .compiler + .package_store() + .get(map_fir_package_to_hir(package_id)) + .expect("package should exist in the package store") + .package; + for global in global::iter_package(Some(map_fir_package_to_hir(package_id)), package) { + if let global::Kind::Term(term) = global.kind { + let store_item_id = fir::StoreItemId { + package: package_id, + item: fir::LocalItemId::from(usize::from(term.id.item)), + }; + exported_items.push((global.namespace, global.name, store_item_id)); + } + } + exported_items + } + + /// Get the global items defined in the user source passed into initialization of the interpreter. + pub fn get_source_package_global_items( + &self, + ) -> Vec<(Vec>, Rc, fir::StoreItemId)> { + self.get_global_items(self.source_package) + } + + /// Get the global items defined in the open package being interpreted, which will include any items + /// defined by calls to `eval_fragments` and the like. + pub fn get_open_package_global_items(&self) -> Vec<(Vec>, Rc, fir::StoreItemId)> { + self.get_global_items(self.package) + } + + /// Get the input and output types of a given callable item. + /// # Panics + /// Panics if the item is not callable or a type that can be invoked as a callable. + pub fn get_callable_tys(&self, item_id: fir::StoreItemId) -> Option<(ty::Ty, ty::Ty)> { + let package_id = map_fir_package_to_hir(item_id.package); + let unit = self + .compiler + .package_store() + .get(package_id) + .expect("package should exist in the package store"); + let item = unit + .package + .items + .get(qsc_hir::hir::LocalItemId::from(usize::from(item_id.item)))?; + match &item.kind { + qsc_hir::hir::ItemKind::Callable(decl) => { + Some((decl.input.ty.clone(), decl.output.clone())) + } + qsc_hir::hir::ItemKind::Ty(_, udt) => { + // We don't handle UDTs, so we return an error type that prevents later code from processing this item. + Some((udt.get_pure_ty(), ty::Ty::Err)) + } + _ => panic!("item is not callable"), + } + } + pub fn set_quantum_seed(&mut self, seed: Option) { self.quantum_seed = seed; self.sim.set_seed(seed); @@ -482,6 +549,32 @@ impl Interpreter { self.run_with_sim(&mut sim, receiver, expr) } + /// Invokes the given callable with the given arguments using the current environment, simlator, and compilation. + pub fn invoke( + &mut self, + receiver: &mut impl Receiver, + callable: Value, + args: Value, + ) -> InterpretResult { + qsc_eval::invoke( + self.package, + self.classical_seed, + &self.fir_store, + &mut self.env, + &mut self.sim, + receiver, + callable, + args, + ) + .map_err(|(error, call_stack)| { + eval_error( + self.compiler.package_store(), + &self.fir_store, + call_stack, + error, + ) + }) + } /// Gets the current quantum state of the simulator. pub fn get_quantum_state(&mut self) -> (Vec<(BigUint, Complex)>, usize) { self.sim.capture_quantum_state() @@ -1033,7 +1126,7 @@ impl<'a> BreakpointCollector<'a> { .expect("Couldn't find source file") } - fn add_stmt(&mut self, stmt: &qsc_fir::fir::Stmt) { + fn add_stmt(&mut self, stmt: &fir::Stmt) { let source: &Source = self.get_source(stmt.span.lo); if source.offset == self.offset { let span = stmt.span - source.offset; diff --git a/compiler/qsc/src/interpret/tests.rs b/compiler/qsc/src/interpret/tests.rs index ce37254484..bdd3ee184f 100644 --- a/compiler/qsc/src/interpret/tests.rs +++ b/compiler/qsc/src/interpret/tests.rs @@ -46,6 +46,21 @@ mod given_interpreter { (result, receiver.dump()) } + fn invoke( + interpreter: &mut Interpreter, + callable: &str, + args: Value, + ) -> (InterpretResult, String) { + let mut cursor = Cursor::new(Vec::::new()); + let mut receiver = CursorReceiver::new(&mut cursor); + let callable = match interpreter.eval_fragments(&mut receiver, callable) { + Ok(val) => val, + Err(e) => return (Err(e), receiver.dump()), + }; + let result = interpreter.invoke(&mut receiver, callable, args); + (result, receiver.dump()) + } + mod without_sources { use expect_test::expect; use indoc::indoc; @@ -514,6 +529,166 @@ mod given_interpreter { is_only_value(&result, &output, &Value::unit()); } + #[test] + fn interpreter_without_sources_has_no_items() { + let interpreter = get_interpreter(); + let items = interpreter.get_source_package_global_items(); + assert!(items.is_empty()); + } + + #[test] + fn fragment_without_items_has_no_items() { + let mut interpreter = get_interpreter(); + let (result, output) = line(&mut interpreter, "()"); + is_only_value(&result, &output, &Value::unit()); + let items = interpreter.get_open_package_global_items(); + assert!(items.is_empty()); + } + + #[test] + fn fragment_defining_items_has_items() { + let mut interpreter = get_interpreter(); + let (result, output) = line( + &mut interpreter, + indoc! {r#" + function Foo() : Int { 2 } + function Bar() : Int { 3 } + "#}, + ); + is_only_value(&result, &output, &Value::unit()); + let items = interpreter.get_open_package_global_items(); + assert_eq!(items.len(), 2); + // No namespace for top-level items + assert!(items[0].0.is_empty()); + expect![[r#" + "Foo" + "#]] + .assert_debug_eq(&items[0].1); + // No namespace for top-level items + assert!(items[1].0.is_empty()); + expect![[r#" + "Bar" + "#]] + .assert_debug_eq(&items[1].1); + } + + #[test] + fn fragment_defining_items_with_namespace_has_items() { + let mut interpreter = get_interpreter(); + let (result, output) = line( + &mut interpreter, + indoc! {r#" + namespace Foo { + function Bar() : Int { 3 } + } + "#}, + ); + is_only_value(&result, &output, &Value::unit()); + let items = interpreter.get_open_package_global_items(); + assert_eq!(items.len(), 1); + expect![[r#" + [ + "Foo", + ] + "#]] + .assert_debug_eq(&items[0].0); + expect![[r#" + "Bar" + "#]] + .assert_debug_eq(&items[0].1); + } + + #[test] + fn fragments_defining_items_add_to_existing_items() { + let mut interpreter = get_interpreter(); + let (result, output) = line( + &mut interpreter, + indoc! {r#" + function Foo() : Int { 2 } + function Bar() : Int { 3 } + "#}, + ); + is_only_value(&result, &output, &Value::unit()); + let items = interpreter.get_open_package_global_items(); + assert_eq!(items.len(), 2); + let (result, output) = line( + &mut interpreter, + indoc! {r#" + function Baz() : Int { 4 } + function Qux() : Int { 5 } + "#}, + ); + is_only_value(&result, &output, &Value::unit()); + let items = interpreter.get_open_package_global_items(); + assert_eq!(items.len(), 4); + // No namespace for top-level items + assert!(items[0].0.is_empty()); + expect![[r#" + "Foo" + "#]] + .assert_debug_eq(&items[0].1); + // No namespace for top-level items + assert!(items[1].0.is_empty()); + expect![[r#" + "Bar" + "#]] + .assert_debug_eq(&items[1].1); + // No namespace for top-level items + assert!(items[2].0.is_empty()); + expect![[r#" + "Baz" + "#]] + .assert_debug_eq(&items[2].1); + // No namespace for top-level items + assert!(items[3].0.is_empty()); + expect![[r#" + "Qux" + "#]] + .assert_debug_eq(&items[3].1); + } + + #[test] + fn invoke_callable_without_args_succeeds() { + let mut interpreter = get_interpreter(); + let (result, output) = invoke( + &mut interpreter, + "Std.Diagnostics.DumpMachine", + Value::unit(), + ); + is_unit_with_output(&result, &output, "STATE:\nNo qubits allocated"); + } + + #[test] + fn invoke_callable_with_args_succeeds() { + let mut interpreter = get_interpreter(); + let (result, output) = invoke( + &mut interpreter, + "Message", + Value::String("Hello, World!".into()), + ); + is_unit_with_output(&result, &output, "Hello, World!"); + } + + #[test] + fn invoke_lambda_with_capture_succeeds() { + let mut interpreter = get_interpreter(); + let (result, output) = line(&mut interpreter, "let x = 1; let f = y -> x + y;"); + is_only_value(&result, &output, &Value::unit()); + let (result, output) = invoke(&mut interpreter, "f", Value::Int(2)); + is_only_value(&result, &output, &Value::Int(3)); + } + + #[test] + fn invoke_lambda_with_capture_in_callable_expr_succeeds() { + let mut interpreter = get_interpreter(); + let (result, output) = invoke( + &mut interpreter, + "{let x = 1; let f = y -> x + y; f}", + Value::Int(2), + ); + is_only_value(&result, &output, &Value::Int(3)); + } + #[test] fn callables_failing_profile_validation_are_not_registered() { let mut interpreter = @@ -1698,6 +1873,46 @@ mod given_interpreter { ); } + #[test] + fn interpreter_returns_items_from_source() { + let sources = SourceMap::new( + [( + "test".into(), + "namespace A { + operation B(): Unit { } + } + " + .into(), + )], + Some("A.B()".into()), + ); + + let (std_id, store) = + crate::compile::package_store_with_stdlib(TargetCapabilityFlags::all()); + let interpreter = Interpreter::new( + sources, + PackageType::Lib, + TargetCapabilityFlags::all(), + LanguageFeatures::default(), + store, + &[(std_id, None)], + ) + .expect("interpreter should be created"); + + let items = interpreter.get_source_package_global_items(); + assert_eq!(1, items.len()); + expect![[r#" + [ + "A", + ] + "#]] + .assert_debug_eq(&items[0].0); + expect![[r#" + "B" + "#]] + .assert_debug_eq(&items[0].1); + } + #[test] fn interpreter_can_be_created_from_ast() { let sources = SourceMap::new( diff --git a/compiler/qsc/src/lib.rs b/compiler/qsc/src/lib.rs index dc957bfc8e..5847ab4449 100644 --- a/compiler/qsc/src/lib.rs +++ b/compiler/qsc/src/lib.rs @@ -38,7 +38,8 @@ pub mod project { } pub use qsc_data_structures::{ - language_features::LanguageFeatures, namespaces::*, span::Span, target::TargetCapabilityFlags, + functors::FunctorApp, language_features::LanguageFeatures, namespaces::*, span::Span, + target::TargetCapabilityFlags, }; pub use qsc_passes::{lower_hir_to_fir, PackageType, PassContext}; diff --git a/compiler/qsc_eval/src/lib.rs b/compiler/qsc_eval/src/lib.rs index 8655e05685..e3daac3b37 100644 --- a/compiler/qsc_eval/src/lib.rs +++ b/compiler/qsc_eval/src/lib.rs @@ -282,6 +282,51 @@ pub fn eval( Ok(value) } +/// Evaluates the given callable with the given context. +/// # Errors +/// Returns the first error encountered during execution. +/// # Panics +/// On internal error where no result is returned. +#[allow(clippy::too_many_arguments)] +pub fn invoke( + package: PackageId, + seed: Option, + globals: &impl PackageStoreLookup, + env: &mut Env, + sim: &mut impl Backend>, + receiver: &mut impl Receiver, + callable: Value, + args: Value, +) -> Result)> { + let mut state = State::new(package, Vec::new().into(), seed); + // Push the callable value into the state stack and then the args value so they are ready for evaluation. + state.set_val_register(callable); + state.push_val(); + state.set_val_register(args); + + // Evaluate the call, which will pop the args and callable values from the stack and then either + // a) prepare the call stack for the execution of the callable, or + // b) invoke the callable directly if it is an intrinsic. + state + .eval_call( + env, + sim, + globals, + Span::default(), + Span::default(), + receiver, + ) + .map_err(|e| (e, state.get_stack_frames()))?; + + // Trigger evaluation of the state until the end of the stack is reached and a return value is obtained, which will be the final + // result of the invocation. + let res = state.eval(globals, env, sim, receiver, &[], StepAction::Continue)?; + let StepResult::Return(value) = res else { + panic!("eval should always return a value"); + }; + Ok(value) +} + /// The type of step action to take during evaluation #[derive(Debug, Copy, Clone, Eq, PartialEq)] pub enum StepAction { diff --git a/compiler/qsc_frontend/src/error.rs b/compiler/qsc_frontend/src/error.rs index 81e54a28a0..3da222b51c 100644 --- a/compiler/qsc_frontend/src/error.rs +++ b/compiler/qsc_frontend/src/error.rs @@ -40,9 +40,11 @@ impl WithSource { .flatten() .map(|label| u32::try_from(label.offset()).expect("offset should fit into u32")) { - let source = sources - .find_by_offset(offset) - .expect("expected to find source at offset"); + let Some(source) = sources.find_by_offset(offset) else { + // If the source is not found, skip it. This can happen when the originating line for this particular label + // comes from Python code, which is not included in the source map. + continue; + }; // Keep the vector sorted by source offsets match filtered.binary_search_by_key(&source.offset, |s| s.offset) { @@ -70,15 +72,16 @@ impl WithSource { /// Takes a span that uses `SourceMap` offsets, and returns /// a span that is relative to the `Source` that the span falls into, /// along with a reference to the `Source`. - pub fn resolve_span(&self, span: &SourceSpan) -> (&Source, SourceSpan) { + pub fn resolve_span(&self, span: &SourceSpan) -> Result<(&Source, SourceSpan), MietteError> { let offset = u32::try_from(span.offset()).expect("expected the offset to fit into u32"); + // Since some sources may be missing, treat any failure to find the source as "out of bounds" error. let source = self .sources .iter() .rev() .find(|source| offset >= source.offset) - .expect("expected to find source at span"); - (source, with_offset(span, |o| o - (source.offset as usize))) + .ok_or(MietteError::OutOfBounds)?; + Ok((source, with_offset(span, |o| o - (source.offset as usize)))) } } @@ -135,7 +138,7 @@ impl SourceCode for WithSource { context_lines_before: usize, context_lines_after: usize, ) -> Result + 'a>, MietteError> { - let (source, source_relative_span) = self.resolve_span(span); + let (source, source_relative_span) = self.resolve_span(span)?; let contents = source.contents.read_span( &source_relative_span, diff --git a/compiler/qsc_frontend/src/error/tests.rs b/compiler/qsc_frontend/src/error/tests.rs index c643e68459..aa009e310a 100644 --- a/compiler/qsc_frontend/src/error/tests.rs +++ b/compiler/qsc_frontend/src/error/tests.rs @@ -136,7 +136,9 @@ fn resolve_spans() { .labels() .expect("expected labels to exist") .map(|l| { - let resolved = with_source.resolve_span(l.inner()); + let resolved = with_source + .resolve_span(l.inner()) + .expect("expected to resolve span"); ( resolved.0.name.to_string(), resolved.1.offset(), diff --git a/compiler/qsc_hir/src/global.rs b/compiler/qsc_hir/src/global.rs index 754daf4846..d4afe8fecc 100644 --- a/compiler/qsc_hir/src/global.rs +++ b/compiler/qsc_hir/src/global.rs @@ -157,6 +157,17 @@ impl PackageIter<'_> { intrinsic: decl.body.body == SpecBody::Gen(SpecGen::Intrinsic), }), }), + (ItemKind::Callable(decl), None) => Some(Global { + namespace: Vec::new(), + name: alias.map_or_else(|| Rc::clone(&decl.name.name), |alias| alias.name.clone()), + visibility, + status, + kind: Kind::Term(Term { + id, + scheme: decl.scheme(), + intrinsic: decl.body.body == SpecBody::Gen(SpecGen::Intrinsic), + }), + }), (ItemKind::Ty(name, def), Some(ItemKind::Namespace(namespace, _))) => { self.next = Some(Global { namespace: namespace.into(), diff --git a/language_service/src/state.rs b/language_service/src/state.rs index 3eaed8c13c..9e495d5d70 100644 --- a/language_service/src/state.rs +++ b/language_service/src/state.rs @@ -566,8 +566,8 @@ fn map_errors_to_docs( .flatten() .next() .map_or(compilation_uri, |l| { - let (source, _) = err.resolve_span(l.inner()); - &source.name + err.resolve_span(l.inner()) + .map_or(compilation_uri, |(s, _)| &s.name) }); map.entry(doc.clone()) diff --git a/pip/qsharp/__init__.py b/pip/qsharp/__init__.py index 3b9984cfae..a259762e9a 100644 --- a/pip/qsharp/__init__.py +++ b/pip/qsharp/__init__.py @@ -23,7 +23,7 @@ telemetry_events.on_import() -from ._native import Result, Pauli, QSharpError, TargetProfile +from ._native import Result, Pauli, QSharpError, TargetProfile, GlobalCallable # IPython notebook specific features try: @@ -56,5 +56,6 @@ "PauliNoise", "DepolarizingNoise", "BitFlipNoise", - "PhaseFlipNoise" + "PhaseFlipNoise", + "GlobalCallable", ] diff --git a/pip/qsharp/_native.pyi b/pip/qsharp/_native.pyi index 0bf47a2d5b..5e9cc4ed0f 100644 --- a/pip/qsharp/_native.pyi +++ b/pip/qsharp/_native.pyi @@ -103,6 +103,13 @@ class TargetProfile(Enum): Describes the unrestricted set of capabilities required to run any Q# program. """ +class GlobalCallable: + """ + A callable reference that can be invoked with arguments. + """ + + ... + class Interpreter: """A Q# interpreter.""" @@ -114,6 +121,8 @@ class Interpreter: read_file: Callable[[str], Tuple[str, str]], list_directory: Callable[[str], List[Dict[str, str]]], resolve_path: Callable[[str, str], str], + env: Optional[Any], + make_callable: Optional[Callable[[GlobalCallable], Callable]], ) -> None: """ Initializes the Q# interpreter. @@ -123,6 +132,8 @@ class Interpreter: :param read_file: A function that reads a file from the file system. :param list_directory: A function that lists the contents of a directory. :param resolve_path: A function that joins path segments and normalizes the resulting path. + :param env: An object on which to add any globally accesible callables from the compiled source. + :param make_callable: A function that converts a GlobalCallable into a callable object. """ ... @@ -159,6 +170,22 @@ class Interpreter: """ ... + def invoke( + self, + callable: GlobalCallable, + args: Any, + output_fn: Callable[[Output], None], + ) -> Any: + """ + Invokes the callable with the given arguments, converted into the appropriate Q# values. + :param callable: The callable to invoke. + :param args: The arguments to pass to the callable. + :param output_fn: A callback function that will be called with each output. + :returns values: A result or runtime errors. + :raises QSharpError: If there is an error interpreting the input. + """ + ... + def qir(self, entry_expr: str) -> str: """ Generates QIR from Q# source code. diff --git a/pip/qsharp/_qsharp.py b/pip/qsharp/_qsharp.py index 84a083c770..80b77227fe 100644 --- a/pip/qsharp/_qsharp.py +++ b/pip/qsharp/_qsharp.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from . import telemetry_events +from . import telemetry_events, env from ._native import ( Interpreter, TargetProfile, @@ -24,6 +24,8 @@ from .estimator._estimator import EstimatorResult, EstimatorParams import json import os +import sys +import types from time import monotonic _interpreter = None @@ -190,6 +192,25 @@ def init( f"Error reading {qsharp_json}. qsharp.json should exist at the project root and be a valid JSON file." ) from e + # Loop through the environment module and remove any dynamically added attributes that represent + # Q# callables. This is necessary to avoid conflicts with the new interpreter instance. + keys_to_remove = [] + for key in env.__dict__: + if hasattr(env.__dict__[key], "__qs_gen") or isinstance( + env.__dict__[key], types.ModuleType + ): + keys_to_remove.append(key) + for key in keys_to_remove: + env.__delattr__(key) + + # Also remove any namespace modules dynamically added to the system. + keys_to_remove = [] + for key in sys.modules: + if key.startswith("qsharp.env."): + keys_to_remove.append(key) + for key in keys_to_remove: + sys.modules.__delitem__(key) + _interpreter = Interpreter( target_profile, language_features, @@ -198,6 +219,8 @@ def init( list_directory, resolve, fetch_github, + env, + _make_callable, ) _config = Config(target_profile, language_features, manifest_contents, project_root) @@ -252,6 +275,33 @@ def callback(output: Output) -> None: return results +# Helper function that knows how to create a function that invokes a callable. This will be +# used by the underlying native code to create functions for callables on the fly that know +# how to get the currently intitialized global interpreter instance. +def _make_callable(callable): + def _callable(*args): + ipython_helper() + + def callback(output: Output) -> None: + if _in_jupyter: + try: + display(output) + return + except: + # If IPython is not available, fall back to printing the output + pass + print(output, flush=True) + + if len(args) == 1: + args = args[0] + elif len(args) == 0: + args = None + + return get_interpreter().invoke(callable, args, callback) + + return _callable + + class ShotResult(TypedDict): """ A single result of a shot. diff --git a/pip/qsharp/env/__init__.py b/pip/qsharp/env/__init__.py new file mode 100644 index 0000000000..59e481eb93 --- /dev/null +++ b/pip/qsharp/env/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/pip/src/interpreter.rs b/pip/src/interpreter.rs index 78b4b00a10..12ab786a2f 100644 --- a/pip/src/interpreter.rs +++ b/pip/src/interpreter.rs @@ -11,16 +11,17 @@ use crate::{ noisy_simulator::register_noisy_simulator_submodule, }; use miette::{Diagnostic, Report}; -use num_bigint::BigUint; +use num_bigint::{BigInt, BigUint}; use num_complex::Complex64; use pyo3::{ create_exception, exceptions::{PyException, PyValueError}, prelude::*, - types::{PyComplex, PyDict, PyList, PyTuple, PyType}, + types::{PyComplex, PyDict, PyList, PyString, PyTuple, PyType}, }; use qsc::{ - fir, + fir::{self, StoreItemId}, + hir::ty::{Prim, Ty}, interpret::{ self, output::{Error, Receiver}, @@ -29,7 +30,7 @@ use qsc::{ packages::BuildableProgram, project::{FileSystem, PackageCache, PackageGraphSources}, target::Profile, - LanguageFeatures, PackageType, SourceMap, + FunctorApp, LanguageFeatures, PackageType, SourceMap, }; use resource_estimator::{self as re, estimate_expr}; @@ -73,6 +74,7 @@ fn _native<'a>(py: Python<'a>, m: &Bound<'a, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_function(wrap_pyfunction!(physical_estimates, m)?)?; m.add("QSharpError", py.get_type_bound::())?; register_noisy_simulator_submodule(py, m)?; @@ -213,9 +215,14 @@ impl From for qsc_qasm3::ProgramType { } } +#[allow(clippy::struct_field_names)] #[pyclass(unsendable)] pub(crate) struct Interpreter { pub(crate) interpreter: interpret::Interpreter, + /// The Python environment to which new callables will be added. + pub(crate) env: Option, + /// The Python function to call to create a new function wrapping a callable invocation. + pub(crate) make_callable: Option, } thread_local! { static PACKAGE_CACHE: Rc> = Rc::default(); } @@ -225,7 +232,7 @@ thread_local! { static PACKAGE_CACHE: Rc> = Rc::default(); impl Interpreter { #[allow(clippy::too_many_arguments)] #[allow(clippy::needless_pass_by_value)] - #[pyo3(signature = (target_profile, language_features=None, project_root=None, read_file=None, list_directory=None, resolve_path=None, fetch_github=None))] + #[pyo3(signature = (target_profile, language_features=None, project_root=None, read_file=None, list_directory=None, resolve_path=None, fetch_github=None, env=None, make_callable=None))] #[new] /// Initializes a new Q# interpreter. pub(crate) fn new( @@ -237,6 +244,8 @@ impl Interpreter { list_directory: Option, resolve_path: Option, fetch_github: Option, + env: Option, + make_callable: Option, ) -> PyResult { let target = Into::::into(target_profile).into(); @@ -278,7 +287,24 @@ impl Interpreter { buildable_program.store, &buildable_program.user_code_dependencies, ) { - Ok(interpreter) => Ok(Self { interpreter }), + Ok(interpreter) => { + if let (Some(env), Some(make_callable)) = (&env, &make_callable) { + // Add any global callables from the user source whose types are supported by interop as + // Python functions to the environment. + let exported_items = interpreter.get_source_package_global_items(); + for (namespace, name, item_id) in exported_items + .iter() + .filter(|(_, _, id)| item_supports_interop(&interpreter, *id)) + { + create_py_callable(py, env, make_callable, namespace, name, *item_id)?; + } + } + Ok(Self { + interpreter, + env, + make_callable, + }) + } Err(errors) => Err(QSharpError::new_err(format_errors(errors))), } } @@ -300,7 +326,22 @@ impl Interpreter { ) -> PyResult { let mut receiver = OptionalCallbackReceiver { callback, py }; match self.interpreter.eval_fragments(&mut receiver, input) { - Ok(value) => Ok(ValueWrapper(value).into_py(py)), + Ok(value) => { + if let (Some(env), Some(make_callable)) = (&self.env, &self.make_callable) { + // Get any global callables from the evaluated input whose types are supported by interop and + // add them to the environment. This will grab every callable that was defined in the input and by + // previous calls that added to the open package. This is safe because either the callable will be replaced + // with itself or a new callable with the same name will shadow the previous one, which is the expected behavior. + let new_items = self.interpreter.get_open_package_global_items(); + for (namespace, name, item_id) in new_items + .iter() + .filter(|(_, _, id)| item_supports_interop(&self.interpreter, *id)) + { + create_py_callable(py, env, make_callable, namespace, name, *item_id)?; + } + } + Ok(ValueWrapper(value).into_py(py)) + } Err(errors) => Err(QSharpError::new_err(format_errors(errors))), } } @@ -357,6 +398,45 @@ impl Interpreter { } } + #[pyo3(signature=(callable, args=None, callback=None))] + fn invoke( + &mut self, + py: Python, + callable: GlobalCallable, + args: Option, + callback: Option, + ) -> PyResult { + let mut receiver = OptionalCallbackReceiver { callback, py }; + let (ty, _) = self + .interpreter + .get_callable_tys(callable.id) + .ok_or(QSharpError::new_err("callable not found"))?; + + let callable = Value::Global(callable.id, FunctorApp::default()); + + // Conver the Python arguments to Q# values, treating None as an empty tuple aka `Unit`. + let args = if matches!(&ty, Ty::Tuple(tup) if tup.is_empty()) { + // Special case for unit, where args should be None + if args.is_some() { + return Err(QSharpError::new_err("expected no arguments")); + } + Value::unit() + } else { + let Some(args) = args else { + return Err(QSharpError::new_err(format!( + "expected arguments of type `{ty}`" + ))); + }; + // This conversion will produce errors if the types don't match or can't be converted. + convert_obj_with_ty(py, &args, &ty)? + }; + + match self.interpreter.invoke(&mut receiver, callable, args) { + Ok(value) => Ok(ValueWrapper(value).into_py(py)), + Err(errors) => Err(QSharpError::new_err(format_errors(errors))), + } + } + fn qir(&mut self, _py: Python, entry_expr: &str) -> PyResult { match self.interpreter.qirgen(entry_expr) { Ok(qir) => Ok(qir), @@ -494,6 +574,89 @@ impl Interpreter { } } +/// Check if the given item supports interop with Python by verifying the input and output types. +fn item_supports_interop(interpreter: &interpret::Interpreter, id: StoreItemId) -> bool { + let (input_ty, output_ty) = interpreter + .get_callable_tys(id) + .expect("item should exist in package"); + type_supports_interop(&input_ty) && type_supports_interop(&output_ty) +} + +/// Confirm a Q# type supports with interop with Python, meaning our code knows how to convert it back and forth +/// across the interop boundary. +fn type_supports_interop(ty: &Ty) -> bool { + match ty { + Ty::Prim(prim_ty) => match prim_ty { + Prim::Pauli + | Prim::BigInt + | Prim::Bool + | Prim::Double + | Prim::Int + | Prim::String + | Prim::Result => true, + Prim::Qubit | Prim::Range | Prim::RangeTo | Prim::RangeFrom | Prim::RangeFull => false, + }, + Ty::Tuple(tup) => tup.iter().all(type_supports_interop), + Ty::Array(ty) => type_supports_interop(ty), + _ => false, + } +} + +/// Given a type, convert a Python object into a Q# value of that type. This will recur through tuples and arrays, +/// and will return an error if the type is not supported or the object cannot be converted. +fn convert_obj_with_ty(py: Python, obj: &PyObject, ty: &Ty) -> PyResult { + match ty { + Ty::Prim(prim_ty) => match prim_ty { + Prim::BigInt => Ok(Value::BigInt(obj.extract::(py)?)), + Prim::Bool => Ok(Value::Bool(obj.extract::(py)?)), + Prim::Double => Ok(Value::Double(obj.extract::(py)?)), + Prim::Int => Ok(Value::Int(obj.extract::(py)?)), + Prim::String => Ok(Value::String(obj.extract::(py)?.into())), + Prim::Result => Ok(Value::Result(qsc::interpret::Result::Val( + obj.extract::(py)? == Result::One, + ))), + Prim::Pauli => Ok(Value::Pauli(match obj.extract::(py)? { + Pauli::I => fir::Pauli::I, + Pauli::X => fir::Pauli::X, + Pauli::Y => fir::Pauli::Y, + Pauli::Z => fir::Pauli::Z, + })), + Prim::Qubit | Prim::Range | Prim::RangeTo | Prim::RangeFrom | Prim::RangeFull => Err( + PyException::new_err(format!("unhandled primitive input type: {prim_ty:?}")), + ), + }, + Ty::Tuple(tup) => { + if tup.len() == 1 { + let value = convert_obj_with_ty(py, obj, &tup[0]); + Ok(Value::Tuple(vec![value?].into())) + } else { + let obj = obj.extract::>(py)?; + if obj.len() != tup.len() { + return Err(QSharpError::new_err(format!( + "mismatched tuple arity: expected {}, got {}", + tup.len(), + obj.len() + ))); + } + let mut values = Vec::with_capacity(obj.len()); + for (i, ty) in tup.iter().enumerate() { + values.push(convert_obj_with_ty(py, &obj[i], ty)?); + } + Ok(Value::Tuple(values.into())) + } + } + Ty::Array(ty) => { + let obj = obj.extract::>(py)?; + let mut values = Vec::with_capacity(obj.len()); + for item in &obj { + values.push(convert_obj_with_ty(py, item, ty)?); + } + Ok(Value::Array(values.into())) + } + _ => Err(PyException::new_err(format!("unhandled input type: {ty}"))), + } +} + #[pyfunction] pub fn physical_estimates(logical_resources: &str, job_params: &str) -> PyResult { match re::estimate_physical_resources_from_json(logical_resources, job_params) { @@ -828,3 +991,67 @@ where PyException::new_err(message) } } + +#[pyclass] +#[derive(Clone, Copy)] +struct GlobalCallable { + id: StoreItemId, +} + +/// Create a Python callable from a Q# callable and adds it to the given environment. +fn create_py_callable( + py: Python, + env: &PyObject, + make_callable: &PyObject, + namespace: &[Rc], + name: &str, + item_id: StoreItemId, +) -> PyResult<()> { + if namespace.is_empty() && name == "lambda" { + // We don't want to bind auto-generated lambda callables. + return Ok(()); + } + + // env is expected to be a module, any other type will raise an error. + let mut module: Bound = env.extract(py)?; + + // Create a name that will be used to collect the hieirchy of namespace identifiers if they exist and use that + // to register created modules with the system. + let mut accumulated_namespace: String = module.name()?.extract()?; + accumulated_namespace.push('.'); + for name in namespace { + accumulated_namespace.push_str(name); + let py_name = PyString::new_bound(py, name); + module = if let Ok(module) = module.as_any().getattr(py_name.clone()) { + // Use the existing entry, which should already be a module. + module.extract()? + } else { + // This namespace entry doesn't exist as a module yet, so create it, add it to the environment, and + // add it to sys.modules so it support import properly. + let new_module = PyModule::new_bound(py, &accumulated_namespace)?; + module.add(py_name, &new_module)?; + py.import_bound("sys")? + .getattr("modules")? + .set_item(accumulated_namespace.clone(), new_module.clone())?; + new_module + }; + accumulated_namespace.push('.'); + } + + // Call into the Python layer to create the function wrapping the callable invocation. + let callable = make_callable.call1( + py, + PyTuple::new_bound( + py, + &[Py::new(py, GlobalCallable { id: item_id }) + .expect("should be able to create callable")], + ), + )?; + + // Each callable is annotated so that we know it is auto-generated and can be removed on a re-init of the interpreter. + callable.setattr(py, "__qs_gen", true.into_py(py))?; + + // Add the callable to the module. + module.add(name, callable)?; + Ok(()) +} diff --git a/pip/tests/test_qsharp.py b/pip/tests/test_qsharp.py index 0779972fa5..2f0a65a9dd 100644 --- a/pip/tests/test_qsharp.py +++ b/pip/tests/test_qsharp.py @@ -257,6 +257,7 @@ def test_dump_operation() -> None: else: assert res[i][j] == complex(0.0, 0.0) + def test_run_with_noise_produces_noisy_results() -> None: qsharp.init() qsharp.set_quantum_seed(0) @@ -273,6 +274,7 @@ def test_run_with_noise_produces_noisy_results() -> None: ) assert result[0] > 5 + def test_compile_qir_input_data() -> None: qsharp.init(target_profile=qsharp.TargetProfile.Base) qsharp.eval("operation Program() : Result { use q = Qubit(); return M(q) }") @@ -373,3 +375,77 @@ def test_target_profile_from_str_match_enum_values() -> None: assert qsharp.TargetProfile.from_str(str_value) == target_profile with pytest.raises(ValueError): qsharp.TargetProfile.from_str("Invalid") + + +def test_callables_exposed_into_env() -> None: + qsharp.init() + qsharp.eval("function Four() : Int { 4 }") + assert qsharp.env.Four() == 4 + qsharp.eval("function Add(a : Int, b : Int) : Int { a + b }") + assert qsharp.env.Four() == 4 + assert qsharp.env.Add(2, 3) == 5 + qsharp.eval( + "function Complicated(a : Int, b : (Double, BigInt)) : ((Double, BigInt), Int) { (b, a) }" + ) + assert qsharp.env.Complicated(2, (3.0, 4000000000000000000)) == ( + (3.0, 4000000000000000000), + 2, + ) + qsharp.eval("function Smallest(a : Int[]) : Int { Std.Math.Min(a)}") + assert qsharp.env.Smallest([1, 2, 3, 0, 4, 5]) == 0 + qsharp.init() + # After init, the callables should be cleared and no longer available + with pytest.raises(AttributeError): + qsharp.env.Four() + qsharp.eval("function Identity(a : Int) : Int { a }") + assert qsharp.env.Identity(4) == 4 + with pytest.raises(TypeError): + qsharp.env.Identity("4") + with pytest.raises(TypeError): + qsharp.env.Identity(4.0) + # callables should be created with their namespaces + qsharp.eval("namespace Test { function Four() : Int { 4 } }") + assert qsharp.env.Test.Four() == 4 + # should be able to import callables + from qsharp.env import Identity + from qsharp.env.Test import Four + + assert Identity(4) == 4 + assert Four() == 4 + qsharp.init() + # namespaces should be removed + with pytest.raises(AttributeError): + qsharp.env.Test + # imported callables should fail gracefully + with pytest.raises(qsharp.QSharpError): + Four() + + +def test_callables_with_unsupported_types_not_exposed_into_env() -> None: + qsharp.init() + qsharp.eval("function Unsupported(q : Qubit) : Unit { }") + with pytest.raises(AttributeError): + qsharp.env.Unsupported + qsharp.eval("function Unsupported(q : Qubit[]) : Unit { }") + with pytest.raises(AttributeError): + qsharp.env.Unsupported + qsharp.eval('function Unsupported() : Qubit { fail "won\'t be called" }') + with pytest.raises(AttributeError): + qsharp.env.Unsupported + qsharp.eval("function Unsupported(a : Std.Math.Complex) : Unit { }") + with pytest.raises(AttributeError): + qsharp.env.Unsupported + qsharp.eval('function Unsupported() : Std.Math.Complex { fail "won\'t be called" }') + with pytest.raises(AttributeError): + qsharp.env.Unsupported + qsharp.eval("struct Unsupported { a : Int }") + with pytest.raises(AttributeError): + qsharp.env.Unsupported + + +def test_lambdas_not_exposed_into_env() -> None: + qsharp.init() + qsharp.eval("a -> a + 1") + assert not hasattr(qsharp.env, "lambda") + qsharp.eval("q => I(q)") + assert not hasattr(qsharp.env, "lambda") diff --git a/wasm/src/diagnostic.rs b/wasm/src/diagnostic.rs index b5c2e6dc6b..48dbfd06bf 100644 --- a/wasm/src/diagnostic.rs +++ b/wasm/src/diagnostic.rs @@ -195,7 +195,9 @@ fn resolve_label(e: &WithSource, labeled_span: &LabeledSpan) -> Label where T: Diagnostic + Send + Sync, { - let (source, span) = e.resolve_span(labeled_span.inner()); + let (source, span) = e + .resolve_span(labeled_span.inner()) + .expect("expected span to resolve"); let start = u32::try_from(span.offset()).expect("offset should fit in u32"); let len = u32::try_from(span.len()).expect("length should fit in u32"); let range = qsc::line_column::Range::from_span(