Skip to content

Commit 9985143

Browse files
authored
feat(hugr-py): Add to/from_bytes/str to Hugr, using envelopes (#2228)
Followup to #2148 - Adds envelope reading/writting methods to `Hugr` (py) that mimic Package. - Deprecates `to_json` and `load_json`. - Uses envelopes in python tests. Note that `EnvelopeConfig.BINARY` is still set to JSON. Using `hugr-module` there currently causes errors.
1 parent ef8ea5e commit 9985143

File tree

4 files changed

+113
-18
lines changed

4 files changed

+113
-18
lines changed

hugr-py/src/hugr/envelope.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,22 @@
4040
import pyzstd
4141

4242
if TYPE_CHECKING:
43+
from hugr.hugr.base import Hugr
4344
from hugr.package import Package
4445

4546
# This is a hard-coded magic number that identifies the start of a HUGR envelope.
4647
MAGIC_NUMBERS = b"HUGRiHJv"
4748

4849

49-
def make_envelope(package: Package, config: EnvelopeConfig) -> bytes:
50-
"""Encode a HUGR package into an envelope, using the given configuration."""
50+
def make_envelope(package: Package | Hugr, config: EnvelopeConfig) -> bytes:
51+
"""Encode a HUGR or Package into an envelope, using the given configuration."""
52+
from hugr.package import Package
53+
5154
envelope = bytearray(config._make_header().to_bytes())
5255

56+
if not isinstance(package, Package):
57+
package = Package(modules=[package], extensions=[])
58+
5359
# Currently only uncompressed JSON is supported.
5460
payload: bytes
5561
match config.format:
@@ -77,8 +83,8 @@ def make_envelope(package: Package, config: EnvelopeConfig) -> bytes:
7783
return bytes(envelope)
7884

7985

80-
def make_envelope_str(package: Package, config: EnvelopeConfig) -> str:
81-
"""Encode a HUGR package into an envelope, using the given configuration."""
86+
def make_envelope_str(package: Package | Hugr, config: EnvelopeConfig) -> str:
87+
"""Encode a HUGR or Package into an envelope, using the given configuration."""
8288
if not config.format.ascii_printable():
8389
msg = "Only ascii-printable envelope formats can be encoded into a string."
8490
raise ValueError(msg)
@@ -104,11 +110,43 @@ def read_envelope(envelope: bytes) -> Package:
104110
raise ValueError(msg)
105111

106112

113+
def read_envelope_hugr(envelope: bytes) -> Hugr:
114+
"""Decode a HUGR from an envelope.
115+
116+
Raises:
117+
ValueError: If the envelope does not contain a single module.
118+
"""
119+
pkg = read_envelope(envelope)
120+
if len(pkg.modules) != 1:
121+
msg = (
122+
"Expected a single module in the envelope, but got "
123+
+ f"{len(pkg.modules)} modules."
124+
)
125+
raise ValueError(msg)
126+
return pkg.modules[0]
127+
128+
107129
def read_envelope_str(envelope: str) -> Package:
108130
"""Decode a HUGR package from an envelope."""
109131
return read_envelope(envelope.encode("utf-8"))
110132

111133

134+
def read_envelope_hugr_str(envelope: str) -> Hugr:
135+
"""Decode a HUGR from an envelope.
136+
137+
Raises:
138+
ValueError: If the envelope does not contain a single module.
139+
"""
140+
pkg = read_envelope_str(envelope)
141+
if len(pkg.modules) != 1:
142+
msg = (
143+
"Expected a single module in the envelope, but got "
144+
+ f"{len(pkg.modules)} modules."
145+
)
146+
raise ValueError(msg)
147+
return pkg.modules[0]
148+
149+
112150
class EnvelopeFormat(Enum):
113151
"""Format used to encode a HUGR envelope."""
114152

hugr-py/src/hugr/hugr/base.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,19 @@
1515
overload,
1616
)
1717

18+
from typing_extensions import deprecated
19+
1820
import hugr.model as model
1921
import hugr.ops as ops
2022
from hugr._serialization.ops import OpType as SerialOp
2123
from hugr._serialization.serial_hugr import SerialHugr
24+
from hugr.envelope import (
25+
EnvelopeConfig,
26+
make_envelope,
27+
make_envelope_str,
28+
read_envelope_hugr,
29+
read_envelope_hugr_str,
30+
)
2231
from hugr.exceptions import ParentBeforeChild
2332
from hugr.ops import (
2433
CFG,
@@ -892,6 +901,59 @@ def get_meta(idx: int) -> dict[str, Any]:
892901

893902
return hugr
894903

904+
@staticmethod
905+
def from_bytes(envelope: bytes) -> Hugr:
906+
"""Deserialize a byte string to a Hugr object.
907+
908+
Some envelope formats can be read from a string. See :meth:`from_str`.
909+
910+
Args:
911+
envelope: The byte string representing a Hugr envelope.
912+
913+
Returns:
914+
The deserialized Hugr object.
915+
916+
Raises:
917+
ValueError: If the envelope does not contain exactly one module.
918+
"""
919+
return read_envelope_hugr(envelope)
920+
921+
@staticmethod
922+
def from_str(envelope: str) -> Hugr:
923+
"""Deserialize a string to a Hugr object.
924+
925+
Not all envelope formats can be read from a string.
926+
See :meth:`from_bytes` for a more general method.
927+
928+
Args:
929+
envelope: The string representing a Hugr envelope.
930+
931+
Returns:
932+
The deserialized Hugr object.
933+
934+
Raises:
935+
ValueError: If the envelope does not contain exactly one module.
936+
"""
937+
return read_envelope_hugr_str(envelope)
938+
939+
def to_bytes(self, config: EnvelopeConfig | None = None) -> bytes:
940+
"""Serialize the HUGR into an envelope byte string.
941+
942+
Some envelope formats can be encoded into a string. See :meth:`to_str`.
943+
"""
944+
config = config or EnvelopeConfig.BINARY
945+
return make_envelope(self, config)
946+
947+
def to_str(self, config: EnvelopeConfig | None = None) -> str:
948+
"""Serialize the package to a HUGR envelope string.
949+
950+
Not all envelope formats can be encoded into a string.
951+
See :meth:`to_bytes` for a more general method.
952+
"""
953+
config = config or EnvelopeConfig.TEXT
954+
return make_envelope_str(self, config)
955+
956+
@deprecated("Use HUGR envelopes instead. See the `to_bytes` and `to_str` methods.")
895957
def to_json(self) -> str:
896958
"""Serialize the HUGR to a JSON string.
897959
@@ -909,6 +971,7 @@ def to_model(self) -> model.Module:
909971
return model.Module(region)
910972

911973
@classmethod
974+
@deprecated("Use HUGR envelopes instead. See the `to_bytes` and `to_str` methods.")
912975
def load_json(cls, json_str: str) -> Hugr:
913976
"""Deserialize a JSON string into a HUGR.
914977

hugr-py/tests/conftest.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
import json
43
import os
54
import pathlib
65
import subprocess
@@ -11,7 +10,6 @@
1110
from typing_extensions import Self
1211

1312
from hugr import ext, tys
14-
from hugr._serialization.serial_hugr import SerialHugr
1513
from hugr.envelope import EnvelopeConfig
1614
from hugr.hugr import Hugr
1715
from hugr.ops import AsExtOp, Command, DataflowOp, ExtOp, RegisteredOp
@@ -131,7 +129,7 @@ def _base_command() -> list[str]:
131129
def mermaid(h: Hugr):
132130
"""Render the Hugr as a mermaid diagram for debugging."""
133131
cmd = [*_base_command(), "mermaid", "-"]
134-
_run_hugr_cmd(h.to_json().encode(), cmd)
132+
_run_hugr_cmd(h.to_str().encode(), cmd)
135133

136134

137135
def validate(
@@ -151,21 +149,17 @@ def validate(
151149
# TODO: Use envelopes instead of legacy hugr-json
152150
cmd = [*_base_command(), "validate", "-"]
153151

154-
if isinstance(h, Hugr):
155-
cmd += ["--hugr-json"]
156-
_run_hugr_cmd(h.to_json().encode(), cmd)
157-
else:
158-
serial = h.to_bytes(EnvelopeConfig.BINARY)
159-
_run_hugr_cmd(serial, cmd)
152+
serial = h.to_bytes(EnvelopeConfig.BINARY)
153+
_run_hugr_cmd(serial, cmd)
160154

161155
if not roundtrip:
162156
return
163157

164158
# Roundtrip checks
165159
if isinstance(h, Hugr):
166-
starting_json = json.loads(h.to_json())
167-
h2 = Hugr._from_serial(SerialHugr.load_json(starting_json))
168-
roundtrip_json = json.loads(h2._to_serial().to_json())
160+
starting_json = h.to_str()
161+
h2 = Hugr.from_str(starting_json)
162+
roundtrip_json = h2.to_str()
169163
assert roundtrip_json == starting_json
170164

171165
if snap is not None:

hugr-py/tests/serialization/test_basic.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def test_children():
2323
h = mod.hugr
2424
assert len(h.children()) == 1
2525

26-
h2 = Hugr.load_json(h.to_json())
26+
h2 = Hugr.from_str(h.to_str())
2727

2828
assert len(h2.children()) == 1
2929

@@ -44,7 +44,7 @@ def test_entrypoint():
4444
assert h[func].parent == h.module_root
4545

4646
# Do a roundtrip, and test all again
47-
h2 = Hugr.load_json(h.to_json())
47+
h2 = Hugr.from_str(h.to_str())
4848

4949
dfg = h2.entrypoint
5050
assert h2[dfg].op == ops.DFG(inputs=[tys.Bool], _outputs=[tys.Bool])

0 commit comments

Comments
 (0)