Skip to content

Commit f483146

Browse files
authored
feat: return description output to python on error (#2681)
1 parent 3b177a7 commit f483146

File tree

6 files changed

+235
-25
lines changed

6 files changed

+235
-25
lines changed

hugr-cli/src/lib.rs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,13 +247,35 @@ impl CliArgs {
247247
///
248248
/// The `gen-extensions` and `external` commands don't support byte I/O
249249
/// and should use the normal `run_cli()` method instead.
250-
pub fn run_with_io(self, input: impl std::io::Read) -> Result<Vec<u8>> {
250+
pub fn run_with_io(self, input: impl std::io::Read) -> Result<Vec<u8>, RunWithIoError> {
251251
let mut output = Vec::new();
252-
self.command.run_with_io(Some(input), Some(&mut output))?;
253-
Ok(output)
252+
let is_describe = matches!(self.command, CliCommand::Describe(_));
253+
let res = self.command.run_with_io(Some(input), Some(&mut output));
254+
match (res, is_describe) {
255+
(Ok(()), _) => Ok(output),
256+
(Err(e), true) => Err(RunWithIoError::Describe { source: e, output }),
257+
(Err(e), false) => Err(RunWithIoError::Other(e)),
258+
}
254259
}
255260
}
256261

262+
#[derive(Debug, Error)]
263+
#[non_exhaustive]
264+
#[error("Error running CLI command with IO.")]
265+
/// Error type for `run_with_io` method.
266+
pub enum RunWithIoError {
267+
/// Error describing HUGR package.
268+
Describe {
269+
#[source]
270+
/// Error returned from describe command.
271+
source: anyhow::Error,
272+
/// Describe command output.
273+
output: Vec<u8>,
274+
},
275+
/// Non-describe command error.
276+
Other(anyhow::Error),
277+
}
278+
257279
fn run_external(args: Vec<OsString>) -> Result<()> {
258280
// External subcommand support: invoke `hugr-<subcommand>`
259281
if args.is_empty() {

hugr-core/src/envelope/reader.rs

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use hugr_model::v0::table;
55
use itertools::{Either, Itertools as _};
66

77
use crate::HugrView as _;
8-
use crate::envelope::description::PackageDesc;
8+
use crate::envelope::description::{ExtensionDesc, ModuleDesc, PackageDesc};
99
use crate::envelope::header::{EnvelopeFormat, HeaderError};
1010
use crate::envelope::{
1111
EnvelopeError, EnvelopeHeader, ExtensionBreakingError, FormatUnsupportedError,
@@ -103,6 +103,32 @@ impl<R: BufRead> EnvelopeReader<R> {
103103
self.registry.extend(extensions);
104104
}
105105

106+
/// Handle extension resolution errors by recording missing extensions in the description.
107+
///
108+
/// This function inspects the error and adds any missing extensions to the module description
109+
/// with a default version of 0.0.0.
110+
fn handle_resolution_error(desc: &mut ModuleDesc, err: &ExtensionResolutionError) {
111+
match err {
112+
ExtensionResolutionError::MissingOpExtension {
113+
missing_extension, ..
114+
}
115+
| ExtensionResolutionError::MissingTypeExtension {
116+
missing_extension, ..
117+
} => desc.extend_used_extensions_resolved([ExtensionDesc::new(
118+
missing_extension,
119+
crate::extension::Version::new(0, 0, 0),
120+
)]),
121+
ExtensionResolutionError::InvalidConstTypes {
122+
missing_extensions, ..
123+
} => desc.extend_used_extensions_resolved(
124+
missing_extensions
125+
.iter()
126+
.map(|ext| ExtensionDesc::new(ext, crate::extension::Version::new(0, 0, 0))),
127+
),
128+
_ => {}
129+
}
130+
}
131+
106132
fn read_impl(&mut self) -> Result<Package, PayloadError> {
107133
let mut package = match self.header().format {
108134
EnvelopeFormat::PackageJson => self.decode_json()?,
@@ -121,7 +147,10 @@ impl<R: BufRead> EnvelopeReader<R> {
121147
check_breaking_extensions(module.extensions(), used_exts.drain(..))?;
122148
}
123149

124-
module.resolve_extension_defs(&self.registry)?;
150+
module
151+
.resolve_extension_defs(&self.registry)
152+
.inspect_err(|err| Self::handle_resolution_error(desc, err))?;
153+
125154
// overwrite the description with the actual module read,
126155
// cheap so ok to repeat.
127156
desc.load_from_hugr(&module);
@@ -415,4 +444,77 @@ mod test {
415444
assert_eq!(description.header, header);
416445
assert_eq!(description.n_modules(), 0); // No valid modules should be set
417446
}
447+
448+
#[test]
449+
fn test_handle_resolution_error() {
450+
use crate::extension::ExtensionId;
451+
use crate::ops::{OpName, constant::ValueName};
452+
use crate::types::TypeName;
453+
454+
let mut desc = ModuleDesc::default();
455+
let handle_error = |d: &mut ModuleDesc, err: &ExtensionResolutionError| {
456+
EnvelopeReader::<Cursor<Vec<u8>>>::handle_resolution_error(d, err)
457+
};
458+
let assert_extensions = |d: &ModuleDesc, expected_ids: &[&ExtensionId]| {
459+
let resolved = d.used_extensions_resolved.as_ref().unwrap();
460+
assert_eq!(resolved.len(), expected_ids.len());
461+
let names: Vec<_> = resolved.iter().map(|e| &e.name).collect();
462+
for ext_id in expected_ids {
463+
assert!(names.contains(&&ext_id.to_string()));
464+
}
465+
assert!(
466+
resolved
467+
.iter()
468+
.all(|e| e.version == crate::extension::Version::new(0, 0, 0))
469+
);
470+
};
471+
472+
// Test MissingOpExtension
473+
let ext_id = ExtensionId::new("test.extension").unwrap();
474+
let error = ExtensionResolutionError::MissingOpExtension {
475+
node: None,
476+
op: OpName::new("test.op"),
477+
missing_extension: ext_id.clone(),
478+
available_extensions: vec![],
479+
};
480+
handle_error(&mut desc, &error);
481+
assert_extensions(&desc, &[&ext_id]);
482+
483+
// Test MissingTypeExtension
484+
desc.used_extensions_resolved = None;
485+
let ext_id2 = ExtensionId::new("test.extension2").unwrap();
486+
let error = ExtensionResolutionError::MissingTypeExtension {
487+
node: None,
488+
ty: TypeName::new("test.type"),
489+
missing_extension: ext_id2.clone(),
490+
available_extensions: vec![],
491+
};
492+
handle_error(&mut desc, &error);
493+
assert_extensions(&desc, &[&ext_id2]);
494+
495+
// Test InvalidConstTypes with multiple extensions
496+
desc.used_extensions_resolved = None;
497+
let ext_id3 = ExtensionId::new("test.extension3").unwrap();
498+
let ext_id4 = ExtensionId::new("test.extension4").unwrap();
499+
let mut missing_exts = crate::extension::ExtensionSet::new();
500+
missing_exts.insert(ext_id3.clone());
501+
missing_exts.insert(ext_id4.clone());
502+
503+
let error = ExtensionResolutionError::InvalidConstTypes {
504+
value: ValueName::new("test.value"),
505+
missing_extensions: missing_exts,
506+
};
507+
handle_error(&mut desc, &error);
508+
assert_extensions(&desc, &[&ext_id3, &ext_id4]);
509+
510+
// Test other error variant (should not add anything)
511+
desc.used_extensions_resolved = None;
512+
let error = ExtensionResolutionError::WrongTypeDefExtension {
513+
extension: ExtensionId::new("ext1").unwrap(),
514+
def: TypeName::new("def"),
515+
wrong_extension: ExtensionId::new("ext2").unwrap(),
516+
};
517+
handle_error(&mut desc, &error);
518+
assert!(desc.used_extensions_resolved.is_none());
519+
}
418520
}

hugr-py/rust/lib.rs

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,41 @@
11
//! Supporting Rust library for the hugr Python bindings.
22
3-
use hugr_cli::CliArgs;
3+
use hugr_cli::{CliArgs, RunWithIoError};
44
use hugr_model::v0::ast;
5-
use pyo3::{exceptions::PyValueError, prelude::*};
5+
use pyo3::{create_exception, exceptions::PyException, exceptions::PyValueError, prelude::*};
6+
7+
// Define custom exceptions
8+
create_exception!(
9+
_hugr,
10+
HugrCliError,
11+
PyException,
12+
"Base exception for HUGR CLI errors."
13+
);
14+
create_exception!(
15+
_hugr,
16+
HugrCliDescribeError,
17+
HugrCliError,
18+
"Exception for HUGR CLI describe command errors with partial output."
19+
);
20+
21+
/// Helper to convert RunWithIoError to Python exception
22+
fn cli_error_to_py(err: RunWithIoError) -> PyErr {
23+
match err {
24+
RunWithIoError::Describe { source, output } => {
25+
// Convert output bytes to string, falling back to empty string if invalid UTF-8
26+
let output_str = String::from_utf8(output).unwrap_or_else(|e| {
27+
format!("<Invalid UTF-8 output: {} bytes>", e.as_bytes().len())
28+
});
29+
30+
HugrCliDescribeError::new_err((format!("{:?}", source), output_str))
31+
}
32+
RunWithIoError::Other(e) => HugrCliError::new_err(format!("{:?}", e)),
33+
_ => {
34+
// Catch-all for any future error variants (non_exhaustive enum)
35+
HugrCliError::new_err(format!("{:?}", err))
36+
}
37+
}
38+
}
639

740
macro_rules! syntax_to_and_from_string {
841
($name:ident, $ty:ty) => {
@@ -93,13 +126,18 @@ fn cli_with_io(mut args: Vec<String>, input_bytes: Option<&[u8]>) -> PyResult<Ve
93126
args.insert(0, String::new());
94127
let cli_args = CliArgs::new_from_args(args);
95128
let input = input_bytes.unwrap_or(&[]);
96-
cli_args
97-
.run_with_io(input)
98-
.map_err(|e| PyValueError::new_err(format!("{:?}", e)))
129+
cli_args.run_with_io(input).map_err(cli_error_to_py)
99130
}
100131

101132
#[pymodule]
102133
fn _hugr(m: &Bound<'_, PyModule>) -> PyResult<()> {
134+
// Register custom exceptions
135+
m.add("HugrCliError", m.py().get_type::<HugrCliError>())?;
136+
m.add(
137+
"HugrCliDescribeError",
138+
m.py().get_type::<HugrCliDescribeError>(),
139+
)?;
140+
103141
m.add_function(wrap_pyfunction!(term_to_string, m)?)?;
104142
m.add_function(wrap_pyfunction!(string_to_term, m)?)?;
105143
m.add_function(wrap_pyfunction!(node_to_string, m)?)?;

hugr-py/src/hugr/_hugr/__init__.pyi

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import hugr.model
22

3+
class HugrCliError(Exception): ...
4+
class HugrCliDescribeError(HugrCliError): ...
5+
36
def term_to_string(term: hugr.model.Term) -> str: ...
47
def string_to_term(string: str) -> hugr.model.Term: ...
58
def node_to_string(node: hugr.model.Node) -> str: ...

hugr-py/src/hugr/cli.py

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from pydantic import BaseModel
99

10-
from hugr._hugr import cli_with_io
10+
from hugr._hugr import HugrCliDescribeError, HugrCliError, cli_with_io
1111

1212
__all__ = [
1313
"cli_with_io",
@@ -20,6 +20,8 @@
2020
"ModuleDesc",
2121
"ExtensionDesc",
2222
"EntrypointDesc",
23+
"HugrCliError",
24+
"HugrCliDescribeError",
2325
]
2426

2527

@@ -50,7 +52,7 @@ def validate(
5052
extensions: Paths to additional serialised extensions needed to load the HUGR.
5153
5254
Raises:
53-
ValueError: On validation failure.
55+
HugrCliError: On validation failure or other CLI errors.
5456
"""
5557
args = _add_input_args(["validate"], no_std, extensions)
5658
cli_with_io(args, hugr_bytes)
@@ -175,6 +177,10 @@ def describe_str(
175177
176178
Returns:
177179
Text description of the package.
180+
181+
Raises:
182+
HugrCliDescribeError: On error during package description. The exception
183+
contains partial output if available.
178184
"""
179185
args = ["describe"]
180186
if _json:
@@ -222,16 +228,19 @@ def describe(
222228
Returns:
223229
Structured package description as a PackageDesc object.
224230
"""
225-
output = describe_str(
226-
hugr_bytes,
227-
_json=True,
228-
packaged_extensions=packaged_extensions,
229-
no_resolved_extensions=no_resolved_extensions,
230-
public_symbols=public_symbols,
231-
generator_claimed_extensions=generator_claimed_extensions,
232-
no_std=no_std,
233-
extensions=extensions,
234-
)
231+
try:
232+
output = describe_str(
233+
hugr_bytes,
234+
_json=True,
235+
packaged_extensions=packaged_extensions,
236+
no_resolved_extensions=no_resolved_extensions,
237+
public_symbols=public_symbols,
238+
generator_claimed_extensions=generator_claimed_extensions,
239+
no_std=no_std,
240+
extensions=extensions,
241+
)
242+
except HugrCliDescribeError as e:
243+
output = e.args[1]
235244
return PackageDesc.model_validate_json(output)
236245

237246

@@ -265,6 +274,9 @@ def convert(
265274
266275
Returns:
267276
Converted package as bytes.
277+
278+
Raises:
279+
HugrCliError: On conversion failure or other CLI errors.
268280
"""
269281
args = ["convert"]
270282
if format is not None:
@@ -300,6 +312,9 @@ def mermaid(
300312
301313
Returns:
302314
Mermaid diagram output as a string.
315+
316+
Raises:
317+
HugrCliError: On mermaid generation failure or other CLI errors.
303318
"""
304319
args = ["mermaid"]
305320
if validate:

hugr-py/tests/test_cli.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
import pytest
66

7-
from hugr import cli
8-
from hugr.build import Module
7+
from hugr import cli, tys
8+
from hugr.build import Dfg, Module
99
from hugr.ext import Extension
1010
from hugr.package import Package
1111

@@ -38,7 +38,7 @@ def test_validate_with_bytes_invalid():
3838

3939
invalid_bytes = b"not a valid hugr package"
4040

41-
with pytest.raises(ValueError, match="Bad magic number"):
41+
with pytest.raises(cli.HugrCliError, match="Bad magic number"):
4242
cli.cli_with_io(["validate"], invalid_bytes)
4343

4444

@@ -139,3 +139,33 @@ def test_describe_json_with_packaged_extensions(hugr_with_extension_bytes: bytes
139139

140140
assert desc.uses_extension("ext")
141141
assert not desc.uses_extension("nonexistent_extension")
142+
143+
144+
@pytest.fixture
145+
def hugr_using_ext() -> bytes:
146+
"""A simple HUGR package that uses an extension, but doesn't package it."""
147+
ext = Extension.from_json(EXAMPLE)
148+
u_t = tys.USize()
149+
op = ext.get_op("New").instantiate(
150+
[u_t.type_arg()], concrete_signature=tys.FunctionType([u_t], [])
151+
)
152+
h = Dfg(u_t)
153+
a = h.inputs()[0]
154+
h.add_op(op, a)
155+
h.set_outputs()
156+
157+
package = Package([h.hugr], [])
158+
159+
return package.to_bytes()
160+
161+
162+
def test_failed_describe(hugr_using_ext):
163+
"""Json description still succeeds, with error field populated"""
164+
desc = cli.describe(hugr_using_ext)
165+
mod = desc.modules[0]
166+
assert mod is not None
167+
assert mod.num_nodes == 8 # computed before error
168+
assert isinstance(desc.error, str)
169+
assert "requires extension ext" in desc.error
170+
171+
assert desc.uses_extension("ext")

0 commit comments

Comments
 (0)