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

Issue:
Add method for identifying Quil-T instructions and a utility for filtering programs.
It is a common task to want to create a copy of a program without some set of instructions. One common example is creating a copy of a program that doesn't have any Quil-T instructions. Quil-T instructions are well defined in the spec, so we should also add a utility that makes identifying them easier.


Discussion / hints:

Fix patch:
diff --git a/quil-py/poetry.lock b/quil-py/poetry.lock
index a75cd81e..c84d4b3c 100644
--- a/quil-py/poetry.lock
+++ b/quil-py/poetry.lock
@@ -1,10 +1,9 @@
-# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
 
 [[package]]
 name = "astunparse"
 version = "1.6.3"
 description = "An AST unparser for Python"
-category = "dev"
 optional = false
 python-versions = "*"
 files = [
@@ -20,7 +19,6 @@ wheel = ">=0.23.0,<1.0"
 name = "attrs"
 version = "22.2.0"
 description = "Classes Without Boilerplate"
-category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -39,7 +37,6 @@ tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy
 name = "black"
 version = "23.1.0"
 description = "The uncompromising code formatter."
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -89,7 +86,6 @@ uvloop = ["uvloop (>=0.15.2)"]
 name = "click"
 version = "8.1.3"
 description = "Composable command line interface toolkit"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -104,7 +100,6 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""}
 name = "colorama"
 version = "0.4.6"
 description = "Cross-platform colored terminal text."
-category = "dev"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
 files = [
@@ -112,11 +107,20 @@ files = [
     {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
 ]
 
+[[package]]
+name = "colored"
+version = "1.4.4"
+description = "Simple library for color and formatting to terminal"
+optional = false
+python-versions = "*"
+files = [
+    {file = "colored-1.4.4.tar.gz", hash = "sha256:04ff4d4dd514274fe3b99a21bb52fb96f2688c01e93fba7bef37221e7cb56ce0"},
+]
+
 [[package]]
 name = "exceptiongroup"
 version = "1.1.1"
 description = "Backport of PEP 654 (exception groups)"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -131,7 +135,6 @@ test = ["pytest (>=6)"]
 name = "iniconfig"
 version = "2.0.0"
 description = "brain-dead simple config-ini parsing"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -143,7 +146,6 @@ files = [
 name = "jinja2"
 version = "3.1.2"
 description = "A very fast and expressive template engine."
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -161,7 +163,6 @@ i18n = ["Babel (>=2.7)"]
 name = "markupsafe"
 version = "2.1.3"
 description = "Safely add untrusted strings to HTML/XML markup."
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -231,7 +232,6 @@ files = [
 name = "maturin"
 version = "1.2.3"
 description = "Build and publish crates with pyo3, rust-cpython and cffi bindings as well as rust binaries as python packages"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -261,7 +261,6 @@ zig = ["ziglang (>=0.10.0,<0.11.0)"]
 name = "mypy"
 version = "1.1.1"
 description = "Optional static typing for Python"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -308,7 +307,6 @@ reports = ["lxml"]
 name = "mypy-extensions"
 version = "1.0.0"
 description = "Type system extensions for programs checked with the mypy type checker."
-category = "dev"
 optional = false
 python-versions = ">=3.5"
 files = [
@@ -320,7 +318,6 @@ files = [
 name = "numpy"
 version = "1.24.3"
 description = "Fundamental package for array computing in Python"
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -358,7 +355,6 @@ files = [
 name = "packaging"
 version = "23.0"
 description = "Core utilities for Python packages"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -370,7 +366,6 @@ files = [
 name = "pathspec"
 version = "0.11.0"
 description = "Utility library for gitignore style pattern matching of file paths."
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -382,7 +377,6 @@ files = [
 name = "pdoc"
 version = "14.1.0"
 description = "API Documentation for Python Projects"
-category = "dev"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -403,7 +397,6 @@ dev = ["black", "hypothesis", "mypy", "pygments (>=2.14.0)", "pytest", "pytest-c
 name = "platformdirs"
 version = "3.1.1"
 description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -419,7 +412,6 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2.1)", "pytes
 name = "pluggy"
 version = "1.0.0"
 description = "plugin and hook calling mechanisms for python"
-category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -435,7 +427,6 @@ testing = ["pytest", "pytest-benchmark"]
 name = "pygments"
 version = "2.16.1"
 description = "Pygments is a syntax highlighting package written in Python."
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -450,7 +441,6 @@ plugins = ["importlib-metadata"]
 name = "pytest"
 version = "7.2.2"
 description = "pytest: simple powerful testing with Python"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -474,7 +464,6 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.
 name = "six"
 version = "1.16.0"
 description = "Python 2 and 3 compatibility utilities"
-category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
 files = [
@@ -482,11 +471,25 @@ files = [
     {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
 ]
 
+[[package]]
+name = "syrupy"
+version = "3.0.6"
+description = "Pytest Snapshot Test Utility"
+optional = false
+python-versions = ">=3.7,<4"
+files = [
+    {file = "syrupy-3.0.6-py3-none-any.whl", hash = "sha256:9c18e22264026b34239bcc87ab7cc8d893eb17236ea7dae634217ea4f22a848d"},
+    {file = "syrupy-3.0.6.tar.gz", hash = "sha256:583aa5ca691305c27902c3e29a1ce9da50ff9ab5f184c54b1dc124a16e4a6cf4"},
+]
+
+[package.dependencies]
+colored = ">=1.3.92,<2.0.0"
+pytest = ">=5.1.0,<8.0.0"
+
 [[package]]
 name = "tomli"
 version = "2.0.1"
 description = "A lil' TOML parser"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -498,7 +501,6 @@ files = [
 name = "typing-extensions"
 version = "4.5.0"
 description = "Backported and Experimental Type Hints for Python 3.7+"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -510,7 +512,6 @@ files = [
 name = "wheel"
 version = "0.41.3"
 description = "A built-package format for Python"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -524,4 +525,4 @@ test = ["pytest (>=6.0.0)", "setuptools (>=65)"]
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.8"
-content-hash = "c688b8e91c55e759929e2a80f7758c31f1063ebdf1d9098b426c325dbe108511"
+content-hash = "0a3b0fa6670ec3156719cb56a052cce8f8fe11169745f976da20549182936840"
diff --git a/quil-py/pyproject.toml b/quil-py/pyproject.toml
index 4e5a61af..4e6dd71b 100644
--- a/quil-py/pyproject.toml
+++ b/quil-py/pyproject.toml
@@ -33,6 +33,7 @@ maturin = "^1.2.3"
 mypy = "^1.1.1"
 pytest = "^7.2.2"
 pdoc = "^14.1.0"
+syrupy = "^3.0.6"
 
 [tool.maturin]
 features = ["pyo3/extension-module"]
diff --git a/quil-py/quil/instructions/__init__.pyi b/quil-py/quil/instructions/__init__.pyi
index a16a90ba..60a14f27 100644
--- a/quil-py/quil/instructions/__init__.pyi
+++ b/quil-py/quil/instructions/__init__.pyi
@@ -116,6 +116,11 @@ class Instruction:
         """
         Returns a new ``Instruction`` from the given inner data.
         """
+    def is_quil_t(self) -> bool:
+        """
+        Returns ``True`` if the instruction is a Quil-T instruction, ``False`` otherwise.
+        """
+        ...
     def inner(
         self,
     ) -> Union[
diff --git a/quil-py/quil/program/__init__.pyi b/quil-py/quil/program/__init__.pyi
index d8ccdb16..eb057568 100644
--- a/quil-py/quil/program/__init__.pyi
+++ b/quil-py/quil/program/__init__.pyi
@@ -122,6 +122,11 @@ class Program:
         Convert the instruction to a Quil string. If any part of the instruction can't
         be converted to valid Quil, it will be printed in a human-readable debug format.
         """
+    def filter_instructions(self, predicate: Callable[[Instruction], bool]) -> "Program":
+        """
+        Return a new ``Program`` containing only the instructions for which ``predicate`` returns ``True``.
+        """
+        ...
     def wrap_in_loop(
         self, loop_count_reference: MemoryReference, start_target: Target, end_target: Target, iterations: int
     ) -> "Program":
diff --git a/quil-py/src/instruction/mod.rs b/quil-py/src/instruction/mod.rs
index d466951b..bb2c4ba7 100644
--- a/quil-py/src/instruction/mod.rs
+++ b/quil-py/src/instruction/mod.rs
@@ -2,6 +2,7 @@ use quil_rs::instruction::Instruction;
 use rigetti_pyo3::{
     create_init_submodule, impl_repr, py_wrap_union_enum,
     pyo3::{pymethods, types::PyDict, PyResult, Python},
+    PyWrapper,
 };
 
 use crate::{impl_eq, impl_to_quil};
@@ -100,6 +101,10 @@ impl_eq!(PyInstruction);
 
 #[pymethods]
 impl PyInstruction {
+    pub fn is_quil_t(&self) -> bool {
+        self.as_inner().is_quil_t()
+    }
+
     // Implement the __copy__ and __deepcopy__ dunder methods, which are used by Python's
     // `copy` module.
     //
diff --git a/quil-py/src/program/mod.rs b/quil-py/src/program/mod.rs
index 6757e13a..5b3f6bf8 100644
--- a/quil-py/src/program/mod.rs
+++ b/quil-py/src/program/mod.rs
@@ -237,6 +237,19 @@ impl PyProgram {
             .to_owned())
     }
 
+    pub fn filter_instructions(&self, py: Python, predicate: Py<PyFunction>) -> PyResult<Self> {
+        let filtered = self.as_inner().filter_instructions(|inst| {
+            Python::with_gil(|py| {
+                predicate
+                    .call1(py, (inst.to_python(py).unwrap(),))
+                    .unwrap_or_else(|err| panic!("predicate function returned an error: {err}"))
+                    .extract(py)
+                    .unwrap_or_else(|err| panic!("predicate function must return a bool: {err}"))
+            })
+        });
+        filtered.to_python(py)
+    }
+
     pub fn resolve_placeholders(&mut self) {
         self.as_inner_mut().resolve_placeholders();
     }
diff --git a/quil-rs/src/instruction/mod.rs b/quil-rs/src/instruction/mod.rs
index a6d77b50..107744ae 100644
--- a/quil-rs/src/instruction/mod.rs
+++ b/quil-rs/src/instruction/mod.rs
@@ -644,6 +644,51 @@ impl Instruction {
         Ok(instruction)
     }
 
+    /// Returns true if the instruction is a Quil-T instruction.
+    pub fn is_quil_t(&self) -> bool {
+        match self {
+            Instruction::Capture(_)
+            | Instruction::CalibrationDefinition(_)
+            | Instruction::Delay(_)
+            | Instruction::Fence(_)
+            | Instruction::FrameDefinition(_)
+            | Instruction::MeasureCalibrationDefinition(_)
+            | Instruction::Pulse(_)
+            | Instruction::RawCapture(_)
+            | Instruction::SetFrequency(_)
+            | Instruction::SetPhase(_)
+            | Instruction::SetScale(_)
+            | Instruction::ShiftFrequency(_)
+            | Instruction::ShiftPhase(_)
+            | Instruction::SwapPhases(_)
+            | Instruction::Wait
+            | Instruction::WaveformDefinition(_) => true,
+            Instruction::Arithmetic(_)
+            | Instruction::BinaryLogic(_)
+            | Instruction::CircuitDefinition(_)
+            | Instruction::Convert(_)
+            | Instruction::Comparison(_)
+            | Instruction::Declaration(_)
+            | Instruction::Exchange(_)
+            | Instruction::Gate(_)
+            | Instruction::GateDefinition(_)
+            | Instruction::Halt
+            | Instruction::Include(_)
+            | Instruction::Jump(_)
+            | Instruction::JumpUnless(_)
+            | Instruction::JumpWhen(_)
+            | Instruction::Label(_)
+            | Instruction::Load(_)
+            | Instruction::Measurement(_)
+            | Instruction::Move(_)
+            | Instruction::Nop
+            | Instruction::Pragma(_)
+            | Instruction::Reset(_)
+            | Instruction::Store(_)
+            | Instruction::UnaryLogic(_) => false,
+        }
+    }
+
     /// Per the Quil-T spec, whether this instruction's timing within the pulse
     /// program must be precisely controlled so as to begin exactly on the end of
     /// the latest preceding timed instruction
diff --git a/quil-rs/src/program/mod.rs b/quil-rs/src/program/mod.rs
index 2904f8ec..e23589fd 100644
--- a/quil-rs/src/program/mod.rs
+++ b/quil-rs/src/program/mod.rs
@@ -202,6 +202,17 @@ impl Program {
             .for_each(|i| self.add_instruction(i));
     }
 
+    /// Return a new [`Program`] containing only the instructions for which `predicate` returns
+    /// true.
+    pub fn filter_instructions(&self, predicate: impl FnMut(&Instruction) -> bool) -> Program {
+        Program::from_instructions(
+            self.to_instructions()
+                .into_iter()
+                .filter(predicate)
+                .collect(),
+        )
+    }
+
     /// Creates a new conjugate transpose of the [`Program`] by reversing the order of gate
     /// instructions and applying the DAGGER modifier to each.
     ///
@@ -1316,6 +1327,26 @@ CNOT 2 3
         );
     }
 
+    #[test]
+    fn test_filter_instructions() {
+        let input = "DECLARE foo REAL[1]
+DEFFRAME 1 \"rx\":
+\tHARDWARE-OBJECT: \"hardware\"
+DEFCAL I 1:
+\tDELAY 0 1
+DEFGATE BAR AS MATRIX:
+\t0, 1
+\t1, 0
+
+H 1
+CNOT 2 3";
+
+        let program = Program::from_str(input).unwrap();
+        let program_without_quil_t =
+            program.filter_instructions(|instruction| !instruction.is_quil_t());
+        assert_snapshot!(program_without_quil_t.to_quil().unwrap())
+    }
+
     #[test]
     fn test_wrap_in_loop() {
         let input = "DECLARE ro BIT


Test patch:
diff --git a/quil-py/test/program/__snapshots__/test_program.ambr b/quil-py/test/program/__snapshots__/test_program.ambr
new file mode 100644
index 00000000..79a305ea
--- /dev/null
+++ b/quil-py/test/program/__snapshots__/test_program.ambr
@@ -0,0 +1,12 @@
+# name: test_filter_instructions
+  '''
+  DECLARE foo REAL[1]
+  DEFGATE BAR AS MATRIX:
+  	0, 1
+  	1, 0
+  
+  H 1
+  CNOT 2 3
+  
+  '''
+# ---
diff --git a/quil-py/test/program/test_program.py b/quil-py/test/program/test_program.py
index 008a33a8..8dd92358 100644
--- a/quil-py/test/program/test_program.py
+++ b/quil-py/test/program/test_program.py
@@ -2,6 +2,8 @@
 import pytest
 from typing import Optional
 
+from syrupy.assertion import SnapshotAssertion
+
 from quil.program import Program
 from quil.instructions import Instruction, QubitPlaceholder, TargetPlaceholder, Gate, Qubit, Jump, Target
 
@@ -49,3 +51,21 @@ def target_resolver(target: TargetPlaceholder) -> Optional[str]:
     print(program.to_quil_or_debug())
 
     assert program.to_quil() == "H 9\nJUMP @test\n"
+
+
+def test_filter_instructions(snapshot: SnapshotAssertion):
+    input = """DECLARE foo REAL[1]
+DEFFRAME 1 "rx":
+\tHARDWARE-OBJECT: "hardware"
+DEFCAL I 1:
+\tDELAY 0 1
+DEFGATE BAR AS MATRIX:
+\t0, 1
+\t1, 0
+
+H 1
+CNOT 2 3
+"""
+    program = Program.parse(input)
+    program_without_quil_t = program.filter_instructions(lambda instruction: not instruction.is_quil_t())
+    assert program_without_quil_t.to_quil() == snapshot
diff --git a/quil-rs/src/program/snapshots/quil_rs__program__tests__filter_instructions.snap b/quil-rs/src/program/snapshots/quil_rs__program__tests__filter_instructions.snap
new file mode 100644
index 00000000..e12ff500
--- /dev/null
+++ b/quil-rs/src/program/snapshots/quil_rs__program__tests__filter_instructions.snap
@@ -0,0 +1,12 @@
+---
+source: quil-rs/src/program/mod.rs
+expression: program_without_quil_t.to_quil().unwrap()
+---
+DECLARE foo REAL[1]
+DEFGATE BAR AS MATRIX:
+	0, 1
+	1, 0
+
+H 1
+CNOT 2 3
+