From 172f85eafbf95243b9c54cf91ab26f3246a75949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn=20Borgna?= Date: Wed, 16 Jul 2025 15:54:08 +0100 Subject: [PATCH 1/6] Better lower func serialization errors --- hugr-core/src/envelope/serde_with.rs | 259 ++++++++++++++++++- hugr-core/src/extension/op_def.rs | 34 ++- hugr-py/src/hugr/_serialization/extension.py | 28 +- hugr-py/src/hugr/ext.py | 4 +- hugr-py/tests/test_package.py | 4 + 5 files changed, 323 insertions(+), 6 deletions(-) diff --git a/hugr-core/src/envelope/serde_with.rs b/hugr-core/src/envelope/serde_with.rs index 7b9517d3e0..beb963054b 100644 --- a/hugr-core/src/envelope/serde_with.rs +++ b/hugr-core/src/envelope/serde_with.rs @@ -15,6 +15,9 @@ use crate::std_extensions::STD_REG; /// De/Serialize a package or hugr by encoding it into a textual Envelope and /// storing it as a string. /// +/// This is similar to [`AsBinaryEnvelope`], but uses a textual envelope instead +/// of a binary one. +/// /// Note that only PRELUDE extensions are used to decode the package's content. /// When serializing a HUGR, any additional extensions required to load it are /// embedded in the envelope. Packages should manually add any required @@ -45,9 +48,53 @@ use crate::std_extensions::STD_REG; /// When reading an encoded HUGR, the `AsStringEnvelope` deserializer will first /// try to decode the value as an string-encoded envelope. If that fails, it /// will fallback to decoding the legacy HUGR serde definition. This temporary -/// compatibility layer is meant to be removed in 0.21.0. +/// compatibility is required to support `hugr <= 0.19` and will be removed in +/// a future version. pub struct AsStringEnvelope; +/// De/Serialize a package or hugr by encoding it into a binary envelope and +/// storing it as a base64-encoded string. +/// +/// This is similar to [`AsStringEnvelope`], but uses a binary envelope instead +/// of a string. +/// When deserializing, if the string starts with the envelope magic 'HUGRiHJv' +/// it will be loaded as a string envelope without base64 decoding. +/// +/// Note that only PRELUDE extensions are used to decode the package's content. +/// When serializing a HUGR, any additional extensions required to load it are +/// embedded in the envelope. Packages should manually add any required +/// extensions before serializing. +/// +/// # Examples +/// +/// ```rust +/// # use serde::{Deserialize, Serialize}; +/// # use serde_json::json; +/// # use serde_with::{serde_as}; +/// # use hugr_core::Hugr; +/// # use hugr_core::package::Package; +/// # use hugr_core::envelope::serde_with::AsBinaryEnvelope; +/// # +/// #[serde_as] +/// #[derive(Deserialize, Serialize)] +/// struct A { +/// #[serde_as(as = "AsBinaryEnvelope")] +/// package: Package, +/// #[serde_as(as = "Vec")] +/// hugrs: Vec, +/// } +/// ``` +/// +/// # Backwards compatibility +/// +/// When reading an encoded HUGR, the `AsBinaryEnvelope` deserializer will first +/// try to decode the value as an binary-encoded envelope. If that fails, it +/// will fallback to decoding a string envelope instead, and then finally to +/// decoding the legacy HUGR serde definition. This temporary compatibility +/// layer is required to support `hugr <= 0.19` and will be removed in a future +/// version. +pub struct AsBinaryEnvelope; + /// Implements [`serde_with::DeserializeAs`] and [`serde_with::SerializeAs`] for /// the helper to deserialize `Hugr` and `Package` types, using the given /// extension registry. @@ -211,3 +258,213 @@ macro_rules! impl_serde_as_string_envelope { pub use impl_serde_as_string_envelope; impl_serde_as_string_envelope!(AsStringEnvelope, &STD_REG); + +/// Implements [`serde_with::DeserializeAs`] and [`serde_with::SerializeAs`] for +/// the helper to deserialize `Hugr` and `Package` types, using the given +/// extension registry. +/// +/// This macro is used to implement the default [`AsBinaryEnvelope`] wrapper. +/// +/// # Parameters +/// +/// - `$adaptor`: The name of the adaptor type to implement. +/// - `$extension_reg`: A reference to the extension registry to use for deserialization. +/// +/// # Examples +/// +/// ```rust +/// # use serde::{Deserialize, Serialize}; +/// # use serde_json::json; +/// # use serde_with::{serde_as}; +/// # use hugr_core::Hugr; +/// # use hugr_core::package::Package; +/// # use hugr_core::envelope::serde_with::AsBinaryEnvelope; +/// # use hugr_core::envelope::serde_with::impl_serde_as_binary_envelope; +/// # use hugr_core::extension::ExtensionRegistry; +/// # +/// struct CustomAsEnvelope; +/// +/// impl_serde_as_binary_envelope!(CustomAsEnvelope, &hugr_core::extension::EMPTY_REG); +/// +/// #[serde_as] +/// #[derive(Deserialize, Serialize)] +/// struct A { +/// #[serde_as(as = "CustomAsEnvelope")] +/// package: Package, +/// } +/// ``` +/// +#[macro_export] +macro_rules! impl_serde_as_binary_envelope { + ($adaptor:ident, $extension_reg:expr) => { + impl<'de> serde_with::DeserializeAs<'de, $crate::package::Package> for $adaptor { + fn deserialize_as(deserializer: D) -> Result<$crate::package::Package, D::Error> + where + D: serde::Deserializer<'de>, + { + struct Helper; + impl serde::de::Visitor<'_> for Helper { + type Value = $crate::package::Package; + + fn expecting( + &self, + formatter: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + formatter.write_str("a base64-encoded envelope") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + use $crate::envelope::serde_with::base64::{DecoderReader, STANDARD}; + + let extensions: &$crate::extension::ExtensionRegistry = $extension_reg; + + if value + .as_bytes() + .starts_with($crate::envelope::MAGIC_NUMBERS) + { + // If the string starts with the envelope magic 'HUGRiHJv', + // skip the base64 decoding. + let reader = std::io::Cursor::new(value.as_bytes()); + $crate::package::Package::load(reader, Some(extensions)) + .map_err(serde::de::Error::custom) + } else { + let reader = DecoderReader::new(value.as_bytes(), &STANDARD); + let buf_reader = std::io::BufReader::new(reader); + $crate::package::Package::load(buf_reader, Some(extensions)) + .map_err(serde::de::Error::custom) + } + } + } + + deserializer.deserialize_str(Helper) + } + } + + impl<'de> serde_with::DeserializeAs<'de, $crate::Hugr> for $adaptor { + fn deserialize_as(deserializer: D) -> Result<$crate::Hugr, D::Error> + where + D: serde::Deserializer<'de>, + { + struct Helper; + impl<'vis> serde::de::Visitor<'vis> for Helper { + type Value = $crate::Hugr; + + fn expecting( + &self, + formatter: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + formatter.write_str("a base64-encoded envelope") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + use $crate::envelope::serde_with::base64::{DecoderReader, STANDARD}; + + let extensions: &$crate::extension::ExtensionRegistry = $extension_reg; + + if value + .as_bytes() + .starts_with($crate::envelope::MAGIC_NUMBERS) + { + // If the string starts with the envelope magic 'HUGRiHJv', + // skip the base64 decoding. + let reader = std::io::Cursor::new(value.as_bytes()); + $crate::Hugr::load(reader, Some(extensions)) + .map_err(serde::de::Error::custom) + } else { + let reader = DecoderReader::new(value.as_bytes(), &STANDARD); + let buf_reader = std::io::BufReader::new(reader); + $crate::Hugr::load(buf_reader, Some(extensions)) + .map_err(serde::de::Error::custom) + } + } + + fn visit_map(self, map: A) -> Result + where + A: serde::de::MapAccess<'vis>, + { + // Backwards compatibility: If the encoded value is not a + // string, we may have a legacy HUGR serde structure instead. In that + // case, we can add an envelope header and try again. + // + // TODO: Remove this fallback in 0.21.0 + let deserializer = serde::de::value::MapAccessDeserializer::new(map); + #[allow(deprecated)] + let mut hugr = + $crate::hugr::serialize::serde_deserialize_hugr(deserializer) + .map_err(serde::de::Error::custom)?; + + let extensions: &$crate::extension::ExtensionRegistry = $extension_reg; + hugr.resolve_extension_defs(extensions) + .map_err(serde::de::Error::custom)?; + Ok(hugr) + } + } + + // TODO: Go back to `deserialize_str` once the fallback is removed. + deserializer.deserialize_any(Helper) + } + } + + impl serde_with::SerializeAs<$crate::package::Package> for $adaptor { + fn serialize_as( + source: &$crate::package::Package, + serializer: S, + ) -> Result + where + S: serde::Serializer, + { + use $crate::envelope::serde_with::base64::{EncoderStringWriter, STANDARD}; + + let mut writer = EncoderStringWriter::new(&STANDARD); + source + .store(&mut writer, $crate::envelope::EnvelopeConfig::binary()) + .map_err(serde::ser::Error::custom)?; + let str = writer.into_inner(); + serializer.collect_str(&str) + } + } + + impl serde_with::SerializeAs<$crate::Hugr> for $adaptor { + fn serialize_as(source: &$crate::Hugr, serializer: S) -> Result + where + S: serde::Serializer, + { + // Include any additional extension required to load the HUGR in the envelope. + let extensions: &$crate::extension::ExtensionRegistry = $extension_reg; + let mut extra_extensions = $crate::extension::ExtensionRegistry::default(); + for ext in $crate::hugr::views::HugrView::extensions(source).iter() { + if !extensions.contains(ext.name()) { + extra_extensions.register_updated(ext.clone()); + } + } + + let str = source + .store_str_with_exts( + $crate::envelope::EnvelopeConfig::text(), + &extra_extensions, + ) + .map_err(serde::ser::Error::custom)?; + serializer.collect_str(&str) + } + } + }; +} +pub use impl_serde_as_binary_envelope; + +impl_serde_as_binary_envelope!(AsBinaryEnvelope, &STD_REG); + +// Hidden re-export required to expand the binary envelope macros on external +// crates. +#[doc(hidden)] +pub mod base64 { + pub use base64::Engine; + pub use base64::engine::general_purpose::STANDARD; + pub use base64::read::DecoderReader; + pub use base64::write::EncoderStringWriter; +} diff --git a/hugr-core/src/extension/op_def.rs b/hugr-core/src/extension/op_def.rs index d521ea17a0..a551a06215 100644 --- a/hugr-core/src/extension/op_def.rs +++ b/hugr-core/src/extension/op_def.rs @@ -290,6 +290,34 @@ pub enum LowerFunc { CustomFunc(Box), } +/// A function for deserializing sequences of [`LowerFunc::FixedHugr`]. +/// +/// We could let serde deserialize [`LowerFunc`] as-is, but if the LowerFunc +/// deserialization fails it just returns an opaque "data did not match any +/// variant of untagged enum LowerFunc" error. This function will return the +/// internal errors instead. +fn deserialize_lower_funcs<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[serde_as] + #[derive(serde::Deserialize)] + struct FixedHugrDeserializer { + pub extensions: ExtensionSet, + #[serde_as(as = "Box")] + pub hugr: Box, + } + + let funcs: Vec = serde::Deserialize::deserialize(deserializer)?; + Ok(funcs + .into_iter() + .map(|f| LowerFunc::FixedHugr { + extensions: f.extensions, + hugr: f.hugr, + }) + .collect()) +} + impl Debug for LowerFunc { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -322,7 +350,11 @@ pub struct OpDef { signature_func: SignatureFunc, // Some operations cannot lower themselves and tools that do not understand them // can only treat them as opaque/black-box ops. - #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[serde( + default, + skip_serializing_if = "Vec::is_empty", + deserialize_with = "deserialize_lower_funcs" + )] pub(crate) lower_funcs: Vec, /// Operations can optionally implement [`ConstFold`] to implement constant folding. diff --git a/hugr-py/src/hugr/_serialization/extension.py b/hugr-py/src/hugr/_serialization/extension.py index 5ffdae2ff9..a06c63f9fa 100644 --- a/hugr-py/src/hugr/_serialization/extension.py +++ b/hugr-py/src/hugr/_serialization/extension.py @@ -66,12 +66,26 @@ def deserialize(self, extension: ext.Extension) -> ext.TypeDef: class FixedHugr(ConfiguredBaseModel): + """Fixed HUGR used to define the lowering of an operations. + + Args: + extensions: Extensions used in the HUGR. + hugr: Base64 encoded HUGR envelope. + """ + extensions: ExtensionSet hugr: str def deserialize(self) -> ext.FixedHugr: - hugr = Hugr.from_str(self.hugr) - return ext.FixedHugr(extensions=self.extensions, hugr=hugr) + # Loading fixed HUGRs requires reading hugr-model envelopes, + # which is not currently supported in Python. + # TODO: Add support for loading fixed HUGRs in Python. + # https://github.com/CQCL/hugr/issues/2287 + msg = ( + "Loading extensions with operation lowering functions is not " + + "supported in Python" + ) + raise NotImplementedError(msg) class OpDef(ConfiguredBaseModel, populate_by_name=True): @@ -91,13 +105,21 @@ def deserialize(self, extension: ext.Extension) -> ext.OpDef: self.binary, ) + # Loading fixed HUGRs requires reading hugr-model envelopes, + # which is not currently supported in Python. + # We currently ignore any lower functions instead of raising an error. + # + # TODO: Add support for loading fixed HUGRs in Python. + # https://github.com/CQCL/hugr/issues/2287 + lower_funcs: list[ext.FixedHugr] = [] + return extension.add_op_def( ext.OpDef( name=self.name, description=self.description, misc=self.misc or {}, signature=signature, - lower_funcs=[f.deserialize() for f in self.lower_funcs], + lower_funcs=lower_funcs, ) ) diff --git a/hugr-py/src/hugr/ext.py b/hugr-py/src/hugr/ext.py index 8123fa556b..53def975d3 100644 --- a/hugr-py/src/hugr/ext.py +++ b/hugr-py/src/hugr/ext.py @@ -2,6 +2,7 @@ from __future__ import annotations +import base64 from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, TypeVar @@ -154,7 +155,8 @@ class FixedHugr: hugr: Hugr def _to_serial(self) -> ext_s.FixedHugr: - return ext_s.FixedHugr(extensions=self.extensions, hugr=self.hugr.to_str()) + hugr_64: str = base64.b64encode(self.hugr.to_bytes()).decode() + return ext_s.FixedHugr(extensions=self.extensions, hugr=hugr_64) @dataclass diff --git a/hugr-py/tests/test_package.py b/hugr-py/tests/test_package.py index 100d47359c..2a11e0331f 100644 --- a/hugr-py/tests/test_package.py +++ b/hugr-py/tests/test_package.py @@ -62,4 +62,8 @@ def test_lower_func(): pkg = Package([hugr.hugr], [ext]) + # Lowering functions are currently ignored in Python, + # because we don't support loading -model envelopes yet. + assert pkg.extensions[0].operations["dummy_op"].lower_funcs == [] + validate(pkg) From 7f61088660765eb733671e1b317a993af3ff6b3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn=20Borgna?= Date: Thu, 17 Jul 2025 11:09:17 +0100 Subject: [PATCH 2/6] better error reporting on for serde_with envelopes --- hugr-core/src/envelope/serde_with.rs | 8 ++++---- hugr-core/src/extension/op_def.rs | 12 ++++++++---- hugr-py/tests/conftest.py | 6 ++++++ hugr-py/tests/test_package.py | 4 ---- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/hugr-core/src/envelope/serde_with.rs b/hugr-core/src/envelope/serde_with.rs index beb963054b..d01bc5ea52 100644 --- a/hugr-core/src/envelope/serde_with.rs +++ b/hugr-core/src/envelope/serde_with.rs @@ -329,12 +329,12 @@ macro_rules! impl_serde_as_binary_envelope { // skip the base64 decoding. let reader = std::io::Cursor::new(value.as_bytes()); $crate::package::Package::load(reader, Some(extensions)) - .map_err(serde::de::Error::custom) + .map_err(|e| serde::de::Error::custom(format!("{e:?}"))) } else { let reader = DecoderReader::new(value.as_bytes(), &STANDARD); let buf_reader = std::io::BufReader::new(reader); $crate::package::Package::load(buf_reader, Some(extensions)) - .map_err(serde::de::Error::custom) + .map_err(|e| serde::de::Error::custom(format!("{e:?}"))) } } } @@ -375,12 +375,12 @@ macro_rules! impl_serde_as_binary_envelope { // skip the base64 decoding. let reader = std::io::Cursor::new(value.as_bytes()); $crate::Hugr::load(reader, Some(extensions)) - .map_err(serde::de::Error::custom) + .map_err(|e| serde::de::Error::custom(format!("{e:?}"))) } else { let reader = DecoderReader::new(value.as_bytes(), &STANDARD); let buf_reader = std::io::BufReader::new(reader); $crate::Hugr::load(buf_reader, Some(extensions)) - .map_err(serde::de::Error::custom) + .map_err(|e| serde::de::Error::custom(format!("{e:?}"))) } } diff --git a/hugr-core/src/extension/op_def.rs b/hugr-core/src/extension/op_def.rs index a551a06215..af051f5642 100644 --- a/hugr-core/src/extension/op_def.rs +++ b/hugr-core/src/extension/op_def.rs @@ -12,7 +12,7 @@ use super::{ }; use crate::Hugr; -use crate::envelope::serde_with::AsStringEnvelope; +use crate::envelope::serde_with::AsBinaryEnvelope; use crate::ops::{OpName, OpNameRef}; use crate::types::type_param::{TypeArg, TypeParam, check_term_types}; use crate::types::{FuncValueType, PolyFuncType, PolyFuncTypeRV, Signature}; @@ -268,8 +268,12 @@ impl Debug for SignatureFunc { /// Different ways that an [OpDef] can lower operation nodes i.e. provide a Hugr /// that implements the operation using a set of other extensions. +/// +/// Does not implement [`serde::Deserialize`] directly since the serde error for +/// untagged enums is unhelpful. Use [`deserialize_lower_funcs`] with +/// [`serde(deserialize_with = "deserialize_lower_funcs")] instead. #[serde_as] -#[derive(serde::Deserialize, serde::Serialize)] +#[derive(serde::Serialize)] #[serde(untagged)] pub enum LowerFunc { /// Lowering to a fixed Hugr. Since this cannot depend upon the [TypeArg]s, @@ -281,7 +285,7 @@ pub enum LowerFunc { /// [OpDef] /// /// [ExtensionOp]: crate::ops::ExtensionOp - #[serde_as(as = "Box")] + #[serde_as(as = "Box")] hugr: Box, }, /// Custom binary function that can (fallibly) compute a Hugr @@ -304,7 +308,7 @@ where #[derive(serde::Deserialize)] struct FixedHugrDeserializer { pub extensions: ExtensionSet, - #[serde_as(as = "Box")] + #[serde_as(as = "Box")] pub hugr: Box, } diff --git a/hugr-py/tests/conftest.py b/hugr-py/tests/conftest.py index 34835b809c..7855483b22 100644 --- a/hugr-py/tests/conftest.py +++ b/hugr-py/tests/conftest.py @@ -226,6 +226,12 @@ def validate( h1_hash == h2_hash ), f"HUGRs are not the same for {write_fmt} -> {load_fmt}" + # Lowering functions are currently ignored in Python, + # because we don't support loading -model envelopes yet. + for ext in loaded.extensions: + for op in ext.operations.values(): + assert op.lower_funcs == [] + @dataclass(frozen=True, order=True) class _NodeHash: diff --git a/hugr-py/tests/test_package.py b/hugr-py/tests/test_package.py index 2a11e0331f..100d47359c 100644 --- a/hugr-py/tests/test_package.py +++ b/hugr-py/tests/test_package.py @@ -62,8 +62,4 @@ def test_lower_func(): pkg = Package([hugr.hugr], [ext]) - # Lowering functions are currently ignored in Python, - # because we don't support loading -model envelopes yet. - assert pkg.extensions[0].operations["dummy_op"].lower_funcs == [] - validate(pkg) From 7acf2963f8f95b933a12a4b237dcc7f5e5ce5718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn=20Borgna?= Date: Thu, 17 Jul 2025 14:00:41 +0100 Subject: [PATCH 3/6] Update schema comment --- hugr-py/src/hugr/_serialization/extension.py | 4 ++-- specification/schema/hugr_schema_live.json | 1 + specification/schema/hugr_schema_strict_live.json | 1 + specification/schema/testing_hugr_schema_live.json | 1 + specification/schema/testing_hugr_schema_strict_live.json | 1 + 5 files changed, 6 insertions(+), 2 deletions(-) diff --git a/hugr-py/src/hugr/_serialization/extension.py b/hugr-py/src/hugr/_serialization/extension.py index a06c63f9fa..fed975fa61 100644 --- a/hugr-py/src/hugr/_serialization/extension.py +++ b/hugr-py/src/hugr/_serialization/extension.py @@ -66,11 +66,11 @@ def deserialize(self, extension: ext.Extension) -> ext.TypeDef: class FixedHugr(ConfiguredBaseModel): - """Fixed HUGR used to define the lowering of an operations. + """Fixed HUGR used to define the lowering of an operation. Args: extensions: Extensions used in the HUGR. - hugr: Base64 encoded HUGR envelope. + hugr: Base64-encoded HUGR envelope. """ extensions: ExtensionSet diff --git a/specification/schema/hugr_schema_live.json b/specification/schema/hugr_schema_live.json index a9cbd06d01..b9dde9121e 100644 --- a/specification/schema/hugr_schema_live.json +++ b/specification/schema/hugr_schema_live.json @@ -581,6 +581,7 @@ "type": "object" }, "FixedHugr": { + "description": "Fixed HUGR used to define the lowering of an operation.\n\nArgs:\n extensions: Extensions used in the HUGR.\n hugr: Base64-encoded HUGR envelope.", "properties": { "extensions": { "items": { diff --git a/specification/schema/hugr_schema_strict_live.json b/specification/schema/hugr_schema_strict_live.json index 90bbbe3928..0d440a51b1 100644 --- a/specification/schema/hugr_schema_strict_live.json +++ b/specification/schema/hugr_schema_strict_live.json @@ -581,6 +581,7 @@ "type": "object" }, "FixedHugr": { + "description": "Fixed HUGR used to define the lowering of an operation.\n\nArgs:\n extensions: Extensions used in the HUGR.\n hugr: Base64-encoded HUGR envelope.", "properties": { "extensions": { "items": { diff --git a/specification/schema/testing_hugr_schema_live.json b/specification/schema/testing_hugr_schema_live.json index cace6b28a5..581ccc8291 100644 --- a/specification/schema/testing_hugr_schema_live.json +++ b/specification/schema/testing_hugr_schema_live.json @@ -581,6 +581,7 @@ "type": "object" }, "FixedHugr": { + "description": "Fixed HUGR used to define the lowering of an operation.\n\nArgs:\n extensions: Extensions used in the HUGR.\n hugr: Base64-encoded HUGR envelope.", "properties": { "extensions": { "items": { diff --git a/specification/schema/testing_hugr_schema_strict_live.json b/specification/schema/testing_hugr_schema_strict_live.json index 5fe940e7ed..bb21f3c639 100644 --- a/specification/schema/testing_hugr_schema_strict_live.json +++ b/specification/schema/testing_hugr_schema_strict_live.json @@ -581,6 +581,7 @@ "type": "object" }, "FixedHugr": { + "description": "Fixed HUGR used to define the lowering of an operation.\n\nArgs:\n extensions: Extensions used in the HUGR.\n hugr: Base64-encoded HUGR envelope.", "properties": { "extensions": { "items": { From 4e9e1ee3a52f1ca2584813554be9bfab4d2b40cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn=20Borgna?= Date: Thu, 17 Jul 2025 14:11:20 +0100 Subject: [PATCH 4/6] Fix doclink visibility --- hugr-core/src/extension.rs | 2 +- hugr-core/src/extension/op_def.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hugr-core/src/extension.rs b/hugr-core/src/extension.rs index bc071b0130..f9ed5d1f75 100644 --- a/hugr-core/src/extension.rs +++ b/hugr-core/src/extension.rs @@ -36,7 +36,7 @@ mod type_def; pub use const_fold::{ConstFold, ConstFoldResult, Folder, fold_out_row}; pub use op_def::{ CustomSignatureFunc, CustomValidator, LowerFunc, OpDef, SignatureFromArgs, SignatureFunc, - ValidateJustArgs, ValidateTypeArgs, + ValidateJustArgs, ValidateTypeArgs, deserialize_lower_funcs, }; pub use prelude::{PRELUDE, PRELUDE_REGISTRY}; pub use type_def::{TypeDef, TypeDefBound}; diff --git a/hugr-core/src/extension/op_def.rs b/hugr-core/src/extension/op_def.rs index af051f5642..6a2b5ab69f 100644 --- a/hugr-core/src/extension/op_def.rs +++ b/hugr-core/src/extension/op_def.rs @@ -300,7 +300,7 @@ pub enum LowerFunc { /// deserialization fails it just returns an opaque "data did not match any /// variant of untagged enum LowerFunc" error. This function will return the /// internal errors instead. -fn deserialize_lower_funcs<'de, D>(deserializer: D) -> Result, D::Error> +pub fn deserialize_lower_funcs<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { From cda40c0afa9e8681d8d051e05f1e32f3fd51d4a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn=20Borgna?= Date: Fri, 18 Jul 2025 13:33:25 +0100 Subject: [PATCH 5/6] encode binary hugrs... --- hugr-core/src/envelope/serde_with.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/hugr-core/src/envelope/serde_with.rs b/hugr-core/src/envelope/serde_with.rs index d01bc5ea52..6087c948a7 100644 --- a/hugr-core/src/envelope/serde_with.rs +++ b/hugr-core/src/envelope/serde_with.rs @@ -392,7 +392,7 @@ macro_rules! impl_serde_as_binary_envelope { // string, we may have a legacy HUGR serde structure instead. In that // case, we can add an envelope header and try again. // - // TODO: Remove this fallback in 0.21.0 + // TODO: Remove this fallback in a breaking change let deserializer = serde::de::value::MapAccessDeserializer::new(map); #[allow(deprecated)] let mut hugr = @@ -443,13 +443,17 @@ macro_rules! impl_serde_as_binary_envelope { extra_extensions.register_updated(ext.clone()); } } + use $crate::envelope::serde_with::base64::{EncoderStringWriter, STANDARD}; - let str = source - .store_str_with_exts( - $crate::envelope::EnvelopeConfig::text(), + let mut writer = EncoderStringWriter::new(&STANDARD); + source + .store_with_exts( + &mut writer, + $crate::envelope::EnvelopeConfig::binary(), &extra_extensions, ) .map_err(serde::ser::Error::custom)?; + let str = writer.into_inner(); serializer.collect_str(&str) } } From 152507a3fe121af1ae5f5938d1cd69c327afc8b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn=20Borgna?= Date: Fri, 18 Jul 2025 15:33:39 +0100 Subject: [PATCH 6/6] cross-compat tests --- hugr-core/src/envelope/serde_with.rs | 119 +++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/hugr-core/src/envelope/serde_with.rs b/hugr-core/src/envelope/serde_with.rs index 6087c948a7..b7fc7d3d14 100644 --- a/hugr-core/src/envelope/serde_with.rs +++ b/hugr-core/src/envelope/serde_with.rs @@ -472,3 +472,122 @@ pub mod base64 { pub use base64::read::DecoderReader; pub use base64::write::EncoderStringWriter; } + +#[cfg(test)] +mod test { + use rstest::rstest; + use serde::{Deserialize, Serialize}; + use serde_with::serde_as; + + use crate::Hugr; + use crate::package::Package; + + use super::*; + + #[serde_as] + #[derive(Deserialize, Serialize)] + struct TextPkg { + #[serde_as(as = "AsStringEnvelope")] + data: Package, + } + + #[serde_as] + #[derive(Default, Deserialize, Serialize)] + struct TextHugr { + #[serde_as(as = "AsStringEnvelope")] + data: Hugr, + } + + #[serde_as] + #[derive(Deserialize, Serialize)] + struct BinaryPkg { + #[serde_as(as = "AsBinaryEnvelope")] + data: Package, + } + + #[serde_as] + #[derive(Default, Deserialize, Serialize)] + struct BinaryHugr { + #[serde_as(as = "AsBinaryEnvelope")] + data: Hugr, + } + + #[derive(Default, Deserialize, Serialize)] + struct LegacyHugr { + #[serde(deserialize_with = "Hugr::serde_deserialize")] + #[serde(serialize_with = "Hugr::serde_serialize")] + data: Hugr, + } + + impl Default for TextPkg { + fn default() -> Self { + // Default package with a single hugr (so it can be decoded as a hugr too). + Self { + data: Package::from_hugr(Hugr::default()), + } + } + } + + impl Default for BinaryPkg { + fn default() -> Self { + // Default package with a single hugr (so it can be decoded as a hugr too). + Self { + data: Package::from_hugr(Hugr::default()), + } + } + } + + fn decode serde::Deserialize<'a>>(encoded: String) -> Result<(), serde_json::Error> { + let _: T = serde_json::de::from_str(&encoded)?; + Ok(()) + } + + #[rstest] + // Text formats are swappable + #[case::text_pkg_text_pkg(TextPkg::default(), decode::, false)] + #[case::text_pkg_text_hugr(TextPkg::default(), decode::, false)] + #[case::text_hugr_text_pkg(TextHugr::default(), decode::, false)] + #[case::text_hugr_text_hugr(TextHugr::default(), decode::, false)] + // Binary formats can read each other + #[case::bin_pkg_bin_pkg(BinaryPkg::default(), decode::, false)] + #[case::bin_pkg_bin_hugr(BinaryPkg::default(), decode::, false)] + #[case::bin_hugr_bin_pkg(BinaryHugr::default(), decode::, false)] + #[case::bin_hugr_bin_hugr(BinaryHugr::default(), decode::, false)] + // Binary formats can read text ones + #[case::text_pkg_bin_pkg(TextPkg::default(), decode::, false)] + #[case::text_pkg_bin_hugr(TextPkg::default(), decode::, false)] + #[case::text_hugr_bin_pkg(TextHugr::default(), decode::, false)] + #[case::text_hugr_bin_hugr(TextHugr::default(), decode::, false)] + // But text formats can't read binary + #[case::bin_pkg_text_pkg(BinaryPkg::default(), decode::, true)] + #[case::bin_pkg_text_hugr(BinaryPkg::default(), decode::, true)] + #[case::bin_hugr_text_pkg(BinaryHugr::default(), decode::, true)] + #[case::bin_hugr_text_hugr(BinaryHugr::default(), decode::, true)] + // We can read old hugrs into hugrs, but not packages + #[case::legacy_hugr_text_pkg(LegacyHugr::default(), decode::, true)] + #[case::legacy_hugr_text_hugr(LegacyHugr::default(), decode::, false)] + #[case::legacy_hugr_bin_pkg(LegacyHugr::default(), decode::, true)] + #[case::legacy_hugr_bin_hugr(LegacyHugr::default(), decode::, false)] + // Decoding any new format as legacy hugr always fails + #[case::text_pkg_legacy_hugr(TextPkg::default(), decode::, true)] + #[case::text_hugr_legacy_hugr(TextHugr::default(), decode::, true)] + #[case::bin_pkg_legacy_hugr(BinaryPkg::default(), decode::, true)] + #[case::bin_hugr_legacy_hugr(BinaryHugr::default(), decode::, true)] + fn check_format_compatibility( + #[case] encoder: impl serde::Serialize, + #[case] decoder: fn(String) -> Result<(), serde_json::Error>, + #[case] errors: bool, + ) { + let encoded = serde_json::to_string(&encoder).unwrap(); + let decoded = decoder(encoded); + match (errors, decoded) { + (false, Err(e)) => { + panic!("Decoding error: {e}"); + } + (true, Ok(_)) => { + panic!("Roundtrip should have failed"); + } + _ => {} + } + } +}