Repository: qir-alliance/pyqir (language: python)

Issue:
Support external functions in generator
PyQIR Generator should support adding external function declarations to a module and emitting LLVM call instructions for them using the builder. The initial version of the feature will probably support only external functions with a void return type. That will enable custom quantum instruction sets and state-changing utility functions.

A draft PR with an initial implementation is available: #54

There are a few different ideas for how the API can be designed:

## 1. Declare function before calling

`SimpleModule` could have a method like `add_external_function(name, types)` that returns a wrapped LLVM function value. `Builder` could have a method like `call(function, args)` that takes the function value and a list of arguments.

```python
module = SimpleModule(...)
my_function = module.add_external_function("my_function", Qubit)
module.builder.call(my_function, module.qubits[0])
```

Pros:
- Simple and explicit API with minimal magic under the hood
- Close to LLVM and easy to evolve into a general LLVM call API (e.g. with return types)
- Good support for mypy and code completion

Cons:
- Verbose

## 2. Automatically declare function on first call

There would be no explicit method to add a function - it would happen automatically the first time `call(name, args)` is used with a new function name. The type of the external function would be inferred from the type of the arguments.

```python
module = SimpleModule(...)
module.builder.call("my_function", module.qubits[0])
```

Pros:
- Simpler for currently supported cases (statically known function value with void return type)

Cons:
- Return type can't easily be inferred in the future
- Very dynamic; typos in function name string or issues in inferred argument types could lead to hard-to-debug errors. Because an LLVM function definition is implicily added the first time `call` is used, the first call will always succeed but subsequent calls may fail if the argument types are different.

## 3. Automatically declare function on first call with sugar

A sugared version of option 2 would be to define `__getattr__` on `Builder` that would handle unknown method names. For example, `builder.my_external_function(args)` would be converted to `builder.call("my_external_function", args)`.

```python
module = SimpleModule(...)
module.builder.my_function(module.qubits[0])
```

Pros:
- Very concise

Cons:
- Does not work as well with Python tooling (type checking, code completion)
- Function names can clash with other method names on `Builder`

## Future evolution

We should consider how the chosen API will evolve into a more general LLVM call instruction API. In particular, the options above all have the `call` method on `Builder`, but `Builder` is not specific to the basic QIR profile. So if we choose a simplified `call` API, it may stick around alongside the more general API.

Some features we should consider for future evolution:

- Arguments may be any LLVM value (e.g. referring to a previous instruction result), not just Python values that are converted into LLVM constants.
- Functions could have a non-void return type.
- The function value may not be statically known (but this may require a different API for [QIR callables](https://github.com/qir-alliance/qir-spec/blob/main/specification/v0.1/2_Callables.md)).


Discussion / hints:

Fix patch:
diff --git a/examples/generator/README.md b/examples/generator/README.md
index 997d0c4e..58467fb3 100644
--- a/examples/generator/README.md
+++ b/examples/generator/README.md
@@ -12,43 +12,60 @@ intended to be used as a framework for algorithm and application development.
 This folder contains the following examples for how to use the `pyqir-generator`
 package:
 
-- **Bernstein-Vazirani example**: <br/>
-  This example matches most closely how the `pyqir-generator` package is
-  intended to be used. It consists of a [Python
-  program](https://github.com/qir-alliance/pyqir/tree/main/examples/generator/mock_to_qir.py)
-  that uses a "mini-compiler" for a made up [Mock
-  language](https://github.com/qir-alliance/pyqir/tree/main/examples/generator/mock_language)
-  to parse a program and then walks the created syntax tree to compile it into
-  QIR. For simplicity, we used [Antlr](https://www.antlr.org/) to generate the
-  parser based on the defined
-  [grammar](https://github.com/qir-alliance/pyqir/tree/main/examples/generator/mock_language/MockLanguage.g4)
-  and omitted any further compilation or optimization. Before running the example,
-  please install the Antlr runtime:
-
-  ```bash
-  pip install antlr4-python3-runtime
-  ```
-
-  The example can then be run using python, with the generated QIR being
-  written to a text file:
-
-  ```bash
-  python mock_to_qir.py bernstein_vazirani.txt 7 >> bernstein_vazirani_output.txt
-  ```
-
-- **Bell pair example**: <br/>
-  This examples consists of a [single
-  file](https://github.com/qir-alliance/pyqir/tree/main/examples/generator/bell_pair.py),
-  and does not require any additional installation besides the `pyqir-generator`
-  package itself. Please be aware that the PyQIR API is not intended to directly
-  express quantum applications; its purpose is to be easily usable for *compiler
-  and frontend developers* rather than *application developers* - as evidenced,
-  e.g., by the fact that there is no `qubit` or `register` type defined within
-  the API.
-
-  The example can be run using python, with the generated QIR being
-  written to a text file:
-
-  ```bash
-  python bell_pair.py >> bell_pair_output.txt
-  ```
+## Bernstein-Vazirani
+
+This example matches most closely how the `pyqir-generator` package is intended
+to be used. It consists of a [Python program](mock_to_qir.py) that uses a
+"mini-compiler" for a made up [Mock language](mock_language) to parse a program
+and then walks the created syntax tree to compile it into QIR. For simplicity,
+we used [ANTLR](https://www.antlr.org/) to generate the parser based on the
+defined [grammar](mock_language/MockLanguage.g4) and omitted any further
+compilation or optimization. Before running the example, please install the
+ANTLR runtime:
+
+```bash
+pip install antlr4-python3-runtime
+```
+
+The example can then be run using python, with the generated QIR being written
+to a text file:
+
+```bash
+python mock_to_qir.py bernstein_vazirani.txt 7 >> bernstein_vazirani_output.txt
+```
+
+## Bell pair
+
+This examples consists of a [single file](bell_pair.py), and does not require
+any additional installation besides the `pyqir-generator` package itself. Please
+be aware that the PyQIR API is not intended to directly express quantum
+applications; its purpose is to be easily usable for *compiler and frontend
+developers* rather than *application developers*.
+
+The example can be run using python, with the generated QIR being written to a
+text file:
+
+```bash
+python bell_pair.py >> bell_pair_output.txt
+```
+
+## Branching on measurement results
+
+PyQIR supports simple branching based on the result of measuring a qubit.
+[if.py](if.py) shows how to use this feature to execute certain gates
+conditionally.
+
+Note that the branch condition must be a single result. Conditions based on
+classically computed booleans are not currently supported. However, conjunctions
+and disjunctions of results can be emulated by chaining multiple branches
+together.
+
+## External functions
+
+[external_functions.py](external_functions.py) shows how to call external
+functions using PyQIR. The functions are declared in the module with the type of
+its parameters and return value, but without an implementation, so it can be
+linked with a separate library at compile time.
+
+Note that it's not currently possible to use the return value of an external
+function in subsequent instructions.
diff --git a/examples/generator/bell_pair.py b/examples/generator/bell_pair.py
index 308a48a6..fe33caec 100644
--- a/examples/generator/bell_pair.py
+++ b/examples/generator/bell_pair.py
@@ -5,11 +5,11 @@
 
 from pyqir.generator import BasicQisBuilder, SimpleModule
 
-# This module creates a Bell pair and returns the result of measuring each
-# qubit.
+# Create the module with two qubits and two results.
 bell = SimpleModule("bell", num_qubits=2, num_results=2)
 qis = BasicQisBuilder(bell.builder)
 
+# Add instructions to the module to create a Bell pair and measure both qubits.
 qis.h(bell.qubits[0])
 qis.cx(bell.qubits[0], bell.qubits[1])
 qis.m(bell.qubits[0], bell.results[0])
diff --git a/examples/generator/external_functions.py b/examples/generator/external_functions.py
new file mode 100644
index 00000000..fcc0bd4f
--- /dev/null
+++ b/examples/generator/external_functions.py
@@ -0,0 +1,26 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+from pyqir.generator import SimpleModule, types
+
+mod = SimpleModule("external_functions", num_qubits=1, num_results=0)
+
+# Declare an externally linked function named that has no parameters and returns
+# void.
+my_function = mod.add_external_function(
+    "my_function", types.Function([], types.VOID)
+)
+
+# Call the function with no arguments (the empty list).
+mod.builder.call(my_function, [])
+
+# Declare another function that takes an integer and a qubit and returns void.
+my_gate = mod.add_external_function(
+    "my_gate", types.Function([types.INT, types.QUBIT], types.VOID)
+)
+
+# Call the functions with a list of arguments.
+mod.builder.call(my_gate, [123, mod.qubits[0]])
+
+if __name__ == "__main__":
+    print(mod.ir())
diff --git a/examples/generator/if.py b/examples/generator/if.py
new file mode 100644
index 00000000..154f84ca
--- /dev/null
+++ b/examples/generator/if.py
@@ -0,0 +1,40 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+from pyqir.generator import BasicQisBuilder, SimpleModule
+
+mod = SimpleModule("if", num_qubits=2, num_results=2)
+qis = BasicQisBuilder(mod.builder)
+
+# Manually reset a qubit by measuring it and applying the X gate if the result
+# is one.
+qis.h(mod.qubits[0])
+qis.m(mod.qubits[0], mod.results[0])
+qis.if_result(mod.results[0], lambda: qis.x(mod.qubits[0]))
+
+# Branches can be nested, for example, to execute an instruction only if both
+# results are one.
+for i in range(2):
+    qis.h(mod.qubits[i])
+    qis.m(mod.qubits[i], mod.results[i])
+
+
+def x_both():
+    qis.x(mod.qubits[0])
+    qis.x(mod.qubits[1])
+
+
+qis.if_result(mod.results[0], lambda: qis.if_result(mod.results[1], x_both))
+
+# You can also add instructions that will execeute only when the result is zero.
+qis.if_result(mod.results[0], zero=lambda: qis.x(mod.qubits[0]))
+
+# In general, you can provide both the one and zero branches at the same time.
+qis.if_result(
+    mod.results[0],
+    one=lambda: qis.z(mod.qubits[0]),
+    zero=lambda: qis.y(mod.qubits[0]),
+)
+
+if __name__ == "__main__":
+    print(mod.ir())
diff --git a/pyqir-generator/pyqir/generator/__init__.py b/pyqir-generator/pyqir/generator/__init__.py
index 99e01942..598d425c 100644
--- a/pyqir-generator/pyqir/generator/__init__.py
+++ b/pyqir-generator/pyqir/generator/__init__.py
@@ -1,10 +1,23 @@
 # Copyright (c) Microsoft Corporation.
 # Licensed under the MIT License.
 
+"""
+Provides the ability to generate QIR using a Python API.
+
+This package is intended to be used by code automating translation processes
+enabling the conversion in some format to QIR via Python; i.e., this is a low
+level API intended to be used as a bridge to existing Python frameworks enabling
+the generation of QIR rather than directly consumed by an end-user. It is not
+intended to be used as a framework for algorithm and application development.
+"""
+
 from pyqir.generator._native import (
     BasicQisBuilder as BasicQisBuilder,
     Builder as Builder,
+    Function as Function,
     Qubit as Qubit,
     ResultRef as ResultRef,
     SimpleModule as SimpleModule,
 )
+
+from pyqir.generator._values import Value as Value
diff --git a/pyqir-generator/pyqir/generator/_native.pyi b/pyqir-generator/pyqir/generator/_native.pyi
index 3e5b7133..b4ad1594 100644
--- a/pyqir-generator/pyqir/generator/_native.pyi
+++ b/pyqir-generator/pyqir/generator/_native.pyi
@@ -1,7 +1,9 @@
 # Copyright (c) Microsoft Corporation.
 # Licensed under the MIT License.
 
-from typing import Callable, Tuple
+from pyqir.generator import types
+from pyqir.generator._values import Value
+from typing import Callable, Sequence, Tuple
 
 
 class Qubit:
@@ -14,9 +16,22 @@ class ResultRef:
     ...
 
 
+class Function:
+    """A callable value for a module function."""
+    ...
+
+
 class Builder:
     """An instruction builder."""
-    ...
+
+    def call(self, function: Function, args: Sequence[Value]) -> None:
+        """
+        Builds a call instruction.
+
+        :param function: The function to call.
+        :param args: The arguments to the function.
+        """
+        ...
 
 
 class SimpleModule:
@@ -69,6 +84,16 @@ class SimpleModule:
         """Emits the LLVM bitcode for the module as a sequence of bytes."""
         ...
 
+    def add_external_function(self, name: str, ty: types.Function) -> Function:
+        """
+        Adds a declaration for an externally linked function to the module.
+
+        :param name: The name of the function.
+        :param ty: The type of the function.
+        :return: The function value.
+        """
+        ...
+
 
 class BasicQisBuilder:
     """
diff --git a/pyqir-generator/pyqir/generator/_values.py b/pyqir-generator/pyqir/generator/_values.py
new file mode 100644
index 00000000..f597871a
--- /dev/null
+++ b/pyqir-generator/pyqir/generator/_values.py
@@ -0,0 +1,11 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+from pyqir.generator._native import Qubit, ResultRef
+from typing import Union
+
+Value = Union[bool, int, float, Qubit, ResultRef]
+"""
+A QIR or LLVM value, or a Python value that can be automatically converted into
+one.
+"""
diff --git a/pyqir-generator/pyqir/generator/types.py b/pyqir-generator/pyqir/generator/types.py
new file mode 100644
index 00000000..44e6b0e8
--- /dev/null
+++ b/pyqir-generator/pyqir/generator/types.py
@@ -0,0 +1,76 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+"""
+Types from the LLVM type system, as well as additional type aliases and opaque
+types defined by QIR.
+"""
+
+from dataclasses import dataclass
+from enum import Enum, auto
+from typing import Sequence, Union
+
+
+class Void(Enum):
+    """The void type."""
+    _VOID = auto()
+
+
+@dataclass
+class Integer:
+    """The integer type."""
+
+    width: int
+    """The number of bits used to represent the integer."""
+
+
+class Double(Enum):
+    """The 64-bit floating-point type."""
+    _DOUBLE = auto()
+
+
+class Qubit(Enum):
+    """The QIR qubit type, represented by the LLVM type `%Qubit*`."""
+    _QUBIT = auto()
+
+
+class Result(Enum):
+    """The QIR result type, represented by the LLVM type `%Result*`."""
+    _RESULT = auto()
+
+
+Value = Union[Integer, Double, Qubit, Result]
+"""The set of types that can represent a value."""
+
+Return = Union[Void, Value]
+"""The set of types that can be used as the return type of a function."""
+
+
+@dataclass
+class Function:
+    """The type of a function."""
+
+    param_types: Sequence[Value]
+    """The type of each parameter to the function."""
+
+    return_type: Return
+    """The return type of the function."""
+
+
+VOID: Void = Void._VOID
+"""The void type."""
+
+BOOL: Integer = Integer(1)
+"""The QIR `Bool` type, represented by the LLVM type `i1`."""
+
+INT: Integer = Integer(64)
+"""The QIR `Int` type, represented by the LLVM type `i64`."""
+
+DOUBLE: Double = Double._DOUBLE
+"""The 64-bit floating-point type."""
+
+QUBIT: Qubit = Qubit._QUBIT
+"""The QIR qubit type, represented by the LLVM type `%Qubit*`."""
+
+RESULT: Result = Result._RESULT
+"""The QIR result type, represented by the LLVM type `%Result*`."""
diff --git a/pyqir-generator/requirements-dev.txt b/pyqir-generator/requirements-dev.txt
index e5581af7..cc4663dc 100644
--- a/pyqir-generator/requirements-dev.txt
+++ b/pyqir-generator/requirements-dev.txt
@@ -1,3 +1,4 @@
-pytest>=3.5.0
-pip>=21.3
 maturin>=0.10,<0.12
+pip>=21.3
+pytest-subtests>=0.7.0
+pytest>=3.5.0
diff --git a/pyqir-generator/src/python.rs b/pyqir-generator/src/python.rs
index 3894036f..1f8c6d03 100644
--- a/pyqir-generator/src/python.rs
+++ b/pyqir-generator/src/python.rs
@@ -5,102 +5,130 @@ use pyo3::{
     basic::CompareOp,
     exceptions::{PyOSError, PyTypeError},
     prelude::*,
+    types::PySequence,
     PyObjectProtocol,
 };
 use qirlib::generation::{
     emit,
     interop::{
-        ClassicalRegister, Controlled, If, Instruction, Measured, QuantumRegister, Rotated,
-        SemanticModel, Single,
+        Call, ClassicalRegister, Controlled, FunctionType, If, Instruction, Measured,
+        QuantumRegister, ReturnType, Rotated, SemanticModel, Single, Value, ValueType,
     },
 };
 use std::{
-    collections::hash_map::DefaultHasher,
+    collections::{hash_map::DefaultHasher, HashMap},
     hash::{Hash, Hasher},
+    vec,
 };
 
 #[pymodule]
 #[pyo3(name = "_native")]
 fn native_module(_py: Python, m: &PyModule) -> PyResult<()> {
-    m.add_class::<SimpleModule>()?;
     m.add_class::<Qubit>()?;
     m.add_class::<ResultRef>()?;
+    m.add_class::<Function>()?;
     m.add_class::<Builder>()?;
+    m.add_class::<SimpleModule>()?;
     m.add_class::<BasicQisBuilder>()
 }
 
+const TYPES_MODULE_NAME: &str = "pyqir.generator.types";
 const RESULT_NAME: &str = "result";
 const QUBIT_NAME: &str = "qubit";
 
-#[pyclass]
-struct SimpleModule {
-    model: SemanticModel,
-    builder: Py<Builder>,
-}
+struct PyVoidType;
 
-#[pymethods]
-impl SimpleModule {
-    #[new]
-    fn new(py: Python, name: String, num_qubits: u64, num_results: u64) -> PyResult<SimpleModule> {
-        let registers = vec![ClassicalRegister::new(RESULT_NAME.to_string(), num_results)];
+impl<'source> FromPyObject<'source> for PyVoidType {
+    fn extract(ob: &'source PyAny) -> PyResult<Self> {
+        extract_sentinel(TYPES_MODULE_NAME, "Void", ob).map(|()| PyVoidType)
+    }
+}
 
-        let qubits = (0..num_qubits)
-            .map(|i| QuantumRegister::new(QUBIT_NAME.to_string(), i))
-            .collect();
+#[derive(FromPyObject)]
+struct PyIntegerType {
+    width: u32,
+}
 
-        let model = SemanticModel {
-            name,
-            registers,
-            qubits,
-            instructions: Vec::new(),
-            static_alloc: true,
-        };
+struct PyDoubleType;
 
-        let builder = Py::new(py, Builder::new())?;
-        Ok(SimpleModule { model, builder })
+impl<'source> FromPyObject<'source> for PyDoubleType {
+    fn extract(ob: &'source PyAny) -> PyResult<Self> {
+        extract_sentinel(TYPES_MODULE_NAME, "Double", ob).map(|()| PyDoubleType)
     }
+}
 
-    #[getter]
-    fn qubits(&self) -> Vec<Qubit> {
-        self.model
-            .qubits
-            .iter()
-            .map(|q| Qubit { index: q.index })
-            .collect()
+struct PyQubitType;
+
+impl<'source> FromPyObject<'source> for PyQubitType {
+    fn extract(ob: &'source PyAny) -> PyResult<Self> {
+        extract_sentinel(TYPES_MODULE_NAME, "Qubit", ob).map(|()| PyQubitType)
     }
+}
 
-    #[getter]
-    fn results(&self) -> Vec<ResultRef> {
-        let size = self.model.registers.first().unwrap().size;
-        (0..size).map(|index| ResultRef { index }).collect()
+struct PyResultType;
+
+impl<'source> FromPyObject<'source> for PyResultType {
+    fn extract(ob: &'source PyAny) -> PyResult<Self> {
+        extract_sentinel(TYPES_MODULE_NAME, "Result", ob).map(|()| PyResultType)
     }
+}
 
-    #[getter]
-    fn builder(&self) -> Py<Builder> {
-        self.builder.clone()
+fn extract_sentinel(module_name: &str, type_name: &str, ob: &PyAny) -> PyResult<()> {
+    let module: &str = ob.get_type().getattr("__module__")?.extract()?;
+
+    if module == module_name && ob.get_type().name()? == type_name {
+        Ok(())
+    } else {
+        let message = format!("Expected {}.{}.", module_name, type_name);
+        Err(PyErr::new::<PyTypeError, _>(message))
     }
+}
 
-    fn ir(&self, py: Python) -> PyResult<String> {
-        let model = self.model_with_builder_instructions(py);
-        emit::ir(&model).map_err(PyOSError::new_err)
+#[derive(FromPyObject)]
+enum PyValueType {
+    Integer(PyIntegerType),
+    Double(PyDoubleType),
+    Qubit(PyQubitType),
+    Result(PyResultType),
+}
+
+impl From<PyValueType> for ValueType {
+    fn from(ty: PyValueType) -> Self {
+        match ty {
+            PyValueType::Integer(PyIntegerType { width }) => ValueType::Integer { width },
+            PyValueType::Double(PyDoubleType) => ValueType::Double,
+            PyValueType::Qubit(PyQubitType) => ValueType::Qubit,
+            PyValueType::Result(PyResultType) => ValueType::Result,
+        }
     }
+}
 
-    fn bitcode(&self, py: Python) -> PyResult<Vec<u8>> {
-        let model = self.model_with_builder_instructions(py);
-        emit::bitcode(&model).map_err(PyOSError::new_err)
+#[derive(FromPyObject)]
+enum PyReturnType {
+    Void(PyVoidType),
+    Value(PyValueType),
+}
+
+impl From<PyReturnType> for ReturnType {
+    fn from(ty: PyReturnType) -> Self {
+        match ty {
+            PyReturnType::Void(PyVoidType) => ReturnType::Void,
+            PyReturnType::Value(value_type) => ReturnType::Value(value_type.into()),
+        }
     }
 }
 
-impl SimpleModule {
-    fn model_with_builder_instructions(&self, py: Python) -> SemanticModel {
-        let builder = self.builder.as_ref(py).borrow();
+#[derive(FromPyObject)]
+struct PyFunctionType {
+    param_types: Vec<PyValueType>,
+    return_type: PyReturnType,
+}
 
-        match builder.frames[..] {
-            [ref instructions] => SemanticModel {
-                instructions: instructions.clone(),
-                ..self.model.clone()
-            },
-            _ => panic!("Builder does not contain exactly one stack frame."),
+impl From<PyFunctionType> for FunctionType {
+    fn from(ty: PyFunctionType) -> Self {
+        FunctionType {
+            param_types: ty.param_types.into_iter().map(Into::into).collect(),
+            return_type: ty.return_type.into(),
         }
     }
 }
@@ -169,15 +197,142 @@ impl PyObjectProtocol for ResultRef {
     }
 }
 
+#[derive(Clone)]
+#[pyclass]
+struct Function {
+    name: String,
+}
+
 #[pyclass]
 struct Builder {
     frames: Vec<Vec<Instruction>>,
+    external_functions: HashMap<String, FunctionType>,
 }
 
+#[pymethods]
 impl Builder {
+    #[new]
     fn new() -> Builder {
         Builder {
             frames: vec![vec![]],
+            external_functions: HashMap::new(),
+        }
+    }
+
+    fn call(&mut self, function: Function, args: &PySequence) -> PyResult<()> {
+        let name = function.name;
+        let ty = self.external_functions.get(&name).unwrap();
+
+        let args = args
+            .iter()?
+            .zip(ty.param_types.iter())
+            .map(|(arg, &ty)| match ty {
+                ValueType::Integer { width } => Ok(Value::Integer {
+                    width,
+                    value: arg?.extract()?,
+                }),
+                ValueType::Double => Ok(Value::Double(arg?.extract()?)),
+                ValueType::Qubit => Ok(Value::Qubit(arg?.extract::<Qubit>()?.id())),
+                ValueType::Result => Ok(Value::Result(arg?.extract::<ResultRef>()?.id())),
+            })
+            .collect::<PyResult<_>>()?;
+
+        self.push_inst(Instruction::Call(Call { name, args }));
+        Ok(())
+    }
+}
+
+impl Builder {
+    fn push_inst(&mut self, inst: Instruction) {
+        self.frames.last_mut().unwrap().push(inst);
+    }
+
+    fn push_frame(&mut self) {
+        self.frames.push(vec![]);
+    }
+
+    fn pop_frame(&mut self) -> Option<Vec<Instruction>> {
+        self.frames.pop()
+    }
+}
+
+#[pyclass]
+struct SimpleModule {
+    model: SemanticModel,
+    builder: Py<Builder>,
+}
+
+#[pymethods]
+impl SimpleModule {
+    #[new]
+    fn new(py: Python, name: String, num_qubits: u64, num_results: u64) -> PyResult<SimpleModule> {
+        let registers = vec![ClassicalRegister::new(RESULT_NAME.to_string(), num_results)];
+
+        let qubits = (0..num_qubits)
+            .map(|i| QuantumRegister::new(QUBIT_NAME.to_string(), i))
+            .collect();
+
+        let model = SemanticModel {
+            name,
+            external_functions: HashMap::new(),
+            registers,
+            qubits,
+            instructions: Vec::new(),
+            static_alloc: true,
+        };
+
+        let builder = Py::new(py, Builder::new())?;
+        Ok(SimpleModule { model, builder })
+    }
+
+    #[getter]
+    fn qubits(&self) -> Vec<Qubit> {
+        self.model
+            .qubits
+            .iter()
+            .map(|q| Qubit { index: q.index })
+            .collect()
+    }
+
+    #[getter]
+    fn results(&self) -> Vec<ResultRef> {
+        let size = self.model.registers.first().unwrap().size;
+        (0..size).map(|index| ResultRef { index }).collect()
+    }
+
+    #[getter]
+    fn builder(&self) -> Py<Builder> {
+        self.builder.clone()
+    }
+
+    fn ir(&self, py: Python) -> PyResult<String> {
+        let model = self.model_from_builder(py);
+        emit::ir(&model).map_err(PyOSError::new_err)
+    }
+
+    fn bitcode(&self, py: Python) -> PyResult<Vec<u8>> {
+        let model = self.model_from_builder(py);
+        emit::bitcode(&model).map_err(PyOSError::new_err)
+    }
+
+    fn add_external_function(&mut self, py: Python, name: String, ty: PyFunctionType) -> Function {
+        let mut builder = self.builder.as_ref(py).borrow_mut();
+        builder.external_functions.insert(name.clone(), ty.into());
+        Function { name }
+    }
+}
+
+impl SimpleModule {
+    fn model_from_builder(&self, py: Python) -> SemanticModel {
+        let builder = self.builder.as_ref(py).borrow();
+
+        match builder.frames[..] {
+            [ref instructions] => SemanticModel {
+                instructions: instructions.clone(),
+                external_functions: builder.external_functions.clone(),
+                ..self.model.clone()
+            },
+            _ => panic!("Builder does not contain exactly one stack frame."),
         }
     }
 }
@@ -299,16 +454,16 @@ impl BasicQisBuilder {
 impl BasicQisBuilder {
     fn push_inst(&self, py: Python, inst: Instruction) {
         let mut builder = self.builder.as_ref(py).borrow_mut();
-        builder.frames.last_mut().unwrap().push(inst);
+        builder.push_inst(inst);
     }
 
     fn push_frame(&self, py: Python) {
         let mut builder = self.builder.as_ref(py).borrow_mut();
-        builder.frames.push(vec![]);
+        builder.push_frame();
     }
 
     fn pop_frame(&self, py: Python) -> Option<Vec<Instruction>> {
         let mut builder = self.builder.as_ref(py).borrow_mut();
-        builder.frames.pop()
+        builder.pop_frame()
     }
 }
diff --git a/qirlib/src/codegen/basicvalues.rs b/qirlib/src/codegen/basicvalues.rs
index ce7718a1..9c4214e4 100644
--- a/qirlib/src/codegen/basicvalues.rs
+++ b/qirlib/src/codegen/basicvalues.rs
@@ -1,9 +1,8 @@
 // Copyright (c) Microsoft Corporation.
 // Licensed under the MIT License.
 
-use inkwell::values::{BasicMetadataValueEnum, BasicValue};
-
 use super::types::{double, int32, int64, int8};
+use inkwell::values::{BasicMetadataValueEnum, BasicValue};
 
 #[must_use]
 pub(crate) fn i8_null_ptr(context: &'_ inkwell::context::Context) -> BasicMetadataValueEnum<'_> {
diff --git a/qirlib/src/generation/emit.rs b/qirlib/src/generation/emit.rs
index 1fec9d50..bdc5703f 100644
--- a/qirlib/src/generation/emit.rs
+++ b/qirlib/src/generation/emit.rs
@@ -1,15 +1,23 @@
 // Copyright (c) Microsoft Corporation.
 // Licensed under the MIT License.
 
-use crate::generation::{interop::SemanticModel, qir};
-use crate::{codegen::CodeGenerator, passes::run_basic_passes_on};
+use crate::{
+    codegen::CodeGenerator,
+    generation::{
+        interop::{self, ReturnType, SemanticModel, ValueType},
+        qir,
+    },
+    passes::run_basic_passes_on,
+};
 use inkwell::{
     attributes::AttributeLoc,
     context::Context,
+    module::Linkage,
+    types::{BasicType, BasicTypeEnum, FunctionType},
     values::{BasicValueEnum, FunctionValue, PointerValue},
     AddressSpace,
 };
-use std::collections::HashMap;
+use std::{collections::HashMap, convert::Into};
 
 /// # Errors
 ///
@@ -46,10 +54,8 @@ pub fn populate_context<'a>(
     Ok(generator)
 }
 
-fn build_entry_function(
-    generator: &CodeGenerator<'_>,
-    model: &SemanticModel,
-) -> Result<(), String> {
+fn build_entry_function(generator: &CodeGenerator, model: &SemanticModel) -> Result<(), String> {
+    add_external_functions(generator, model.external_functions.iter());
     let entry_point = qir::create_entry_point(generator.context, &generator.module);
 
     if model.static_alloc {
@@ -64,9 +70,7 @@ fn build_entry_function(
     generator.builder.position_at_end(entry);
 
     let qubits = write_qubits(model, generator);
-
     let mut registers = write_registers(model);
-
     write_instructions(model, generator, &qubits, &mut registers, entry_point);
 
     if !model.static_alloc {
@@ -74,10 +78,53 @@ fn build_entry_function(
     }
 
     generator.builder.build_return(None);
-
     generator.module.verify().map_err(|e| e.to_string())
 }
 
+fn add_external_functions<'a>(
+    generator: &CodeGenerator,
+    functions: impl Iterator<Item = (&'a String, &'a interop::FunctionType)>,
+) {
+    for (name, ty) in functions {
+        let ty = get_function_type(generator, ty);
+        generator
+            .module
+            .add_function(name, ty, Some(Linkage::External));
+    }
+}
+
+fn get_function_type<'ctx>(
+    generator: &CodeGenerator<'ctx>,
+    ty: &interop::FunctionType,
+) -> FunctionType<'ctx> {
+    let param_types: Vec<_> = ty
+        .param_types
+        .iter()
+        .map(|ty| get_basic_type(generator, ty).into())
+        .collect();
+
+    let param_types = param_types.as_slice();
+    match ty.return_type {
+        ReturnType::Void => generator.context.void_type().fn_type(param_types, false),
+        ReturnType::Value(ty) => get_basic_type(generator, &ty).fn_type(param_types, false),
+    }
+}
+
+fn get_basic_type<'ctx>(generator: &CodeGenerator<'ctx>, ty: &ValueType) -> BasicTypeEnum<'ctx> {
+    match ty {
+        ValueType::Integer { width } => {
+            BasicTypeEnum::IntType(generator.context.custom_width_int_type(*width))
+        }
+        ValueType::Double => BasicTypeEnum::FloatType(generator.context.f64_type()),
+        ValueType::Qubit => {
+            BasicTypeEnum::PointerType(generator.qubit_type().ptr_type(AddressSpace::Generic))
+        }
+        ValueType::Result => {
+            BasicTypeEnum::PointerType(generator.result_type().ptr_type(AddressSpace::Generic))
+        }
+    }
+}
+
 fn free_qubits<'ctx>(
     generator: &CodeGenerator<'ctx>,
     qubits: &HashMap<String, BasicValueEnum<'ctx>>,
@@ -162,7 +209,7 @@ mod tests {
         },
     };
     use normalize_line_endings::normalized;
-    use std::{env, fs, path::PathBuf};
+    use std::{collections::HashMap, env, fs, path::PathBuf};
 
     const PYQIR_TEST_SAVE_REFERENCES: &str = "PYQIR_TEST_SAVE_REFERENCES";
 
@@ -181,6 +228,7 @@ mod tests {
                 }),
             ],
             static_alloc: true,
+            external_functions: HashMap::new(),
         };
 
         check_or_save_reference_ir(&model)
@@ -201,6 +249,7 @@ mod tests {
                 }),
             ],
             static_alloc: true,
+            external_functions: HashMap::new(),
         };
 
         check_or_save_reference_ir(&model)
@@ -222,6 +271,7 @@ mod tests {
                 Instruction::H(Single::new("q0".to_string())),
             ],
             static_alloc: true,
+            external_functions: HashMap::new(),
         };
 
         check_or_save_reference_ir(&model)
@@ -243,6 +293,7 @@ mod tests {
                 Instruction::H(Single::new("q0".to_string())),
             ],
             static_alloc: true,
+            external_functions: HashMap::new(),
         };
 
         check_or_save_reference_ir(&model)
@@ -264,6 +315,7 @@ mod tests {
                 Instruction::H(Single::new("q0".to_string())),
             ],
             static_alloc: true,
+            external_functions: HashMap::new(),
         };
 
         check_or_save_reference_ir(&model)
@@ -289,6 +341,7 @@ mod tests {
                 }),
             ],
             static_alloc: true,
+            external_functions: HashMap::new(),
         };
 
         check_or_save_reference_ir(&model)
@@ -314,6 +367,7 @@ mod tests {
                 }),
             ],
             static_alloc: true,
+            external_functions: HashMap::new(),
         };
 
         check_or_save_reference_ir(&model)
@@ -339,6 +393,7 @@ mod tests {
                 }),
             ],
             static_alloc: true,
+            external_functions: HashMap::new(),
         };
 
         check_or_save_reference_ir(&model)
@@ -364,6 +419,7 @@ mod tests {
                 }),
             ],
             static_alloc: true,
+            external_functions: HashMap::new(),
         };
 
         check_or_save_reference_ir(&model)
@@ -381,6 +437,7 @@ mod tests {
                 else_insts: vec![Instruction::H(Single::new("q0".to_string()))],
             })],
             static_alloc: true,
+            external_functions: HashMap::new(),
         };
 
         check_or_save_reference_ir(&model)
diff --git a/qirlib/src/generation/interop.rs b/qirlib/src/generation/interop.rs
index b17a259e..3984eca9 100644
--- a/qirlib/src/generation/interop.rs
+++ b/qirlib/src/generation/interop.rs
@@ -1,6 +1,8 @@
 // Copyright (c) Microsoft Corporation.
 // Licensed under the MIT License.
 
+use std::collections::HashMap;
+
 #[derive(Clone, Debug, Default, PartialEq, Eq)]
 pub struct QuantumRegister {
     pub name: String,
@@ -119,16 +121,52 @@ pub enum Instruction {
     Rz(Rotated),
     Reset(Single),
     M(Measured),
+    Call(Call),
     If(If),
 }
 
-#[derive(Clone, Default)]
+#[derive(Clone, Debug, PartialEq)]
+pub struct Call {
+    pub name: String,
+    pub args: Vec<Value>,
+}
+
+#[derive(Clone, Copy)]
+pub enum ValueType {
+    Integer { width: u32 },
+    Double,
+    Qubit,
+    Result,
+}
+
+#[derive(Clone)]
+pub enum ReturnType {
+    Void,
+    Value(ValueType),
+}
+
+#[derive(Clone)]
+pub struct FunctionType {
+    pub param_types: Vec<ValueType>,
+    pub return_type: ReturnType,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Value {
+    Integer { width: u32, value: u64 },
+    Double(f64),
+    Qubit(String),
+    Result(String),
+}
+
+#[derive(Clone)]
 pub struct SemanticModel {
     pub name: String,
     pub registers: Vec<ClassicalRegister>,
     pub qubits: Vec<QuantumRegister>,
     pub instructions: Vec<Instruction>,
     pub static_alloc: bool,
+    pub external_functions: HashMap<String, FunctionType>,
 }
 
 impl SemanticModel {
@@ -140,6 +178,7 @@ impl SemanticModel {
             qubits: vec![],
             instructions: vec![],
             static_alloc: false,
+            external_functions: HashMap::new(),
         }
     }
 
diff --git a/qirlib/src/generation/qir/instructions.rs b/qirlib/src/generation/qir/instructions.rs
index 65b75236..6933013f 100644
--- a/qirlib/src/generation/qir/instructions.rs
+++ b/qirlib/src/generation/qir/instructions.rs
@@ -1,9 +1,13 @@
 // Copyright (c) Microsoft Corporation.
 // Licensed under the MIT License.
 
-use super::result;
-use crate::codegen::CodeGenerator;
-use crate::generation::interop::{If, Instruction};
+use crate::{
+    codegen::CodeGenerator,
+    generation::{
+        interop::{Call, If, Instruction, Value},
+        qir::result,
+    },
+};
 use inkwell::values::{BasicValueEnum, FunctionValue, PointerValue};
 use std::collections::HashMap;
 
@@ -11,31 +15,50 @@ use std::collections::HashMap;
 ///
 /// Panics if the qubit name doesn't exist
 fn get_qubit<'ctx>(
-    name: &str,
     qubits: &HashMap<String, BasicValueEnum<'ctx>>,
+    name: &str,
 ) -> BasicValueEnum<'ctx> {
+    // TODO: Panicking can be unfriendly to Python clients.
+    // See: https://github.com/qir-alliance/pyqir/issues/31
     *qubits
         .get(name)
         .unwrap_or_else(|| panic!("Qubit {} not found.", name))
 }
 
+/// Gets the most recent value of a result name. Defaults to zero if the result has been declared
+/// but not yet measured.
+///
+/// # Panics
+///
+/// Panics if the result name has not been declared.
+fn get_result<'ctx>(
+    generator: &CodeGenerator<'ctx>,
+    results: &HashMap<String, Option<PointerValue<'ctx>>>,
+    name: &str,
+) -> PointerValue<'ctx> {
+    // TODO: Panicking can be unfriendly to Python clients.
+    // See: https://github.com/qir-alliance/pyqir/issues/31
+    results
+        .get(name)
+        .unwrap_or_else(|| panic!("Result {} not found.", name))
+        .unwrap_or_else(|| result::get_zero(generator))
+}
+
 fn measure<'ctx>(
     generator: &CodeGenerator<'ctx>,
     qubit: &str,
     target: &str,
     qubits: &HashMap<String, BasicValueEnum<'ctx>>,
-    registers: &mut HashMap<String, Option<PointerValue<'ctx>>>,
+    results: &mut HashMap<String, Option<PointerValue<'ctx>>>,
 ) {
-    let find_qubit = |name| get_qubit(name, qubits);
-
     // measure the qubit and save the result to a temporary value
     let new_value = generator.emit_call_with_return(
         generator.qis_m_body(),
-        &[find_qubit(qubit).into()],
+        &[get_qubit(qubits, qubit).into()],
         target,
     );
 
-    registers.insert(target.to_owned(), Some(new_value.into_pointer_value()));
+    results.insert(target.to_owned(), Some(new_value.into_pointer_value()));
 }
 
 fn controlled<'ctx>(
@@ -51,39 +74,37 @@ pub(crate) fn emit<'ctx>(
     generator: &CodeGenerator<'ctx>,
     inst: &Instruction,
     qubits: &HashMap<String, BasicValueEnum<'ctx>>,
-    registers: &mut HashMap<String, Option<PointerValue<'ctx>>>,
+    results: &mut HashMap<String, Option<PointerValue<'ctx>>>,
     entry_point: FunctionValue,
 ) {
-    let find_qubit = |name| get_qubit(name, qubits);
+    let get_qubit = |name| get_qubit(qubits, name);
+
     match inst {
         Instruction::Cx(inst) => {
-            let control = find_qubit(&inst.control);
-            let qubit = find_qubit(&inst.target);
+            let control = get_qubit(&inst.control);
+            let qubit = get_qubit(&inst.target);
             controlled(generator, generator.qis_cnot_body(), control, qubit);
         }
         Instruction::Cz(inst) => {
-            let control = find_qubit(&inst.control);
-            let qubit = find_qubit(&inst.target);
+            let control = get_qubit(&inst.control);
+            let qubit = get_qubit(&inst.target);
             controlled(generator, generator.qis_cz_body(), control, qubit);
         }
         Instruction::H(inst) => {
-            generator.emit_void_call(generator.qis_h_body(), &[find_qubit(&inst.qubit).into()]);
+            generator.emit_void_call(generator.qis_h_body(), &[get_qubit(&inst.qubit).into()]);
         }
         Instruction::M(inst) => {
-            measure(generator, &inst.qubit, &inst.target, qubits, registers);
+            measure(generator, &inst.qubit, &inst.target, qubits, results);
         }
         Instruction::Reset(inst) => {
-            generator.emit_void_call(
-                generator.qis_reset_body(),
-                &[find_qubit(&inst.qubit).into()],
-            );
+            generator.emit_void_call(generator.qis_reset_body(), &[get_qubit(&inst.qubit).into()]);
         }
         Instruction::Rx(inst) => {
             generator.emit_void_call(
                 generator.qis_rx_body(),
                 &[
                     generator.f64_to_f64(inst.theta),
-                    find_qubit(&inst.qubit).into(),
+                    get_qubit(&inst.qubit).into(),
                 ],
             );
         }
@@ -92,7 +113,7 @@ pub(crate) fn emit<'ctx>(
                 generator.qis_ry_body(),
                 &[
                     generator.f64_to_f64(inst.theta),
-                    find_qubit(&inst.qubit).into(),
+                    get_qubit(&inst.qubit).into(),
                 ],
             );
         }
@@ -101,52 +122,79 @@ pub(crate) fn emit<'ctx>(
                 generator.qis_rz_body(),
                 &[
                     generator.f64_to_f64(inst.theta),
-                    find_qubit(&inst.qubit).into(),
+                    get_qubit(&inst.qubit).into(),
                 ],
             );
         }
         Instruction::S(inst) => {
-            generator.emit_void_call(generator.qis_s_body(), &[find_qubit(&inst.qubit).into()]);
+            generator.emit_void_call(generator.qis_s_body(), &[get_qubit(&inst.qubit).into()]);
         }
         Instruction::SAdj(inst) => {
-            generator.emit_void_call(generator.qis_s_adj(), &[find_qubit(&inst.qubit).into()]);
+            generator.emit_void_call(generator.qis_s_adj(), &[get_qubit(&inst.qubit).into()]);
         }
         Instruction::T(inst) => {
-            generator.emit_void_call(generator.qis_t_body(), &[find_qubit(&inst.qubit).into()]);
+            generator.emit_void_call(generator.qis_t_body(), &[get_qubit(&inst.qubit).into()]);
         }
         Instruction::TAdj(inst) => {
-            generator.emit_void_call(generator.qis_t_adj(), &[find_qubit(&inst.qubit).into()]);
+            generator.emit_void_call(generator.qis_t_adj(), &[get_qubit(&inst.qubit).into()]);
         }
         Instruction::X(inst) => {
-            generator.emit_void_call(generator.qis_x_body(), &[find_qubit(&inst.qubit).into()]);
+            generator.emit_void_call(generator.qis_x_body(), &[get_qubit(&inst.qubit).into()]);
         }
         Instruction::Y(inst) => {
-            generator.emit_void_call(generator.qis_y_body(), &[find_qubit(&inst.qubit).into()]);
+            generator.emit_void_call(generator.qis_y_body(), &[get_qubit(&inst.qubit).into()]);
         }
         Instruction::Z(inst) => {
-            generator.emit_void_call(generator.qis_z_body(), &[find_qubit(&inst.qubit).into()]);
+            generator.emit_void_call(generator.qis_z_body(), &[get_qubit(&inst.qubit).into()]);
         }
-        Instruction::If(if_inst) => emit_if(generator, registers, qubits, entry_point, if_inst),
+        Instruction::Call(call) => emit_call(generator, qubits, results, call),
+        Instruction::If(if_) => emit_if(generator, qubits, results, entry_point, if_),
     }
 }
 
+fn emit_call<'ctx>(
+    generator: &CodeGenerator<'ctx>,
+    qubits: &HashMap<String, BasicValueEnum<'ctx>>,
+    results: &HashMap<String, Option<PointerValue<'ctx>>>,
+    call: &Call,
+) {
+    let args: Vec<_> = call
+        .args
+        .iter()
+        .map(|value| match value {
+            Value::Integer { width, value } => generator
+                .context
+                .custom_width_int_type(*width)
+                .const_int(*value, false)
+                .into(),
+            Value::Double(value) => generator.f64_to_f64(*value),
+            Value::Qubit(name) => get_qubit(qubits, name).into(),
+            Value::Result(name) => get_result(generator, results, name).into(),
+        })
+        .collect();
+
+    // TODO: Panicking can be unfriendly to Python clients.
+    // See: https://github.com/qir-alliance/pyqir/issues/31
+    let function = generator
+        .module
+        .get_function(&call.name)
+        .unwrap_or_else(|| panic!("Function {} not found.", &call.name));
+
+    generator.emit_void_call(function, args.as_slice());
+}
+
 fn emit_if<'ctx>(
     generator: &CodeGenerator<'ctx>,
-    registers: &mut HashMap<String, Option<PointerValue<'ctx>>>,
     qubits: &HashMap<String, BasicValueEnum<'ctx>>,
+    results: &mut HashMap<String, Option<PointerValue<'ctx>>>,
     entry_point: FunctionValue,
-    if_inst: &If,
+    if_: &If,
 ) {
-    // Panic if an undeclared result name is referenced, and default to zero if the result has been
-    // declared but not yet measured.
-    let result = registers
-        .get(&if_inst.condition)
-        .unwrap_or_else(|| panic!("Result {} not found.", &if_inst.condition))
-        .unwrap_or_else(|| result::get_zero(generator));
-
+    let result = get_result(generator, results, &if_.condition);
     let condition = result::equal(generator, result, result::get_one(generator));
     let then_block = generator.context.append_basic_block(entry_point, "then");
     let else_block = generator.context.append_basic_block(entry_point, "else");
+
     generator
         .builder
         .build_conditional_branch(condition, then_block, else_block);
@@ -157,14 +205,15 @@ fn emit_if<'ctx>(
 
     let mut emit_block = |block, insts| {
         generator.builder.position_at_end(block);
+
         for inst in insts {
-            emit(generator, inst, qubits, registers, entry_point);
+            emit(generator, inst, qubits, results, entry_point);
         }
 
         generator.builder.build_unconditional_branch(continue_block);
     };
 
-    emit_block(then_block, &if_inst.then_insts);
-    emit_block(else_block, &if_inst.else_insts);
+    emit_block(then_block, &if_.then_insts);
+    emit_block(else_block, &if_.else_insts);
     generator.builder.position_at_end(continue_block);
 }


Test patch:
diff --git a/pyqir-generator/tests/test_external_functions.py b/pyqir-generator/tests/test_external_functions.py
new file mode 100644
index 00000000..14d018f5
--- /dev/null
+++ b/pyqir-generator/tests/test_external_functions.py
@@ -0,0 +1,116 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+from pyqir.generator import BasicQisBuilder, SimpleModule, types
+import unittest
+
+
+class ExternalFunctionsTest(unittest.TestCase):
+    def test_call_no_params(self) -> None:
+        mod = SimpleModule("test", 0, 0)
+        f = mod.add_external_function(
+            "test_function", types.Function([], types.VOID)
+        )
+        mod.builder.call(f, [])
+        self.assertIn("call void @test_function()", mod.ir())
+
+    def test_call_single_qubit(self) -> None:
+        mod = SimpleModule("test", 1, 0)
+        f = mod.add_external_function(
+            "test_function", types.Function([types.QUBIT], types.VOID)
+        )
+        mod.builder.call(f, [mod.qubits[0]])
+        self.assertIn("call void @test_function(%Qubit* null)", mod.ir())
+
+    def test_call_two_qubits(self) -> None:
+        mod = SimpleModule("test", 2, 0)
+        f = mod.add_external_function(
+            "test_function",
+            types.Function([types.QUBIT, types.QUBIT], types.VOID)
+        )
+        mod.builder.call(f, [mod.qubits[0], mod.qubits[1]])
+        self.assertIn(
+            "call void @test_function(%Qubit* null, %Qubit* inttoptr (i64 1 to %Qubit*))",
+            mod.ir(),
+        )
+
+    def test_call_float(self) -> None:
+        mod = SimpleModule("test", 0, 0)
+        f = mod.add_external_function(
+            "test_function", types.Function([types.DOUBLE], types.VOID)
+        )
+        mod.builder.call(f, [23.25])
+        self.assertIn(
+            "call void @test_function(double 2.325000e+01)", mod.ir()
+        )
+
+    def test_call_int(self) -> None:
+        mod = SimpleModule("test", 0, 0)
+        f = mod.add_external_function(
+            "test_function", types.Function([types.INT], types.VOID)
+        )
+        mod.builder.call(f, [42])
+        self.assertIn("call void @test_function(i64 42)", mod.ir())
+
+    def test_call_bool_true(self) -> None:
+        mod = SimpleModule("test", 0, 0)
+        f = mod.add_external_function(
+            "test_function", types.Function([types.BOOL], types.VOID)
+        )
+        mod.builder.call(f, [True])
+        self.assertIn("call void @test_function(i1 true)", mod.ir())
+
+    def test_call_bool_false(self) -> None:
+        mod = SimpleModule("test", 0, 0)
+        f = mod.add_external_function(
+            "test_function", types.Function([types.BOOL], types.VOID)
+        )
+        mod.builder.call(f, [False])
+        self.assertIn("call void @test_function(i1 false)", mod.ir())
+
+    def test_call_single_result(self) -> None:
+        mod = SimpleModule("test", 1, 1)
+        qis = BasicQisBuilder(mod.builder)
+        qis.m(mod.qubits[0], mod.results[0])
+
+        f = mod.add_external_function(
+            "test_function", types.Function([types.RESULT], types.VOID)
+        )
+        mod.builder.call(f, [mod.results[0]])
+        self.assertIn("call void @test_function(%Result* %result0)", mod.ir())
+
+    def test_call_two_results(self) -> None:
+        mod = SimpleModule("test", 1, 2)
+        qis = BasicQisBuilder(mod.builder)
+        qis.m(mod.qubits[0], mod.results[0])
+        qis.m(mod.qubits[0], mod.results[1])
+
+        f = mod.add_external_function(
+            "test_function",
+            types.Function([types.RESULT, types.RESULT], types.VOID)
+        )
+        mod.builder.call(f, [mod.results[0], mod.results[1]])
+
+        self.assertIn(
+            "call void @test_function(%Result* %result0, %Result* %result1)",
+            mod.ir(),
+        )
+
+    def test_call_numbers(self) -> None:
+        b = True
+        bool_rep = f"i1 true"
+        i = 42
+        int_rep = f"i64 42"
+        d = 42.42
+        double_rep = "double 4.242000e+01"
+
+        mod = SimpleModule("test", 0, 0)
+        f = mod.add_external_function("test_function", types.Function(
+            [types.BOOL, types.INT, types.DOUBLE], types.VOID
+        ))
+        mod.builder.call(f, [b, i, d])
+
+        self.assertIn(
+            f"call void @test_function({bool_rep}, {int_rep}, {double_rep})",
+            mod.ir(),
+        )
diff --git a/pyqir-generator/tests/test_qis.py b/pyqir-generator/tests/test_qis.py
new file mode 100644
index 00000000..8b192361
--- /dev/null
+++ b/pyqir-generator/tests/test_qis.py
@@ -0,0 +1,76 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+from pyqir.generator import BasicQisBuilder, SimpleModule
+import unittest
+
+
+class QisTest(unittest.TestCase):
+    def test_single(self) -> None:
+        cases = [
+            ("h", lambda qis: qis.h),
+            ("reset", lambda qis: qis.reset),
+            ("s", lambda qis: qis.s),
+            ("t", lambda qis: qis.t),
+            ("x", lambda qis: qis.x),
+            ("y", lambda qis: qis.y),
+            ("z", lambda qis: qis.z),
+        ]
+
+        for name, gate in cases:
+            with self.subTest(name):
+                mod = SimpleModule("test_single", 1, 0)
+                qis = BasicQisBuilder(mod.builder)
+                gate(qis)(mod.qubits[0])
+                call = f"call void @__quantum__qis__{name}__body(%Qubit* null)"
+                self.assertIn(call, mod.ir())
+
+    def test_controlled(self) -> None:
+        cases = [
+            ("cnot", lambda qis: qis.cx),
+            ("cz", lambda qis: qis.cz),
+        ]
+
+        for name, gate in cases:
+            with self.subTest(name):
+                mod = SimpleModule("test_controlled", 2, 0)
+                qis = BasicQisBuilder(mod.builder)
+                gate(qis)(mod.qubits[0], mod.qubits[1])
+                call = f"call void @__quantum__qis__{name}__body(%Qubit* null, %Qubit* inttoptr (i64 1 to %Qubit*))"
+                self.assertIn(call, mod.ir())
+
+    def test_adjoint(self) -> None:
+        cases = [
+            ("s", lambda qis: qis.s_adj),
+            ("t", lambda qis: qis.t_adj),
+        ]
+
+        for name, gate in cases:
+            with self.subTest(name):
+                mod = SimpleModule("test_adjoint", 1, 0)
+                qis = BasicQisBuilder(mod.builder)
+                gate(qis)(mod.qubits[0])
+                call = f"call void @__quantum__qis__{name}__adj(%Qubit* null)"
+                self.assertIn(call, mod.ir())
+
+    def test_rotated(self) -> None:
+        cases = [
+            ("rx", lambda qis: qis.rx),
+            ("ry", lambda qis: qis.ry),
+            ("rz", lambda qis: qis.rz),
+        ]
+
+        for name, gate in cases:
+            with self.subTest(name):
+                mod = SimpleModule("test_rotated", 1, 0)
+                qis = BasicQisBuilder(mod.builder)
+                gate(qis)(0.0, mod.qubits[0])
+                call = f"call void @__quantum__qis__{name}__body(double 0.000000e+00, %Qubit* null)"
+                self.assertIn(call, mod.ir())
+
+    def test_m(self) -> None:
+        mod = SimpleModule("test_m", 1, 1)
+        qis = BasicQisBuilder(mod.builder)
+        qis.m(mod.qubits[0], mod.results[0])
+        call = f"call %Result* @__quantum__qis__m__body(%Qubit* null)"
+        self.assertIn(call, mod.ir())
diff --git a/pyqir-tests/tests/test_if.py b/pyqir-tests/tests/test_if.py
index 8b2d6b12..29b12629 100644
--- a/pyqir-tests/tests/test_if.py
+++ b/pyqir-tests/tests/test_if.py
@@ -8,7 +8,7 @@
 import unittest
 
 
-class IfTestCase(unittest.TestCase):
+class IfTest(unittest.TestCase):
     def test_one_block_executes_on_one(self) -> None:
         module = SimpleModule("test_if", num_qubits=1, num_results=1)
         qis = BasicQisBuilder(module.builder)
@@ -193,7 +193,3 @@ def _eval(module: SimpleModule,
         f.write(module.ir().encode("utf-8"))
         f.flush()
         NonadaptiveEvaluator().eval(f.name, gates, None, result_stream)
-
-
-if __name__ == "__main__":
-    unittest.main()
diff --git a/pyqir-tests/tests/test_teleport.py b/pyqir-tests/tests/test_teleport.py
index c110fea5..5953fdcd 100644
--- a/pyqir-tests/tests/test_teleport.py
+++ b/pyqir-tests/tests/test_teleport.py
@@ -1,9 +1,8 @@
 # Copyright (c) Microsoft Corporation.
 # Licensed under the MIT License.
 
-from pyqir.generator import BasicQisBuilder, Qubit, ResultRef, SimpleModule
 from pyqir.evaluator import GateLogger, GateSet, NonadaptiveEvaluator
-
+from pyqir.generator import BasicQisBuilder, Qubit, ResultRef, SimpleModule
 import tempfile
 from typing import List, Optional
 import unittest
@@ -33,7 +32,7 @@ def teleport(qis: BasicQisBuilder, qubits: List[Qubit], results: List[ResultRef]
     qis.if_result(results[1], one=lambda: qis.x(target))
 
 
-class TeleportTestCase(unittest.TestCase):
+class TeleportTest(unittest.TestCase):
     def test_teleport_measures_zero_zero(self) -> None:
         module = SimpleModule("teleport00", num_qubits=3, num_results=2)
         qis = BasicQisBuilder(module.builder)
@@ -130,7 +129,3 @@ def _eval(module: SimpleModule,
         f.write(module.ir().encode("utf-8"))
         f.flush()
         NonadaptiveEvaluator().eval(f.name, gates, None, result_stream)
-
-
-if __name__ == "__main__":
-    unittest.main()