Repository: rigetti/quil-rs (language: python)

Issue:
Add Python bindings for Waveform templates
[Waveform templates](https://github.com/rigetti/quil-rs/pull/369) exist in the Rust library, but not the Python package. 


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/msrv.yml b/.github/workflows/msrv.yml
index effc84d9..299c3d95 100644
--- a/.github/workflows/msrv.yml
+++ b/.github/workflows/msrv.yml
@@ -1,6 +1,6 @@
 # Based on https://github.com/actions-rs/meta/blob/master/recipes/msrv.md
 
-on: 
+on:
   push:
     branches: [main]
   pull_request:
@@ -66,7 +66,7 @@ jobs:
         override: true
     - uses: davidB/rust-cargo-make@v1
     - uses: actions/checkout@v1
-    - uses: snok/install-poetry@v1
+    - uses: syphar/restore-virtualenv@v1
     - name: Run quil-py tests, lints, and formatting checks.
       run: cargo make --cwd quil-py
 
diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml
index 989f9418..a5b3b68c 100644
--- a/.github/workflows/publish-docs.yml
+++ b/.github/workflows/publish-docs.yml
@@ -19,14 +19,13 @@ jobs:
       - uses: actions/setup-python@v4
         with:
           python-version: '3.11'
-      - name: Install poetry
-        uses: snok/install-poetry@v1
       - uses: Swatinem/rust-cache@v2
       - name: Install cargo-make
         uses: actions-rs/cargo@v1
         with:
           command: install
           args: --debug cargo-make
+      - uses: syphar/restore-virtualenv@v1
       - name: Build quil-py documentation
         uses: actions-rs/cargo@v1
         with:
diff --git a/Cargo.lock b/Cargo.lock
index c655ffaf..31630cd0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -831,9 +831,9 @@ dependencies = [
 
 [[package]]
 name = "num-complex"
-version = "0.4.3"
+version = "0.4.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02e0d21255c828d6f128a1e41534206671e8c3ea0c62f32291e808dc82cff17d"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
 dependencies = [
  "num-traits",
 ]
@@ -867,9 +867,9 @@ dependencies = [
 
 [[package]]
 name = "num-traits"
-version = "0.2.16"
+version = "0.2.19"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
 dependencies = [
  "autocfg",
  "libm",
@@ -1132,7 +1132,7 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
 
 [[package]]
 name = "quil-cli"
-version = "0.5.1-rc.0"
+version = "0.5.1"
 dependencies = [
  "anyhow",
  "clap",
@@ -1141,10 +1141,11 @@ dependencies = [
 
 [[package]]
 name = "quil-py"
-version = "0.12.1-rc.0"
+version = "0.12.1"
 dependencies = [
  "indexmap",
  "ndarray",
+ "num-complex",
  "numpy",
  "pyo3",
  "pyo3-build-config",
@@ -1155,7 +1156,7 @@ dependencies = [
 
 [[package]]
 name = "quil-rs"
-version = "0.28.1-rc.0"
+version = "0.28.1"
 dependencies = [
  "approx",
  "clap",
diff --git a/quil-py/Cargo.toml b/quil-py/Cargo.toml
index 5b3426d2..46de9363 100644
--- a/quil-py/Cargo.toml
+++ b/quil-py/Cargo.toml
@@ -29,6 +29,7 @@ numpy = "0.20.0"
 pyo3 = { version = "0.20.3", features = ["indexmap"] }
 rigetti-pyo3 = {version = "0.3.4", features = ["indexmap"]}
 indexmap.workspace = true
+num-complex = "0.4.6"
 
 [build-dependencies]
 pyo3-build-config = "0.20.0"
diff --git a/quil-py/Makefile.toml b/quil-py/Makefile.toml
index a3727ac5..58dc55e7 100644
--- a/quil-py/Makefile.toml
+++ b/quil-py/Makefile.toml
@@ -1,65 +1,79 @@
 [env]
+PYTHONPATH = { script = [ "python -c 'from distutils.sysconfig import get_python_lib; print(get_python_lib())'" ] }
 RUST_BACKTRACE = 0
 
-[tasks.poetry-install]
-command = "poetry"
-args = ["install"]
-
-[tasks.install-quil]
-command = "poetry"
-args = ["run", "maturin", "develop"]
-
-[tasks.stubtest]
-dependencies = ["poetry-install"]
-command = "poetry"
-args = ["run", "stubtest", "--allowlist", ".stubtest-allowlist", "quil"]
-
-[tasks.stubtest-flow]
-dependencies = [
-    "poetry-install",
-    "install-quil",
-    "stubtest",
+[tasks.check-venv]
+description = "Check if a virtual environment is activated"
+script = [
+    '''
+    if [ -z "$VIRTUAL_ENV" ]; then
+        echo "No virtual environment activated. Please activate one."
+        exit 1
+    else
+        echo "Virtual environment is active."
+    fi
+    '''
 ]
 
-[tasks.format]
-dependencies = ["poetry-install"]
-command = "poetry"
-args = ["run", "ruff", "format"]
+[tasks.install-uv]
+dependencies = ["check-venv"]
+description = "Install dependencies using uv"
+command = "pip"
+args = ["install", "uv"]
 
-[tasks.check-format]
-dependencies = ["poetry-install"]
-command = "poetry"
-args = ["run", "ruff", "format", "--check"]
+[tasks.install-deps]
+dependencies = ["install-uv"]
+description = "Install project dependencies using uv."
+script = [
+    '''
+    uv pip compile pyproject.toml --all-extras > requirements-dev.txt
+    uv pip install -r requirements-dev.txt
+    rm requirements-dev.txt
+    '''
+]
 
-[tasks.lint]
-dependencies = ["poetry-install"]
-command = "poetry"
-args = ["run", "ruff", "check"]
+[tasks.install-python-package]
+dependencies = ["check-venv", "install-deps"]
+description = "Build the python package and install to the active virtual environment."
+command = "maturin"
+args = ["develop"]
 
-[tasks.pytest]
-command = "poetry"
-args = ["run", "pytest"]
+[tasks.test]
+dependencies = ["install-python-package"]
+command = "pytest"
+args = ["tests_py"]
 
 [tasks.pytest-flow]
 dependencies = [
-    "poetry-install",
-    "install-quil",
-    "pytest",
+    "install-python-package",
+    "test",
+    "post-test",
 ]
 
-[tasks.docs]
-dependencies = ["poetry-install", "install-quil"]
-command = "poetry"
-args = ["run", "pdoc", "-o", "build/docs", "quil", "!quil.quil", "--logo", "https://qcs.rigetti.com/static/img/rigetti-logo.svg"]
+[tasks.stubtest]
+command = "stubtest"
+args = [
+    "--allowlist",
+    ".stubtest-allowlist",
+    "quil"
+]
 
-[tasks.dev-flow]
+[tasks.stubtest-flow]
 dependencies = [
-    "dev-test-flow",
-    "pytest-flow",
+    "install-python-package",
     "stubtest",
-    "lint",
-    "check-format"
 ]
 
+[tasks.dev-flow]
+dependencies = ["dev-test-flow", "pytest-flow", "stubtest"]
+
 [tasks.default]
 alias = "dev-flow"
+
+[tasks.ci-flow]
+dependencies = ["pytest-flow", "stubtest"]
+
+[tasks.docs]
+dependencies = ["install-python-package"]
+command = "pdoc"
+args = ["-o", "build/docs", "quil", "!quil.quil", "--logo", "https://qcs.rigetti.com/static/img/rigetti-logo.svg"]
diff --git a/quil-py/pyproject.toml b/quil-py/pyproject.toml
index af3e516b..f75bdabb 100644
--- a/quil-py/pyproject.toml
+++ b/quil-py/pyproject.toml
@@ -1,5 +1,6 @@
 [project]
 name = "quil"
+version = "0.12.1"
 requires-python = ">=3.8"
 description = "A Python package for building and parsing Quil programs."
 documentation = "https://rigetti.github.io/quil-rs/quil.html"
@@ -14,27 +15,7 @@ classifiers = [
   "Programming Language :: Python :: 3.10",
   "Operating System :: OS Independent",
 ]
-
-# PEP 621 specifies the [project] table as the source for project metadata. However, Poetry only supports [tool.poetry]
-# We can remove this table once this issue is resolved: https://github.com/python-poetry/poetry/issues/3332
-[tool.poetry]
-name = "quil"
-version = "0.12.1"
-description = "A Python package for building and parsing Quil programs."
-readme = "README-py.md"
-authors = ["Rigetti Computing <softapps@rigetti.com>"]
-
-[tool.poetry.dependencies]
-python = "^3.8"
-numpy = "^1.21"
-
-[tool.poetry.group.dev.dependencies]
-ruff = "^0.3.7"
-maturin = "^1.2.3"
-mypy = "^1.1.1"
-pytest = "^7.2.2"
-pdoc = "^14.1.0"
-syrupy = "^3.0.6"
+dependencies = ["numpy>=1.2.1"]
 
 [tool.maturin]
 features = ["pyo3/extension-module"]
@@ -42,6 +23,16 @@ bindings = "pyo3"
 compatibility = "linux"
 sdist-include = ["README.md"]
 
+[project.optional-dependencies]
+dev = [
+    "ruff>=0.3.7",
+    "maturin>=1.2.3",
+    "mypy>=1.1.1",
+    "pytest>=7.2.2",
+    "pdoc>=14.1.0",
+    "syrupy>=3.0.6"
+]
+
 [build-system]
 requires = ["maturin>=1.0.0,<2.0.0"]
 build-backend = "maturin"
@@ -83,7 +74,7 @@ target-version = "py38"
 select = ["D", "E4", "E7", "E9", "F", "I", "B", "S", "W"]
 ignore = [
     "E741" # "Ambiguous" variable names like "I" aren't ambiguous in this contex.
-] 
+]
 fixable = ["ALL"]
 unfixable = []
 # Allow unused variables when underscore-prefixed.
@@ -93,8 +84,8 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
 "quil/**/*.py" = [
     "F403", # * imports allowed in extension module glue.
     "D100", # docstrings belong in type stubs
-    "D104", 
-] 
+    "D104",
+]
 "test/**/*.py" = [
     "D",    # docstrings are not enforced in tests
     "S101", # asserts are allowed in tests
@@ -116,6 +107,9 @@ convention = "google"
 # necessarily the case for a pure Rust pyo3 module, so disabling it.
 reportMissingModuleSource = false
 
+[tool.mypy]
+plugins = "numpy.typing.mypy_plugin"
+
 [[tool.mypy.overrides]]
 module = [
     "quil.quil",
diff --git a/quil-py/quil/instructions/__init__.pyi b/quil-py/quil/instructions/__init__.pyi
index 545472d1..5b6e9a22 100644
--- a/quil-py/quil/instructions/__init__.pyi
+++ b/quil-py/quil/instructions/__init__.pyi
@@ -1549,7 +1549,7 @@ class Gate:
         equal the number of existing parameters.
         """
         ...
-    def to_unitary_mut(self, n_qubits: int) -> NDArray[np.complex_]:
+    def to_unitary_mut(self, n_qubits: int) -> NDArray[np.complex128]:
         """Lift a Gate to the full `n_qubits`-qubit Hilbert space.
 
         Returns a ``GateError` if any of the parameters of this gate are
diff --git a/quil-py/quil/program/__init__.py b/quil-py/quil/program/__init__.py
index bbf0cda4..8bcd2f2c 100644
--- a/quil-py/quil/program/__init__.py
+++ b/quil-py/quil/program/__init__.py
@@ -1,3 +1,5 @@
-"""The `quil.program` module contains classes for constructing and representing a Quil program."""
+"""The `quil.program` module contains classes for constructing and representing a
+Quil program.
+"""
 
 from quil.program import *
diff --git a/quil-py/quil/program/__init__.pyi b/quil-py/quil/program/__init__.pyi
index 5478a841..87f83ef8 100644
--- a/quil-py/quil/program/__init__.pyi
+++ b/quil-py/quil/program/__init__.pyi
@@ -92,7 +92,7 @@ class Program:
         Raises a ``ProgramError`` if the given string isn't valid Quil.
         """
     def to_instructions(self) -> Sequence[Instruction]: ...
-    def to_unitary(self, n_qubits: int) -> NDArray[np.complex_]: ...
+    def to_unitary(self, n_qubits: int) -> NDArray[np.complex128]: ...
     def copy(self) -> "Program":
         """Creates a clone of this ``Program``."""
         ...
diff --git a/quil-py/quil/waveforms/__init__.py b/quil-py/quil/waveforms/__init__.py
new file mode 100644
index 00000000..7f99217f
--- /dev/null
+++ b/quil-py/quil/waveforms/__init__.py
@@ -0,0 +1,3 @@
+"""Templates for generating discrete IQ value sequences representing Quil's defined set of waveforms."""
+
+from quil.waveforms import *
diff --git a/quil-py/quil/waveforms/__init__.pyi b/quil-py/quil/waveforms/__init__.pyi
new file mode 100644
index 00000000..57c0b36f
--- /dev/null
+++ b/quil-py/quil/waveforms/__init__.pyi
@@ -0,0 +1,225 @@
+from typing import Iterable
+
+def apply_phase_and_detuning(iq_values: Iterable[complex], phase: float, detuning: float, sample_rate: float):
+    """Modulate and phase shift waveform IQ data in place."""
+    ...
+
+class BoxcarKernel:
+    def __new__(self, phase_cycles: float, scale: float, sample_count: int) -> BoxcarKernel:
+        """Create a new BoxcarKernel."""
+        ...
+
+    @property
+    def phase(self) -> float:
+        """Get the phase, in cycles."""
+
+    @property
+    def scale(self) -> float:
+        """Get the scale value."""
+
+    @property
+    def sample_count(self) -> int:
+        """Get the sample count."""
+
+    def into_iq_value(self) -> complex:
+        """Convert BoxcarKernel into a Complex64 value."""
+
+
+class ErfSquare:
+    """A waveform with a flat top and edges that are error functions (erfs)."""
+    def __new__(self, duration: float, risetime: float, sample_rate: float,
+                 pad_left: float, pad_right: float, positive_polarity: bool,
+                 scale: float, phase: float, detuning: float) -> ErfSquare:
+        ...
+
+    @property
+    def duration(self) -> float:
+        """Full duration of the pulse (s)"""
+
+    @property
+    def risetime(self) -> float:
+        """Slope of erf shoulders (2x FWHM of erf in s)"""
+
+    @property
+    def sample_rate(self) -> float:
+        """Generate wavform samples at this rate (Hz)"""
+
+    @property
+    def pad_left(self) -> float:
+        """Length of zero padding to add to beginning of pulse (s)"""
+
+    @property
+    def pad_right(self) -> float:
+        """Length of zero padding to add to end of pulse (s)"""
+
+    @property
+    def positive_polarity(self) -> bool:
+        """Toggle for positive/negative polarity"""
+
+    @property
+    def scale(self) -> float:
+        """Scale to apply to waveform envelope"""
+
+    @property
+    def phase(self) -> float:
+        """Phase shift for entire waveform"""
+
+    @property
+    def detuning(self) -> float:
+        """Explicit detuning to bake into iq values"""
+
+    def into_iq_values(self) -> list[complex]:
+        """Convert ErfSquare into a list of Complex64 values."""
+
+
+class Gaussian:
+    """A waveform with a Gaussian shape."""
+    def __new__(self, duration: float, fwhm: float, t0: float,
+                 sample_rate: float, scale: float, phase: float,
+                 detuning: float) -> Gaussian:
+        ...
+
+    @property
+    def duration(self) -> float:
+        """Full duration of the pulse (s)"""
+
+    @property
+    def fwhm(self) -> float:
+        """Full width half maximum of the pulse (s)"""
+
+    @property
+    def t0(self) -> float:
+        """Center/offset for pulse centroid (s)"""
+
+    @property
+    def sample_rate(self) -> float:
+        """Generate waveform samples at this rate (Hz)"""
+
+    @property
+    def scale(self) -> float:
+        """Scale to apply to waveform envelope"""
+
+    @property
+    def phase(self) -> float:
+        """Phase shift for entire waveform"""
+
+    @property
+    def detuning(self) -> float:
+        """Explicit detuning to bake into IQ valuesExplicit detuning to bake into IQ values"""
+
+    def into_iq_values(self) -> list[complex]:
+        """Convert Gaussian into a list of Complex64 values."""
+
+
+class DragGaussian:
+    """A waveform with a DRAG-corrected Gaussian shape.                                                             
+                                                                                                                     
+    This is a Gaussian shape with an additional component proportional to the time derivative of the main Gaussian pulse.
+                                                                                                                         
+    See Motzoi F. et al., Phys. Rev. Lett., 103 (2009) 110501. for details.                                              
+    """
+    def __new__(self, duration: float, fwhm: float, t0: float, anh: float,
+                 alpha: float, sample_rate: float, scale: float, phase: float,
+                 detuning: float) -> DragGaussian:
+        ...
+
+    @property
+    def duration(self) -> float:
+        """Full duration of the pulse (s)"""
+
+    @property
+    def fwhm(self) -> float:
+        """Full width half maximum of the pulse (s)"""
+
+    @property
+    def t0(self) -> float:
+        """Center/offset for pulse centroid (s)"""
+
+    @property
+    def anh(self) -> float:
+        """Qubit anharmonicity - sets rate of evolution for the imaginary term (Hz)"""
+
+    @property
+    def alpha(self) -> float:
+        """DRAG parameter - controls strength of the imaginary term"""
+
+    @property
+    def sample_rate(self) -> float:
+        """Generate samples at this rate (Hz)"""
+
+    @property
+    def scale(self) -> float:
+        """Scale to apply to waveform envelope"""
+
+    @property
+    def phase(self) -> float:
+        """Phase shift for entire waveform"""
+
+    @property
+    def detuning(self) -> float:
+        """Explicit detuning to bake into IQ valuesExplicit detuning to bake into IQ values"""
+
+    def into_iq_values(self) -> list[complex]:
+        """Convert DragGaussian into a list of Complex64 values."""
+        ...
+
+
+class HermiteGaussian:
+    """A Hermite Gaussian waveform.
+
+    This extends the basic DRAG pulse by adding an additional imaginary term to the pulse envelope consisting of a
+    Gaussian pulse modified by the second order Hermite polynomial.
+
+    Refer to
+    "Effects of arbitrary laser or NMR pulse shapes on population inversion and coherence"
+    Warren S. Warren. 81, (1984); doi: 10.1063/1.447644
+    for details.
+    """
+    def __new__(self, duration: float, fwhm: float, t0: float, anh: float,
+                 alpha: float, sample_rate: float, second_order_hrm_coeff: float,
+                 scale: float, phase: float, detuning: float) -> HermiteGaussian:
+        ...
+
+    @property
+    def duration(self) -> float:
+        """Full duration of the pulse (s)"""
+
+    @property
+    def fwhm(self) -> float:
+        """Full width half maximum of the pulse (s)"""
+
+    @property
+    def t0(self) -> float:
+        """Center/offset for pulse centroid (s)"""
+
+    @property
+    def anh(self) -> float:
+        """Qubit anharmonicity - sets rate of evolution for the imaginary term (Hz)"""
+
+    @property
+    def alpha(self) -> float:
+        """DRAG parameter - controls strength of the imaginary term"""
+
+    @property
+    def sample_rate(self) -> float:
+        """Generate samples at this rate (Hz)"""
+
+    @property
+    def second_order_hrm_coeff(self) -> float:
+        """Coefficient of the second order Hermite polynomial term."""
+
+    @property
+    def scale(self) -> float:
+        """Scale to apply to waveform envelope"""
+
+    @property
+    def phase(self) -> float:
+        """Phase shift for entire waveform"""
+
+    @property
+    def detuning(self) -> float:
+        """Explicit detuning to bake into IQ valuesExplicit detuning to bake into IQ values"""
+
+    def into_iq_values(self) -> list[complex]:
+        """Convert HermiteGaussian into a list of Complex64 values."""
+        ...
diff --git a/quil-py/src/lib.rs b/quil-py/src/lib.rs
index 7da5258d..91079b74 100644
--- a/quil-py/src/lib.rs
+++ b/quil-py/src/lib.rs
@@ -4,14 +4,17 @@ use rigetti_pyo3::create_init_submodule;
 pub mod expression;
 pub mod instruction;
 pub mod program;
+pub mod units;
 pub mod validation;
+pub mod waveforms;
 
 create_init_submodule! {
     submodules: [
         "expression": expression::init_submodule,
         "instructions": instruction::init_submodule,
         "program": program::init_submodule,
-        "validation": validation::init_submodule
+        "validation": validation::init_submodule,
+        "waveforms": waveforms::init_submodule
     ],
 }
 
diff --git a/quil-py/src/units.rs b/quil-py/src/units.rs
new file mode 100644
index 00000000..f2a059f0
--- /dev/null
+++ b/quil-py/src/units.rs
@@ -0,0 +1,12 @@
+use quil_rs::units::{Cycles, Radians};
+use rigetti_pyo3::py_wrap_type;
+
+py_wrap_type! {
+    #[derive(Debug)]
+    PyCycles(Cycles<f64>) as "Cycles"
+}
+
+py_wrap_type! {
+    #[derive(Debug)]
+    PyRadians(Radians<f64>) as "Radians"
+}
diff --git a/quil-py/src/waveforms.rs b/quil-py/src/waveforms.rs
new file mode 100644
index 00000000..912cfa2c
--- /dev/null
+++ b/quil-py/src/waveforms.rs
@@ -0,0 +1,166 @@
+use num_complex::Complex64;
+use pyo3::{prelude::*, types::PyList};
+use rigetti_pyo3::{create_init_submodule, impl_repr, py_wrap_data_struct, PyWrapper};
+
+use quil_rs::{
+    units::Cycles,
+    waveform::{
+        apply_phase_and_detuning as rs_apply_phase_and_detuning, BoxcarKernel, DragGaussian,
+        ErfSquare, Gaussian, HermiteGaussian,
+    },
+};
+
+use crate::{impl_eq, units::PyCycles};
+
+create_init_submodule! {
+    classes: [PyBoxcarKernel, PyErfSquare, PyGaussian, PyDragGaussian, PyHermiteGaussian],
+    funcs: [apply_phase_and_detuning],
+}
+
+#[pyfunction]
+pub fn apply_phase_and_detuning(
+    iq_values: &PyList,
+    phase: f64,
+    detuning: f64,
+    sample_rate: f64,
+) -> PyResult<()> {
+    // There isn't a way to provide a mutable Vec<Complex64> as an argument to a pyfunction so we
+    // create a new Vec<Complex64> from the PyList, and use the updated Vec to update the PyList.
+    let mut rust_iq_values: Vec<Complex64> = iq_values
+        .iter()
+        .map(|item| {
+            let num: Complex64 = item.extract().unwrap();
+            num
+        })
+        .collect();
+
+    rs_apply_phase_and_detuning(&mut rust_iq_values, phase, detuning, sample_rate);
+
+    for (i, num) in rust_iq_values.iter().enumerate() {
+        iq_values.set_item(i, num)?;
+    }
+
+    Ok(())
+}
+
+py_wrap_data_struct! {
+    #[derive(Debug, PartialEq)]
+    #[pyo3(subclass)]
+    PyBoxcarKernel(BoxcarKernel) as "BoxcarKernel" {
+        phase: Cycles<f64> => PyCycles,
+        scale: f64 => f64,
+        sample_count: u64 => u64
+    }
+}
+impl_repr!(PyBoxcarKernel);
+impl_eq!(PyBoxcarKernel);
+
+#[pymethods]
+impl PyBoxcarKernel {
+    #[new]
+    pub fn new(phase: f64, scale: f64, sample_count: u64) -> Self {
+        PyBoxcarKernel(BoxcarKernel {
+            phase: Cycles(phase),
+            scale,
+            sample_count,
+        })
+    }
+
+    #[pyo3(name = "into_iq_value")]
+    pub fn py_into_iq_value(&self) -> Complex64 {
+        self.as_inner().into_iq_value()
+    }
+
+    /// Override the implementation from `py_wrap_data_struct` so that f64 is returned directly.
+    #[getter]
+    pub fn phase(&self) -> f64 {
+        self.as_inner().phase.0
+    }
+}
+
+macro_rules! impl_into_iq_values {
+    ($name: ident) => {
+        #[pyo3::pymethods]
+        impl $name {
+            #[pyo3(name = "into_iq_values")]
+            pub fn py_into_iq_values(&self) -> Vec<Complex64> {
+                quil_rs::waveform::WaveformTemplate::into_iq_values(self.clone().into_inner())
+            }
+        }
+    };
+}
+
+py_wrap_data_struct! {
+    #[derive(Debug, PartialEq)]
+    #[pyo3(subclass)]
+    PyErfSquare(ErfSquare) as "ErfSquare" {
+        duration: f64 => f64,
+        risetime: f64 => f64,
+        sample_rate: f64 => f64,
+        pad_left: f64 => f64,
+        pad_right: f64 => f64,
+        positive_polarity: bool => bool,
+        scale: f64 => f64,
+        phase: f64 => f64,
+        detuning: f64 => f64
+    }
+}
+impl_into_iq_values!(PyErfSquare);
+impl_repr!(PyErfSquare);
+impl_eq!(PyErfSquare);
+
+py_wrap_data_struct! {
+    #[derive(Debug, PartialEq)]
+    #[pyo3(subclass)]
+    PyGaussian(Gaussian) as "Gaussian" {
+        duration: f64 => f64,
+        fwhm: f64 => f64,
+        t0: f64 => f64,
+        sample_rate: f64 => f64,
+        scale: f64 => f64,
+        phase: f64 => f64,
+        detuning: f64 => f64
+    }
+}
+impl_into_iq_values!(PyGaussian);
+impl_repr!(PyGaussian);
+impl_eq!(PyGaussian);
+
+py_wrap_data_struct! {
+    #[derive(Debug, PartialEq)]
+    #[pyo3(subclass)]
+    PyDragGaussian(DragGaussian) as "DragGaussian" {
+        duration: f64 => f64,
+        fwhm: f64 => f64,
+        t0: f64 => f64,
+        anh: f64 => f64,
+        alpha: f64 => f64,
+        sample_rate: f64 => f64,
+        scale: f64 => f64,
+        phase: f64 => f64,
+        detuning: f64 => f64
+    }
+}
+impl_into_iq_values!(PyDragGaussian);
+impl_repr!(PyDragGaussian);
+impl_eq!(PyDragGaussian);
+
+py_wrap_data_struct! {
+    #[derive(Debug, PartialEq)]
+    #[pyo3(subclass)]
+    PyHermiteGaussian(HermiteGaussian) as "HermiteGaussian" {
+        duration: f64 => f64,
+        fwhm: f64 => f64,
+        t0: f64 => f64,
+        anh: f64 => f64,
+        alpha: f64 => f64,
+        sample_rate: f64 => f64,
+        second_order_hrm_coeff: f64 => f64,
+        scale: f64 => f64,
+        phase: f64 => f64,
+        detuning: f64 => f64
+    }
+}
+impl_into_iq_values!(PyHermiteGaussian);
+impl_repr!(PyHermiteGaussian);
+impl_eq!(PyHermiteGaussian);


Test patch:
diff --git a/quil-py/test/instructions/test_copy.py b/quil-py/tests_py/instructions/test_copy.py
similarity index 100%
rename from quil-py/test/instructions/test_copy.py
rename to quil-py/tests_py/instructions/test_copy.py
diff --git a/quil-py/test/instructions/test_gate.py b/quil-py/tests_py/instructions/test_gate.py
similarity index 100%
rename from quil-py/test/instructions/test_gate.py
rename to quil-py/tests_py/instructions/test_gate.py
diff --git a/quil-py/test/program/__snapshots__/test_program.ambr b/quil-py/tests_py/program/__snapshots__/test_program.ambr
similarity index 85%
rename from quil-py/test/program/__snapshots__/test_program.ambr
rename to quil-py/tests_py/program/__snapshots__/test_program.ambr
index 8ceda0c8..b0fb860e 100644
--- a/quil-py/test/program/__snapshots__/test_program.ambr
+++ b/quil-py/tests_py/program/__snapshots__/test_program.ambr
@@ -1,3 +1,4 @@
+# serializer version: 1
 # name: test_filter_instructions
   '''
   DECLARE foo REAL[1]
diff --git a/quil-py/test/program/test_program.py b/quil-py/tests_py/program/test_program.py
similarity index 100%
rename from quil-py/test/program/test_program.py
rename to quil-py/tests_py/program/test_program.py
diff --git a/quil-py/test/test_eq.py b/quil-py/tests_py/test_eq.py
similarity index 100%
rename from quil-py/test/test_eq.py
rename to quil-py/tests_py/test_eq.py