Repository: microsoft/qsharp
Pull request #2054 (2024-12-06 20:20:05)

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.code` module and available for invocation or import directly in Python.


Patch:
diff --git a/compiler/qsc/src/interpret.rs b/compiler/qsc/src/interpret.rs
index e1560bac..2c736ee7 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,72 @@ impl Interpreter {
         })
     }
 
+    /// Given a package ID, returns all the global items in the package.
+    /// Note this does not currently include re-exports.
+    fn package_globals(&self, package_id: PackageId) -> Vec<(Vec<Rc<str>>, Rc<str>, Value)> {
+        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,
+                    Value::Global(store_item_id, FunctorApp::default()),
+                ));
+            }
+        }
+        exported_items
+    }
+
+    /// Get the global callables defined in the user source passed into initialization of the interpreter as `Value` instances.
+    pub fn user_globals(&self) -> Vec<(Vec<Rc<str>>, Rc<str>, Value)> {
+        self.package_globals(self.source_package)
+    }
+
+    /// Get the global callables defined in the open package being interpreted as `Value` instances, which will include any items
+    /// defined by calls to `eval_fragments` and the like.
+    pub fn source_globals(&self) -> Vec<(Vec<Rc<str>>, Rc<str>, Value)> {
+        self.package_globals(self.package)
+    }
+
+    /// Get the input and output types of a given value representing a global item.
+    /// # Panics
+    /// Panics if the item is not callable or a type that can be invoked as a callable.
+    pub fn global_tys(&self, item_id: &Value) -> Option<(ty::Ty, ty::Ty)> {
+        let Value::Global(item_id, _) = item_id else {
+            panic!("value is not a global callable");
+        };
+        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<u64>) {
         self.quantum_seed = seed;
         self.sim.set_seed(seed);
@@ -467,6 +536,33 @@ impl Interpreter {
         )
     }
 
+    /// 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,
+            )
+        })
+    }
+
     /// Runs the given entry expression on a new instance of the environment and simulator,
     /// but using the current compilation.
     pub fn run(
@@ -1033,7 +1129,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/lib.rs b/compiler/qsc/src/lib.rs
index dc957bfc..5847ab44 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 8655e056..e3daac3b 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<u64>,
+    globals: &impl PackageStoreLookup,
+    env: &mut Env,
+    sim: &mut impl Backend<ResultType = impl Into<val::Result>>,
+    receiver: &mut impl Receiver,
+    callable: Value,
+    args: Value,
+) -> Result<Value, (Error, Vec<Frame>)> {
+    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_hir/src/global.rs b/compiler/qsc_hir/src/global.rs
index 754daf48..d4afe8fe 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/pip/qsharp/__init__.py b/pip/qsharp/__init__.py
index 3b9984cf..d588a68e 100644
--- a/pip/qsharp/__init__.py
+++ b/pip/qsharp/__init__.py
@@ -56,5 +56,5 @@ __all__ = [
     "PauliNoise",
     "DepolarizingNoise",
     "BitFlipNoise",
-    "PhaseFlipNoise"
+    "PhaseFlipNoise",
 ]
diff --git a/pip/qsharp/_native.pyi b/pip/qsharp/_native.pyi
index adef263e..328ccb0f 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,7 @@ class Interpreter:
         read_file: Callable[[str], Tuple[str, str]],
         list_directory: Callable[[str], List[Dict[str, str]]],
         resolve_path: Callable[[str, str], str],
+        make_callable: Optional[Callable[[GlobalCallable], None]],
     ) -> None:
         """
         Initializes the Q# interpreter.
@@ -123,6 +131,7 @@ 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 make_callable: A function that registers a Q# callable in the in the environment module.
         """
         ...
 
@@ -159,6 +168,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 5bf6ba4f..198b52db 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, code
 from ._native import (
     Interpreter,
     TargetProfile,
@@ -9,6 +9,7 @@ from ._native import (
     QSharpError,
     Output,
     Circuit,
+    GlobalCallable,
 )
 from typing import (
     Any,
@@ -24,6 +25,8 @@ from typing import (
 from .estimator._estimator import EstimatorResult, EstimatorParams
 import json
 import os
+import sys
+import types
 from time import monotonic
 
 _interpreter = None
@@ -190,6 +193,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 code.__dict__:
+        if hasattr(code.__dict__[key], "__qs_gen") or isinstance(
+            code.__dict__[key], types.ModuleType
+        ):
+            keys_to_remove.append(key)
+    for key in keys_to_remove:
+        code.__delattr__(key)
+
+    # Also remove any namespace modules dynamically added to the system.
+    keys_to_remove = []
+    for key in sys.modules:
+        if key.startswith("qsharp.code."):
+            keys_to_remove.append(key)
+    for key in keys_to_remove:
+        sys.modules.__delitem__(key)
+
     _interpreter = Interpreter(
         target_profile,
         language_features,
@@ -198,6 +220,7 @@ def init(
         list_directory,
         resolve,
         fetch_github,
+        _make_callable,
     )
 
     _config = Config(target_profile, language_features, manifest_contents, project_root)
@@ -372,6 +395,56 @@ def eval(
         return results["result"]
 
 
+# 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: GlobalCallable, namespace: List[str], callable_name: str):
+    module = code
+    # Create a name that will be used to collect the hierachy of namespace identifiers if they exist and use that
+    # to register created modules with the system.
+    accumulated_namespace = "qsharp.code"
+    accumulated_namespace += "."
+    for name in namespace:
+        accumulated_namespace += name
+        # Use the existing entry, which should already be a module.
+        if hasattr(module, name):
+            module = module.__getattribute__(name)
+        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 supports import properly.
+            new_module = types.ModuleType(accumulated_namespace)
+            module.__setattr__(name, new_module)
+            sys.modules[accumulated_namespace] = new_module
+            module = new_module
+        accumulated_namespace += "."
+
+    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)
+
+    # Each callable is annotated so that we know it is auto-generated and can be removed on a re-init of the interpreter.
+    _callable.__qs_gen = True
+
+    # Add the callable to the module.
+    module.__setattr__(callable_name, _callable)
+
+
 def run(
     entry_expr: str,
     shots: int,
diff --git a/pip/qsharp/code/__init__.py b/pip/qsharp/code/__init__.py
new file mode 100644
index 00000000..695b54fb
--- /dev/null
+++ b/pip/qsharp/code/__init__.py
@@ -0,0 +1,6 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+"""
+Code module that receives any user-defined Q# callables as Python functions.
+"""
diff --git a/pip/src/interpreter.rs b/pip/src/interpreter.rs
index f540360f..6f1c41a2 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},
+    hir::ty::{Prim, Ty},
     interpret::{
         self,
         output::{Error, Receiver},
@@ -73,6 +74,7 @@ fn _native<'a>(py: Python<'a>, m: &Bound<'a, PyModule>) -> PyResult<()> {
     m.add_class::<Output>()?;
     m.add_class::<StateDumpData>()?;
     m.add_class::<Circuit>()?;
+    m.add_class::<GlobalCallable>()?;
     m.add_function(wrap_pyfunction!(physical_estimates, m)?)?;
     m.add("QSharpError", py.get_type_bound::<QSharpError>())?;
     register_noisy_simulator_submodule(py, m)?;
@@ -213,9 +215,12 @@ impl From<ProgramType> for qsc_qasm3::ProgramType {
     }
 }
 
+#[allow(clippy::struct_field_names)]
 #[pyclass(unsendable)]
 pub(crate) struct Interpreter {
     pub(crate) interpreter: interpret::Interpreter,
+    /// The Python function to call to create a new function wrapping a callable invocation.
+    pub(crate) make_callable: Option<PyObject>,
 }
 
 thread_local! { static PACKAGE_CACHE: Rc<RefCell<PackageCache>> = Rc::default(); }
@@ -225,7 +230,7 @@ thread_local! { static PACKAGE_CACHE: Rc<RefCell<PackageCache>> = 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, make_callable=None))]
     #[new]
     /// Initializes a new Q# interpreter.
     pub(crate) fn new(
@@ -237,6 +242,7 @@ impl Interpreter {
         list_directory: Option<PyObject>,
         resolve_path: Option<PyObject>,
         fetch_github: Option<PyObject>,
+        make_callable: Option<PyObject>,
     ) -> PyResult<Self> {
         let target = Into::<Profile>::into(target_profile).into();
 
@@ -278,7 +284,19 @@ impl Interpreter {
             buildable_program.store,
             &buildable_program.user_code_dependencies,
         ) {
-            Ok(interpreter) => Ok(Self { interpreter }),
+            Ok(interpreter) => {
+                if let Some(make_callable) = &make_callable {
+                    // Add any global callables from the user source as Python functions to the environment.
+                    let exported_items = interpreter.user_globals();
+                    for (namespace, name, val) in exported_items {
+                        create_py_callable(py, make_callable, &namespace, &name, val)?;
+                    }
+                }
+                Ok(Self {
+                    interpreter,
+                    make_callable,
+                })
+            }
             Err(errors) => Err(QSharpError::new_err(format_errors(errors))),
         }
     }
@@ -300,7 +318,19 @@ impl Interpreter {
     ) -> PyResult<PyObject> {
         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(make_callable) = &self.make_callable {
+                    // Get any global callables from the evaluated input 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.source_globals();
+                    for (namespace, name, val) in new_items {
+                        create_py_callable(py, make_callable, &namespace, &name, val)?;
+                    }
+                }
+                Ok(ValueWrapper(value).into_py(py))
+            }
             Err(errors) => Err(QSharpError::new_err(format_errors(errors))),
         }
     }
@@ -357,6 +387,56 @@ impl Interpreter {
         }
     }
 
+    #[pyo3(signature=(callable, args=None, callback=None))]
+    fn invoke(
+        &mut self,
+        py: Python,
+        callable: GlobalCallable,
+        args: Option<PyObject>,
+        callback: Option<PyObject>,
+    ) -> PyResult<PyObject> {
+        let mut receiver = OptionalCallbackReceiver { callback, py };
+        let (input_ty, output_ty) = self
+            .interpreter
+            .global_tys(&callable.0)
+            .ok_or(QSharpError::new_err("callable not found"))?;
+
+        // If the types are not supported, we can't convert the arguments or return value.
+        // Check this before trying to convert the arguments, and return an error if the types are not supported.
+        if let Some(ty) = first_unsupported_interop_ty(&input_ty) {
+            return Err(QSharpError::new_err(format!(
+                "unsupported input type: `{ty}`"
+            )));
+        }
+        if let Some(ty) = first_unsupported_interop_ty(&output_ty) {
+            return Err(QSharpError::new_err(format!(
+                "unsupported output type: `{ty}`"
+            )));
+        }
+
+        // Conver the Python arguments to Q# values, treating None as an empty tuple aka `Unit`.
+        let args = if matches!(&input_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 `{input_ty}`"
+                )));
+            };
+            // This conversion will produce errors if the types don't match or can't be converted.
+            convert_obj_with_ty(py, &args, &input_ty)?
+        };
+
+        match self.interpreter.invoke(&mut receiver, callable.0, 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<String> {
         match self.interpreter.qirgen(entry_expr) {
             Ok(qir) => Ok(qir),
@@ -494,6 +574,85 @@ impl Interpreter {
     }
 }
 
+/// Finds any Q# type recursively that does not support interop with Python, meaning our code cannot convert it back and forth
+/// across the interop boundary.
+fn first_unsupported_interop_ty(ty: &Ty) -> Option<&Ty> {
+    match ty {
+        Ty::Prim(prim_ty) => match prim_ty {
+            Prim::Pauli
+            | Prim::BigInt
+            | Prim::Bool
+            | Prim::Double
+            | Prim::Int
+            | Prim::String
+            | Prim::Result => None,
+            Prim::Qubit | Prim::Range | Prim::RangeTo | Prim::RangeFrom | Prim::RangeFull => {
+                Some(ty)
+            }
+        },
+        Ty::Tuple(tup) => tup
+            .iter()
+            .find(|t| first_unsupported_interop_ty(t).is_some()),
+        Ty::Array(ty) => first_unsupported_interop_ty(ty),
+        _ => Some(ty),
+    }
+}
+
+/// 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<Value> {
+    match ty {
+        Ty::Prim(prim_ty) => match prim_ty {
+            Prim::BigInt => Ok(Value::BigInt(obj.extract::<BigInt>(py)?)),
+            Prim::Bool => Ok(Value::Bool(obj.extract::<bool>(py)?)),
+            Prim::Double => Ok(Value::Double(obj.extract::<f64>(py)?)),
+            Prim::Int => Ok(Value::Int(obj.extract::<i64>(py)?)),
+            Prim::String => Ok(Value::String(obj.extract::<String>(py)?.into())),
+            Prim::Result => Ok(Value::Result(qsc::interpret::Result::Val(
+                obj.extract::<Result>(py)? == Result::One,
+            ))),
+            Prim::Pauli => Ok(Value::Pauli(match obj.extract::<Pauli>(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 => {
+                unimplemented!("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::<Vec<PyObject>>(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::<Vec<PyObject>>(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()))
+        }
+        _ => unimplemented!("input type: {ty}"),
+    }
+}
+
 #[pyfunction]
 pub fn physical_estimates(logical_resources: &str, job_params: &str) -> PyResult<String> {
     match re::estimate_physical_resources_from_json(logical_resources, job_params) {
@@ -840,3 +999,47 @@ where
         PyException::new_err(message)
     }
 }
+
+#[pyclass(unsendable)]
+#[derive(Clone)]
+struct GlobalCallable(Value);
+
+impl From<Value> for GlobalCallable {
+    fn from(val: Value) -> Self {
+        match val {
+            val @ Value::Global(..) => GlobalCallable(val),
+            _ => panic!("expected global callable"),
+        }
+    }
+}
+
+impl From<GlobalCallable> for Value {
+    fn from(val: GlobalCallable) -> Self {
+        val.0
+    }
+}
+
+/// Create a Python callable from a Q# callable and adds it to the given environment.
+fn create_py_callable(
+    py: Python,
+    make_callable: &PyObject,
+    namespace: &[Rc<str>],
+    name: &str,
+    val: Value,
+) -> PyResult<()> {
+    if namespace.is_empty() && name == "<lambda>" {
+        // We don't want to bind auto-generated lambda callables.
+        return Ok(());
+    }
+
+    let args = (
+        Py::new(py, GlobalCallable::from(val)).expect("should be able to create callable"), // callable id
+        PyList::new_bound(py, namespace.iter().map(ToString::to_string)), // namespace as string array
+        PyString::new_bound(py, name),                                    // name of callable
+    );
+
+    // Call into the Python layer to create the function wrapping the callable invocation.
+    make_callable.call1(py, args)?;
+
+    Ok(())
+}


Test patch:
diff --git a/compiler/qsc/src/interpret/tests.rs b/compiler/qsc/src/interpret/tests.rs
index ce372544..2b82256c 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::<u8>::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.user_globals();
+            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.source_globals();
+            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.source_globals();
+            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.source_globals();
+            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.source_globals();
+            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.source_globals();
+            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.user_globals();
+            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/pip/tests/test_qsharp.py b/pip/tests/test_qsharp.py
index a1f98925..f79541b0 100644
--- a/pip/tests/test_qsharp.py
+++ b/pip/tests/test_qsharp.py
@@ -3,6 +3,7 @@
 
 import pytest
 import qsharp
+import qsharp.code
 import qsharp.utils
 from contextlib import redirect_stdout
 import io
@@ -404,3 +405,207 @@ 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.code.Four() == 4, "callable should be available"
+    qsharp.eval("function Add(a : Int, b : Int) : Int { a + b }")
+    assert qsharp.code.Four() == 4, "first callable should still be available"
+    assert qsharp.code.Add(2, 3) == 5, "second callable should be available"
+    # After init, the callables should be cleared and no longer available
+    qsharp.init()
+    with pytest.raises(AttributeError):
+        qsharp.code.Four()
+
+
+def test_callable_exposed_into_env_complex_types() -> None:
+    qsharp.eval(
+        "function Complicated(a : Int, b : (Double, BigInt)) : ((Double, BigInt), Int) { (b, a) }"
+    )
+    assert qsharp.code.Complicated(2, (3.0, 4000000000000000000000)) == (
+        (3.0, 4000000000000000000000),
+        2,
+    ), "callables that take complex types should marshall them correctly"
+
+
+def test_callable_exposed_into_env_with_array() -> None:
+    qsharp.init()
+    qsharp.eval("function Smallest(a : Int[]) : Int { Std.Math.Min(a)}")
+    assert (
+        qsharp.code.Smallest([1, 2, 3, 0, 4, 5]) == 0
+    ), "callable that takes array should work"
+
+
+def test_callable_with_int_exposed_into_env_fails_incorrect_types() -> None:
+    qsharp.init()
+    qsharp.eval("function Identity(a : Int) : Int { a }")
+    assert qsharp.code.Identity(4) == 4
+    with pytest.raises(TypeError):
+        qsharp.code.Identity("4")
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4.0)
+    with pytest.raises(OverflowError):
+        qsharp.code.Identity(4000000000000000000000)
+    with pytest.raises(TypeError):
+        qsharp.code.Identity([4])
+
+
+def test_callable_with_double_exposed_into_env_fails_incorrect_types() -> None:
+    qsharp.init()
+    qsharp.eval("function Identity(a : Double) : Double { a }")
+    assert qsharp.code.Identity(4.0) == 4.0
+    assert qsharp.code.Identity(4) == 4.0
+    with pytest.raises(TypeError):
+        qsharp.code.Identity("4")
+    with pytest.raises(TypeError):
+        qsharp.code.Identity([4])
+
+
+def test_callable_with_bigint_exposed_into_env_fails_incorrect_types() -> None:
+    qsharp.init()
+    qsharp.eval("function Identity(a : BigInt) : BigInt { a }")
+    assert qsharp.code.Identity(4000000000000000000000) == 4000000000000000000000
+    with pytest.raises(TypeError):
+        qsharp.code.Identity("4")
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4.0)
+
+
+def test_callable_with_string_exposed_into_env_fails_incorrect_types() -> None:
+    qsharp.init()
+    qsharp.eval("function Identity(a : String) : String { a }")
+    assert qsharp.code.Identity("4") == "4"
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4)
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4.0)
+    with pytest.raises(TypeError):
+        qsharp.code.Identity([4])
+
+
+def test_callable_with_bool_exposed_into_env_fails_incorrect_types() -> None:
+    qsharp.init()
+    qsharp.eval("function Identity(a : Bool) : Bool { a }")
+    assert qsharp.code.Identity(True) == True
+    with pytest.raises(TypeError):
+        qsharp.code.Identity("4")
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4)
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4.0)
+    with pytest.raises(TypeError):
+        qsharp.code.Identity([4])
+
+
+def test_callable_with_array_exposed_into_env_fails_incorrect_types() -> None:
+    qsharp.init()
+    qsharp.eval("function Identity(a : Int[]) : Int[] { a }")
+    assert qsharp.code.Identity([4, 5, 6]) == [4, 5, 6]
+    assert qsharp.code.Identity([]) == []
+    assert qsharp.code.Identity((4, 5, 6)) == [4, 5, 6]
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4)
+    with pytest.raises(TypeError):
+        qsharp.code.Identity("4")
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4.0)
+    with pytest.raises(TypeError):
+        qsharp.code.Identity([1, 2, 3.0])
+
+
+def test_callable_with_tuple_exposed_into_env_fails_incorrect_types() -> None:
+    qsharp.init()
+    qsharp.eval("function Identity(a : (Int, Double)) : (Int, Double) { a }")
+    assert qsharp.code.Identity((4, 5.0)) == (4, 5.0)
+    assert qsharp.code.Identity((4, 5)) == (4, 5.0)
+    assert qsharp.code.Identity([4, 5.0]) == (4, 5.0)
+    with pytest.raises(qsharp.QSharpError):
+        qsharp.code.Identity((4, 5, 6))
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4)
+    with pytest.raises(TypeError):
+        qsharp.code.Identity("4")
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4.0)
+    with pytest.raises(TypeError):
+        qsharp.code.Identity([4.0, 5])
+
+
+def test_callables_in_namespaces_exposed_into_env_submodules_and_removed_on_reinit() -> (
+    None
+):
+    qsharp.init()
+    # callables should be created with their namespaces
+    qsharp.eval("namespace Test { function Four() : Int { 4 } }")
+    qsharp.eval("function Identity(a : Int) : Int { a }")
+    # should be able to import callables from env and namespace submodule
+    from qsharp.code import Identity
+    from qsharp.code.Test import Four
+
+    assert Identity(4) == 4
+    assert Four() == 4
+    qsharp.init()
+    # namespaces should be removed
+    with pytest.raises(AttributeError):
+        qsharp.code.Test
+    with pytest.raises(AttributeError):
+        qsharp.code.Identity()
+    # imported callables should fail gracefully
+    with pytest.raises(qsharp.QSharpError):
+        Four()
+
+
+def test_callables_with_unsupported_types_raise_errors_on_call() -> None:
+    qsharp.init()
+    qsharp.eval("function Unsupported(a : Int, q : Qubit) : Unit { }")
+    with pytest.raises(qsharp.QSharpError, match="unsupported input type: `Qubit`"):
+        qsharp.code.Unsupported()
+
+
+def test_callables_with_unsupported_types_in_tuples_raise_errors_on_call() -> None:
+    qsharp.init()
+    qsharp.eval("function Unsupported(q : (Int, Qubit)[]) : Unit { }")
+    with pytest.raises(qsharp.QSharpError, match="unsupported input type: `Qubit`"):
+        qsharp.code.Unsupported()
+
+
+def test_callables_with_unsupported_return_types_raise_errors_on_call() -> None:
+    qsharp.init()
+    qsharp.eval('function Unsupported() : Qubit { fail "won\'t be called" }')
+    with pytest.raises(qsharp.QSharpError, match="unsupported output type: `Qubit`"):
+        qsharp.code.Unsupported()
+
+
+def test_callables_with_unsupported_udt_types_raise_errors_on_call() -> None:
+    qsharp.init()
+    qsharp.eval("function Unsupported(a : Std.Math.Complex) : Unit { }")
+    with pytest.raises(
+        qsharp.QSharpError, match='unsupported input type: `UDT<"Complex":'
+    ):
+        qsharp.code.Unsupported()
+
+
+def test_callable_with_unsupported_udt_return_types_raise_errors_on_call() -> None:
+    qsharp.init()
+    qsharp.eval('function Unsupported() : Std.Math.Complex { fail "won\'t be called" }')
+    with pytest.raises(
+        qsharp.QSharpError, match='unsupported output type: `UDT<"Complex":'
+    ):
+        qsharp.code.Unsupported()
+
+
+def test_struct_call_constructor_not_exposed_into_env() -> None:
+    qsharp.init()
+    qsharp.eval("struct CustomUDT { a : Int }")
+    with pytest.raises(AttributeError):
+        qsharp.code.CustomUDT
+
+
+def test_lambdas_not_exposed_into_env() -> None:
+    qsharp.init()
+    qsharp.eval("a -> a + 1")
+    assert not hasattr(qsharp.code, "<lambda>")
+    qsharp.eval("q => I(q)")
+    assert not hasattr(qsharp.code, "<lambda>")