Skip to content

Commit 53002dd

Browse files
committed
feat: return description output to python on error
1 parent 3b177a7 commit 53002dd

File tree

4 files changed

+130
-23
lines changed

4 files changed

+130
-23
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-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/cli.py

Lines changed: 31 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:
@@ -221,17 +227,24 @@ def describe(
221227
222228
Returns:
223229
Structured package description as a PackageDesc object.
230+
231+
Raises:
232+
HugrCliDescribeError: On error during package description. The exception
233+
contains partial output if available.
224234
"""
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-
)
235+
try:
236+
output = describe_str(
237+
hugr_bytes,
238+
_json=True,
239+
packaged_extensions=packaged_extensions,
240+
no_resolved_extensions=no_resolved_extensions,
241+
public_symbols=public_symbols,
242+
generator_claimed_extensions=generator_claimed_extensions,
243+
no_std=no_std,
244+
extensions=extensions,
245+
)
246+
except HugrCliDescribeError as e:
247+
output = e.args[1]
235248
return PackageDesc.model_validate_json(output)
236249

237250

@@ -265,6 +278,9 @@ def convert(
265278
266279
Returns:
267280
Converted package as bytes.
281+
282+
Raises:
283+
HugrCliError: On conversion failure or other CLI errors.
268284
"""
269285
args = ["convert"]
270286
if format is not None:
@@ -300,6 +316,9 @@ def mermaid(
300316
301317
Returns:
302318
Mermaid diagram output as a string.
319+
320+
Raises:
321+
HugrCliError: On mermaid generation failure or other CLI errors.
303322
"""
304323
args = ["mermaid"]
305324
if validate:

hugr-py/tests/test_cli.py

Lines changed: 31 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,31 @@ 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

0 commit comments

Comments
 (0)