Repository: rigetti/pyquil

Issue:
Pyquil cannot round-trip programs as strings if they contain special characters
Section 12.1.1 of the [Quil specification](https://quil-lang.github.io/) says that the predicate of a frame specification may contain a string.  Thus, I expect to be able to round-trip the following program:

```
from pyquil import Program
from pyquil.quilbase import DefFrame
from pyquil.quilatom import Frame, Qubit

qubit = Qubit(1)
tx_frame = Frame(qubits=[qubit], name=f"transmitter_q1")
df = DefFrame(
    frame=tx_frame,
    direction="tx",
    initial_frequency=7.125e9,
    center_frequency=7.125e9,
    hardware_object='{"key": "value", "key2": "value2"}',
    sample_rate=1000000000,
)
p = Program(df)
assert Program(str(p)) == p
```

However, `pyquil 3.0.1` throws an exception on parsing:
```
UnexpectedCharacters: No terminal matches '{' in the current parser context
```

There appear to be two problems here:
1. pyquil's `grammar.lark` defines the predicate of a frame specification to be a `name` surrounded by double quotes, rather than a string.  I think this is what's causing the parsing error on the curly braces.  This is in contrast to PRAGMA's grammar definition, which allows strings.  (Curly braces are used inside FILTER-NODE definitions)
2. pyquil does not escape the double quotes inside the hardware object string before returning the program in string format.

Discussion:
caldwellshane: @notmgsk This is ready for a look when you're up/
notmgsk: Hella tight.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 88a9f21..caaff41 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,9 @@ Changelog
 
 - Allow `np.ndarray` when writing QAM memory. Disallow non-integer and non-float types.
 - Fix typo where `qc.compiler.calibration_program` should be `qc.compiler.get_calibration_program()`.
+- `DefFrame` string-valued fields that contain JSON strings now round trip to valid Quil and back to
+  JSON via `DefFrame.out` and `parse`. Quil and JSON both claim `"` as their only string delimiter,
+  so the JSON `"`s are escaped in the Quil.
 
 [v3.0.0](https://github.com/rigetti/pyquil/releases/tag/v3.0.0)
 ------------------------------------------------------------------------------------
diff --git a/pyquil/_parser/grammar.lark b/pyquil/_parser/grammar.lark
index 5f0b93a..9a5da3f 100644
--- a/pyquil/_parser/grammar.lark
+++ b/pyquil/_parser/grammar.lark
@@ -60,7 +60,7 @@ def_circuit : "DEFCIRCUIT" name [ variables ] [ qubit_designators ] ":" indented
 //             | "DEFCIRCUIT" name [ variables ] qubit_designators ":" indented_instrs -> def_circuit_qubits
 
 def_frame : "DEFFRAME" frame ( ":" frame_spec+ )?
-frame_spec : _NEWLINE_TAB frame_attr ":" (expression | string )
+frame_spec : _NEWLINE_TAB frame_attr ":" ( expression | string )
 !frame_attr : "SAMPLE-RATE"
             | "INITIAL-FREQUENCY"
             | "DIRECTION"
diff --git a/pyquil/_parser/parser.py b/pyquil/_parser/parser.py
index 0b345fd..c8ebd28 100644
--- a/pyquil/_parser/parser.py
+++ b/pyquil/_parser/parser.py
@@ -1,3 +1,4 @@
+import json
 import pkgutil
 import operator
 from typing import List
@@ -142,10 +143,7 @@ class QuilTransformer(Transformer):  # type: ignore
         for (spec_name, spec_value) in specs:
             name = names.get(spec_name, None)
             if name:
-                if isinstance(spec_value, str) and spec_value[0] == '"' and spec_value[-1] == '"':
-                    # Strip quotes if necessary
-                    spec_value = spec_value[1:-1]
-                options[name] = spec_value
+                options[name] = json.loads(str(spec_value))
             else:
                 raise ValueError(
                     f"Unexpectected attribute {spec_name} in definition of frame {frame}. " f"{frame}, {specs}"
diff --git a/pyquil/quilbase.py b/pyquil/quilbase.py
index f3ec7b0..476602d 100644
--- a/pyquil/quilbase.py
+++ b/pyquil/quilbase.py
@@ -17,6 +17,8 @@
 Contains the core pyQuil objects that correspond to Quil instructions.
 """
 import collections
+import json
+
 from numbers import Complex
 from typing import (
     Any,
@@ -1375,7 +1377,6 @@ class DefFrame(AbstractInstruction):
             for value, name in options:
                 if value is None:
                     continue
-                if isinstance(value, str):
-                    value = f'"{value}"'
-                r += f"\n    {name}: {value}"
+                else:
+                    r += f"\n    {name}: {json.dumps(value)}"
         return r + "\n"


Test patch:
diff --git a/test/unit/test_parser.py b/test/unit/test_parser.py
index 16b190b..c0995c1 100644
--- a/test/unit/test_parser.py
+++ b/test/unit/test_parser.py
@@ -604,6 +604,29 @@ def test_parsing_defframe():
     assert excp.value.token == Token("IDENTIFIER", "UNSUPPORTED")
 
 
+def test_parsing_defframe_round_trip_with_json():
+    fdef = DefFrame(
+        frame=Frame(qubits=[FormalArgument("My-Cool-Qubit")], name="bananas"),
+        direction="go west",
+        initial_frequency=123.4,
+        center_frequency=124.5,
+        hardware_object='{"key1": 3.1, "key2": "value2"}',
+        sample_rate=5,
+    )
+    fdef_out = fdef.out()
+    assert (
+        fdef.out()
+        == r"""DEFFRAME My-Cool-Qubit "bananas":
+    DIRECTION: "go west"
+    INITIAL-FREQUENCY: 123.4
+    CENTER-FREQUENCY: 124.5
+    HARDWARE-OBJECT: "{\"key1\": 3.1, \"key2\": \"value2\"}"
+    SAMPLE-RATE: 5
+"""
+    )
+    parse_equals(fdef_out, fdef)
+
+
 def test_parsing_defcal():
     parse_equals("DEFCAL X 0:\n" "    NOP\n", DefCalibration("X", [], [Qubit(0)], [NOP]))
     parse_equals(