Skip to content

Commit fee5e86

Browse files
authored
Strengthen compatibility checks across Python interop (#4000)
This change adds stronger checks across the Python interop boundary to reject two important classes of unsupported interop that can lead to bugs: - Invoking callables with functors (`Adjoint` and `Controlled`), which don't make sense from the Python level as those would need `Qubit` arguments and that type is not allowed across the interop boundary. - Invoking callables or passing callable arguments that include generic type parameters, as that could allow unconstrained or mismatched generics to make it deeper into the compilation (we don't perform type inference for generics after the fact).
1 parent 9faf5c2 commit fee5e86

3 files changed

Lines changed: 166 additions & 62 deletions

File tree

‎source/qdk_package/src/interpreter.rs‎

Lines changed: 28 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use crate::{
1717
get_program_type, get_search_path, sanitize_name,
1818
},
1919
interpreter::data_interop::{
20-
PrimitiveKind, TypeIR, TypeKind, UdtFields, UdtIR, UdtValue, collect_udt_fields,
20+
PrimitiveKind, TypeIR, TypeKind, UdtFields, UdtIR, UdtValue, first_unsupported_interop_ty,
2121
pyobj_to_value, type_ir_from_qsharp_ty, value_to_pyobj,
2222
},
2323
noisy_simulator::register_noisy_simulator_submodule,
@@ -44,11 +44,11 @@ use pyo3::{
4444
types::{PyBool, PyDict, PyList, PyString, PyTuple, PyType},
4545
};
4646
use qsc::{
47-
LanguageFeatures, PackageType, SourceMap,
47+
FunctorApp, LanguageFeatures, PackageType, SourceMap,
4848
circuit::{TracerConfig, circuit_to_standalone_qsharp, circuits_to_qsharp},
4949
error::WithSource,
5050
fir::{self},
51-
hir::ty::{Prim, Ty},
51+
hir::ty::Ty,
5252
interpret::{
5353
self, CircuitEntryPoint, PackageGlobal, PauliNoise, SimType, TaggedItem, Value,
5454
output::{Error, Receiver},
@@ -1119,12 +1119,12 @@ fn args_to_values(
11191119
) -> PyResult<Value> {
11201120
// If the types are not supported, we can't convert the arguments or return value.
11211121
// Check this before trying to convert the arguments, and return an error if the types are not supported.
1122-
if let Some(ty) = first_unsupported_interop_ty(ctx, input_ty) {
1122+
if let Some(ty) = first_unsupported_interop_ty(ctx, input_ty, false) {
11231123
return Err(QSharpError::new_err(format!(
11241124
"unsupported input type: `{ty}`"
11251125
)));
11261126
}
1127-
if let Some(ty) = first_unsupported_interop_ty(ctx, output_ty) {
1127+
if let Some(ty) = first_unsupported_interop_ty(ctx, output_ty, false) {
11281128
return Err(QSharpError::new_err(format!(
11291129
"unsupported output type: `{ty}`"
11301130
)));
@@ -1148,65 +1148,34 @@ fn args_to_values(
11481148
}
11491149
}
11501150

1151-
/// Finds any Q# type recursively that does not support interop with Python, meaning our code cannot convert it back and forth
1152-
/// across the interop boundary.
1153-
fn first_unsupported_interop_ty<'ctx, 'ty>(
1154-
ctx: &'ctx interpret::Interpreter,
1155-
ty: &'ty Ty,
1156-
) -> Option<&'ctx Ty>
1157-
where
1158-
'ty: 'ctx,
1159-
{
1160-
match ty {
1161-
Ty::Prim(prim_ty) => match prim_ty {
1162-
Prim::Pauli
1163-
| Prim::BigInt
1164-
| Prim::Bool
1165-
| Prim::Double
1166-
| Prim::Int
1167-
| Prim::String
1168-
| Prim::Result => None,
1169-
Prim::Qubit | Prim::Range | Prim::RangeTo | Prim::RangeFrom | Prim::RangeFull => {
1170-
Some(ty)
1171-
}
1172-
},
1173-
Ty::Tuple(tup) => tup
1174-
.iter()
1175-
.find(|t| first_unsupported_interop_ty(ctx, t).is_some()),
1176-
Ty::Array(ty) => first_unsupported_interop_ty(ctx, ty),
1177-
Ty::Udt(_, res) => {
1178-
let qsc::hir::Res::Item(item_id) = res else {
1179-
panic!("Udt should be an item");
1180-
};
1181-
let (udt, _) = ctx.udt_ty_from_item_id(item_id);
1182-
1183-
let Ok(fields) = collect_udt_fields(udt) else {
1184-
return Some(ty);
1185-
};
1186-
1187-
for field in fields {
1188-
if let Some(ty) = first_unsupported_interop_ty(ctx, field.1) {
1189-
return Some(ty);
1190-
}
1191-
}
1192-
1193-
None
1194-
}
1195-
Ty::Arrow(..) => None,
1196-
Ty::Infer(..) | Ty::Param { .. } | Ty::Err => Some(ty),
1197-
}
1198-
}
1199-
12001151
fn extract_callable_value(py: Python, callable: &Py<PyAny>) -> PyResult<Value> {
1201-
if let Ok(global_callable) = callable.extract::<GlobalCallable>(py) {
1202-
Ok(global_callable.0)
1152+
let callable_val = if let Ok(global_callable) = callable.extract::<GlobalCallable>(py) {
1153+
global_callable.0
12031154
} else if let Ok(closure) = callable.extract::<Closure>(py) {
1204-
Ok(closure.0)
1155+
closure.0
12051156
} else {
1206-
Err(PyException::new_err(
1157+
return Err(QSharpError::new_err(
12071158
"callable must be either a GlobalCallable or a Closure",
1208-
))
1159+
));
1160+
};
1161+
1162+
// Ensure the value is either a callabe with no functors or a closure with no functors
1163+
match &callable_val {
1164+
Value::Closure(closure) if closure.functor == FunctorApp::default() => {}
1165+
Value::Global(_, functor) if *functor == FunctorApp::default() => {}
1166+
Value::Closure(_) | Value::Global(_, _) => {
1167+
return Err(QSharpError::new_err(
1168+
"callable must have no applied functors (`Adjoint` or `Controlled`)",
1169+
));
1170+
}
1171+
_ => {
1172+
return Err(QSharpError::new_err(
1173+
"callable must be either a GlobalCallable or a Closure",
1174+
));
1175+
}
12091176
}
1177+
1178+
Ok(callable_val)
12101179
}
12111180

12121181
#[pyfunction]

‎source/qdk_package/src/interpreter/data_interop.rs‎

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//! This module contains the types and functions used to build the
55
//! data-interop layer between Python and Q#.
66
7-
use crate::interpreter::{Closure, GlobalCallable, QasmError};
7+
use crate::interpreter::{Closure, GlobalCallable, QSharpError, QasmError};
88

99
use super::{Pauli, Result};
1010
use num_bigint::BigInt;
@@ -214,6 +214,71 @@ where
214214
}
215215
}
216216

217+
/// Finds any Q# type recursively that does not support interop with Python, meaning our code cannot convert it back and forth
218+
/// across the interop boundary.
219+
pub(super) fn first_unsupported_interop_ty<'ctx, 'ty>(
220+
ctx: &'ctx interpret::Interpreter,
221+
ty: &'ty Ty,
222+
allow_any_primitive: bool,
223+
) -> Option<String>
224+
where
225+
'ty: 'ctx,
226+
{
227+
match ty {
228+
Ty::Prim(prim_ty) => match prim_ty {
229+
Prim::Pauli
230+
| Prim::BigInt
231+
| Prim::Bool
232+
| Prim::Double
233+
| Prim::Int
234+
| Prim::String
235+
| Prim::Result => None,
236+
Prim::Qubit | Prim::Range | Prim::RangeTo | Prim::RangeFrom | Prim::RangeFull => {
237+
if allow_any_primitive {
238+
None
239+
} else {
240+
Some(ty.to_string())
241+
}
242+
}
243+
},
244+
Ty::Tuple(tup) => tup
245+
.iter()
246+
.find_map(|t| first_unsupported_interop_ty(ctx, t, allow_any_primitive)),
247+
Ty::Array(ty) => first_unsupported_interop_ty(ctx, ty, allow_any_primitive),
248+
Ty::Udt(_, res) => {
249+
let qsc::hir::Res::Item(item_id) = res else {
250+
panic!("Udt should be an item");
251+
};
252+
let (udt, _) = ctx.udt_ty_from_item_id(item_id);
253+
254+
let Ok(fields) = collect_udt_fields(udt) else {
255+
return Some(ty.to_string());
256+
};
257+
258+
for field in fields {
259+
if let Some(ty) = first_unsupported_interop_ty(ctx, field.1, allow_any_primitive) {
260+
return Some(ty);
261+
}
262+
}
263+
264+
None
265+
}
266+
Ty::Arrow(arrow) => {
267+
// Reject unsupported types in the input and output of the callable, but allow any primitive types as those won't
268+
// be coming from Python directly. Primarily this ends up rejecting callables with generic type parameters,
269+
// to avoid having to infer concrete types on the fly.
270+
if let Some(ty) = first_unsupported_interop_ty(ctx, &arrow.input.borrow(), true) {
271+
return Some(ty);
272+
}
273+
if let Some(ty) = first_unsupported_interop_ty(ctx, &arrow.output.borrow(), true) {
274+
return Some(ty);
275+
}
276+
None
277+
}
278+
Ty::Infer(..) | Ty::Param { .. } | Ty::Err => Some(ty.to_string()),
279+
}
280+
}
281+
217282
/// Given a type, convert a Python object into a Q# value of that type. This will recur through tuples and arrays,
218283
/// and will return an error if the type is not supported or the object cannot be converted.
219284
pub(super) fn pyobj_to_value(
@@ -273,10 +338,14 @@ pub(super) fn pyobj_to_value(
273338
Ty::Udt(_, res) => pyobj_to_udt(ctx, py, obj, ty, res),
274339
Ty::Arrow(..) => {
275340
if let Ok(callable) = extract_obj::<GlobalCallable>(py, obj, ty) {
276-
return Ok(callable.into());
341+
let callable_val = callable.into();
342+
check_callable_types(ctx, &callable_val)?;
343+
return Ok(callable_val);
277344
}
278345
if let Ok(callable) = extract_obj::<Closure>(py, obj, ty) {
279-
return Ok(callable.into());
346+
let callable_val = callable.into();
347+
check_callable_types(ctx, &callable_val)?;
348+
return Ok(callable_val);
280349
}
281350
Err(PyTypeError::new_err(format!(
282351
"expected a callable, found {}",
@@ -287,6 +356,23 @@ pub(super) fn pyobj_to_value(
287356
}
288357
}
289358

359+
fn check_callable_types(ctx: &interpret::Interpreter, callable_val: &Value) -> PyResult<()> {
360+
let (input_ty, output_ty) = ctx
361+
.global_callable_ty(callable_val)
362+
.ok_or(QSharpError::new_err("callable not found"))?;
363+
if let Some(ty) = first_unsupported_interop_ty(ctx, &input_ty, true) {
364+
Err(QSharpError::new_err(format!(
365+
"unsupported input type: `{ty}`"
366+
)))
367+
} else if let Some(ty) = first_unsupported_interop_ty(ctx, &output_ty, true) {
368+
Err(QSharpError::new_err(format!(
369+
"unsupported output type: `{ty}`"
370+
)))
371+
} else {
372+
Ok(())
373+
}
374+
}
375+
290376
fn pyobj_to_udt(
291377
ctx: &interpret::Interpreter,
292378
py: Python,

‎source/qdk_package/tests/test_qsharp.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -918,6 +918,55 @@ def test_callable_with_unsupported_udt_return_type_raises_error_on_call() -> Non
918918
qdk.code.Unsupported()
919919

920920

921+
def test_callable_with_functor_raises_error_on_call() -> None:
922+
qsharp.init()
923+
callable = qsharp.eval("""
924+
operation Unsupported() : Unit is Adj + Ctl { }
925+
Adjoint Unsupported
926+
""")
927+
with pytest.raises(
928+
qsharp.QSharpError,
929+
match="callable must have no applied functors",
930+
):
931+
qsharp.run(callable, shots=1)
932+
callable = qsharp.eval("Controlled Unsupported")
933+
with pytest.raises(
934+
qsharp.QSharpError,
935+
match="callable must have no applied functors",
936+
):
937+
qsharp.run(callable, shots=1)
938+
939+
940+
def test_callable_with_generic_type_raises_error_on_call() -> None:
941+
qsharp.init()
942+
qsharp.eval("""
943+
function Unsupported<'T>(a : 'T) : Unit { }
944+
""")
945+
with pytest.raises(qsharp.QSharpError, match="unsupported input type"):
946+
qdk.code.Unsupported()
947+
948+
949+
def test_callable_with_function_argument_with_generic_type_raises_error_on_call() -> (
950+
None
951+
):
952+
qsharp.init()
953+
qsharp.eval("""
954+
function Unsupported<'T>(a : 'T -> 'T) : Unit { }
955+
""")
956+
with pytest.raises(qsharp.QSharpError, match="unsupported input type"):
957+
qdk.code.Unsupported()
958+
959+
960+
def test_callable_with_generic_function_as_input_raises_error_on_call() -> None:
961+
qsharp.init()
962+
qsharp.eval("""
963+
function Identity<'T>(x : 'T) : 'T { x }
964+
function IntIdentity(x : Int, id : (Int -> Int)) : Int { id(x) }
965+
""")
966+
with pytest.raises(qsharp.QSharpError, match="unsupported input type"):
967+
qdk.code.IntIdentity(42, qdk.code.Identity)
968+
969+
921970
def test_returning_unsupported_udt_from_eval_raises_error_on_call() -> None:
922971
qsharp.init()
923972
with pytest.raises(

0 commit comments

Comments
 (0)