Skip to content

Commit 0fd0332

Browse files
ss2165cqc-alec
andauthored
feat(cli, python): programmatic interface to cli with python bindings (#2677)
Closes #2671 ## Goals 1. Have `hugr` cli available in environment when installed via uv (`uvx hugr --help`) 2. Allow use of cli functionality from python package without spawning subprocess ## Implications - Either ship binary with wheel or bind the function directly - chose the latter as it is more flexible and avoids a bunch of build faff - CLI uses argc + stdin for input and stdout for output (by default), we need a way to do this via binding i/o to avoid subprocess ## Primary outcomes - Cli invocation bound via pyo3 to python "package script" with same name as binary, meeting goal 1 - hugr-cli refactored to read inputs from a reader and write outputs to a writer when appropriate, satisfying goal 2. Uses an "override" pattern - when i/o is specified it overrides the corresponding cli-arg ## Secondary outcomes - CLI main.rs contents moved to lib.rs, allowing use by library dependants - Make tracing a default feature for hugr-cli (not used by hugr-py) - `cli` module in python to wrap core sub-commands and parameters, with simply typed interfaces - Python test cli use replaced with bound function calls - meaning no need to build cli before running python tests - Model loading in python via conversion to json uses cli convert function rather than dedicated binding - Dedicated cargo profile for python wheel for smaller wheels (very open to critique on this) - Pydantic models to parse `describe` json output - Remove parallel pytest since it is now faster without it ## Possible issues - Versioning: the cli version is pinned to the rust crates, the python package is not. Meaning `uvx hugr --version` will report a different value to `uv --with hugr run python -c "import hugr; print(hugr.__version__)"`. Are we ok with this? --------- Co-authored-by: Alec Edgington <[email protected]>
1 parent c722823 commit 0fd0332

File tree

24 files changed

+1032
-292
lines changed

24 files changed

+1032
-292
lines changed

.github/workflows/ci-py.yml

Lines changed: 1 addition & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ on:
1313

1414
env:
1515
SCCACHE_GHA_ENABLED: "true"
16-
HUGR_BIN_DIR: ${{ github.workspace }}/target/debug
17-
HUGR_BIN: ${{ github.workspace }}/target/debug/hugr
1816
# Pinned version for the uv package manager
1917
UV_VERSION: "0.9.5"
2018
UV_FROZEN: 1
@@ -68,30 +66,8 @@ jobs:
6866
- name: Lint with ruff
6967
run: uv run ruff check
7068

71-
build_binary:
72-
needs: changes
73-
if: ${{ needs.changes.outputs.python == 'true' }}
74-
75-
name: Build HUGR binary
76-
runs-on: ubuntu-latest
77-
env:
78-
SCCACHE_GHA_ENABLED: "true"
79-
RUSTC_WRAPPER: "sccache"
80-
81-
steps:
82-
- uses: actions/checkout@v5
83-
- uses: mozilla-actions/[email protected]
84-
- name: Install stable toolchain
85-
uses: dtolnay/rust-toolchain@stable
86-
- name: Build HUGR binary
87-
run: cargo build -p hugr-cli
88-
- name: Upload the binary to the artifacts
89-
uses: actions/upload-artifact@v5
90-
with:
91-
name: hugr_binary
92-
path: target/debug/hugr
9369
test:
94-
needs: [changes, build_binary]
70+
needs: [changes]
9571
if: ${{ needs.changes.outputs.python == 'true' }}
9672
name: test python ${{ matrix.python-version.py }}
9773
runs-on: ubuntu-latest
@@ -110,12 +86,6 @@ jobs:
11086
version: ${{ env.UV_VERSION }}
11187
enable-cache: true
11288

113-
- name: Download the hugr binary
114-
uses: actions/download-artifact@v6
115-
with:
116-
name: hugr_binary
117-
path: ${{env.HUGR_BIN_DIR}}
118-
11989
- name: Setup dependencies
12090
run: uv sync --python ${{ matrix.python-version.py }}
12191

@@ -125,13 +95,11 @@ jobs:
12595
- name: Run tests
12696
if: github.event_name == 'merge_group' || !matrix.python-version.coverage
12797
run: |
128-
chmod +x $HUGR_BIN
12998
HUGR_RENDER_DOT=1 uv run pytest
13099
131100
- name: Run python tests with coverage instrumentation
132101
if: github.event_name != 'merge_group' && matrix.python-version.coverage
133102
run: |
134-
chmod +x $HUGR_BIN
135103
HUGR_RENDER_DOT=1 uv run pytest --cov=./ --cov-report=xml
136104
137105
- name: Upload python coverage to codecov.io

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,10 @@ jsonschema.debug = 1
129129
[profile.dist]
130130
inherits = "release"
131131
lto = "thin"
132+
133+
# The profile that 'hugr-py' will build with
134+
[profile.release-py]
135+
inherits = "release"
136+
lto = "fat"
137+
strip = "symbols"
138+
panic = "abort"

hugr-cli/Cargo.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ categories = ["compilers"]
1515
[lib]
1616
bench = false
1717

18+
[features]
19+
default = ["tracing"]
20+
tracing = ["dep:tracing", "dep:tracing-subscriber"]
21+
1822
[dependencies]
1923
clap = { workspace = true, features = ["derive", "cargo"] }
2024
clap-verbosity-flag.workspace = true
@@ -25,8 +29,8 @@ serde = { workspace = true, features = ["derive"] }
2529
clio = { workspace = true, features = ["clap-parse"] }
2630
anyhow.workspace = true
2731
thiserror.workspace = true
28-
tracing = "0.1.41"
29-
tracing-subscriber = { version = "0.3.20", features = ["fmt"] }
32+
tracing = { version = "0.1.41", optional = true }
33+
tracing-subscriber = { version = "0.3.20", features = ["fmt"], optional = true }
3034
tabled = "0.20.0"
3135
schemars = { workspace = true, features = ["derive"] }
3236

hugr-cli/src/convert.rs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use anyhow::Result;
33
use clap::Parser;
44
use clio::Output;
55
use hugr::envelope::{EnvelopeConfig, EnvelopeFormat, ZstdConfig};
6+
use std::io::{Read, Write};
67

78
use crate::CliError;
89
use crate::hugr_io::HugrInputArgs;
@@ -47,9 +48,20 @@ pub struct ConvertArgs {
4748
}
4849

4950
impl ConvertArgs {
50-
/// Convert a HUGR between different envelope formats
51-
pub fn run_convert(&mut self) -> Result<()> {
52-
let (env_config, package) = self.input_args.get_described_package()?;
51+
/// Convert a HUGR between different envelope formats with optional input/output overrides.
52+
///
53+
/// # Arguments
54+
///
55+
/// * `input_override` - Optional reader to use instead of the CLI input argument.
56+
/// * `output_override` - Optional writer to use instead of the CLI output argument.
57+
pub fn run_convert_with_io<R: Read, W: Write>(
58+
&mut self,
59+
input_override: Option<R>,
60+
mut output_override: Option<W>,
61+
) -> Result<()> {
62+
let (env_config, package) = self
63+
.input_args
64+
.get_described_package_with_reader(input_override)?;
5365

5466
// Handle text and binary format flags, which override the format option
5567
let mut config = if self.text {
@@ -78,8 +90,17 @@ impl ConvertArgs {
7890
}
7991

8092
// Write the package with the requested format
81-
hugr::envelope::write_envelope(&mut self.output, &package, config)?;
93+
if let Some(ref mut writer) = output_override {
94+
hugr::envelope::write_envelope(writer, &package, config)?;
95+
} else {
96+
hugr::envelope::write_envelope(&mut self.output, &package, config)?;
97+
}
8298

8399
Ok(())
84100
}
101+
102+
/// Convert a HUGR between different envelope formats
103+
pub fn run_convert(&mut self) -> Result<()> {
104+
self.run_convert_with_io(None::<&[u8]>, None::<Vec<u8>>)
105+
}
85106
}

hugr-cli/src/describe.rs

Lines changed: 113 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use hugr::envelope::ReadError;
88
use hugr::envelope::description::{ExtensionDesc, ModuleDesc, PackageDesc};
99
use hugr::extension::Version;
1010
use hugr::package::Package;
11-
use std::io::Write;
11+
use std::io::{Read, Write};
1212
use tabled::Tabled;
1313
use tabled::derive::display;
1414

@@ -73,20 +73,37 @@ impl ModuleArgs {
7373
}
7474
}
7575
impl DescribeArgs {
76-
/// Load and describe the HUGR package.
77-
pub fn run_describe(&mut self) -> Result<()> {
76+
/// Load and describe the HUGR package with optional input/output overrides.
77+
///
78+
/// # Arguments
79+
///
80+
/// * `input_override` - Optional reader to use instead of the CLI input argument.
81+
/// * `output_override` - Optional writer to use instead of the CLI output argument.
82+
pub fn run_describe_with_io<R: Read, W: Write>(
83+
&mut self,
84+
input_override: Option<R>,
85+
mut output_override: Option<W>,
86+
) -> Result<()> {
7887
if self.json_schema {
7988
let schema = schemars::schema_for!(PackageDescriptionJson);
8089
let schema_json = serde_json::to_string_pretty(&schema)?;
81-
writeln!(self.output, "{schema_json}")?;
90+
if let Some(ref mut writer) = output_override {
91+
writeln!(writer, "{schema_json}")?;
92+
} else {
93+
writeln!(self.output, "{schema_json}")?;
94+
}
8295
return Ok(());
8396
}
84-
let (mut desc, res) = match self.input_args.get_described_package() {
97+
98+
let (mut desc, res) = match self
99+
.input_args
100+
.get_described_package_with_reader(input_override)
101+
{
85102
Ok((desc, pkg)) => (desc, Ok(pkg)),
86103
Err(crate::CliError::ReadEnvelope(ReadError::Payload {
87104
source,
88105
partial_description,
89-
})) => (partial_description, Err(source)), // keep error for later
106+
})) => (partial_description, Err(source)),
90107
Err(e) => return Err(e.into()),
91108
};
92109

@@ -96,91 +113,116 @@ impl DescribeArgs {
96113
}
97114

98115
let res = res.map_err(anyhow::Error::from);
116+
117+
let writer: &mut dyn Write = if let Some(ref mut w) = output_override {
118+
w
119+
} else {
120+
&mut self.output
121+
};
122+
99123
if self.json {
100124
if !self.packaged_extensions {
101125
desc.packaged_extensions.clear();
102126
}
103-
self.output_json(desc, &res)?;
127+
output_json(desc, &res, writer)?;
104128
} else {
105-
self.print_description(desc)?;
129+
print_description(desc, self.packaged_extensions, writer)?;
106130
}
107131

108132
// bubble up any errors
109133
res.map(|_| ())
110134
}
111135

112-
fn print_description(&mut self, desc: PackageDesc) -> Result<()> {
113-
let header = desc.header();
114-
let n_modules = desc.n_modules();
115-
let n_extensions = desc.n_packaged_extensions();
116-
let module_str = if n_modules == 1 { "module" } else { "modules" };
117-
let extension_str = if n_extensions == 1 {
118-
"extension"
119-
} else {
120-
"extensions"
121-
};
122-
writeln!(
123-
self.output,
124-
"{header}\nPackage contains {n_modules} {module_str} and {n_extensions} {extension_str}",
125-
)?;
126-
let summaries: Vec<ModuleSummary> = desc
127-
.modules
128-
.iter()
129-
.map(|m| m.as_ref().map(Into::into).unwrap_or_default())
130-
.collect();
131-
let summary_table = tabled::Table::builder(summaries).index().build();
132-
writeln!(self.output, "{summary_table}")?;
136+
/// Load and describe the HUGR package.
137+
pub fn run_describe(&mut self) -> Result<()> {
138+
self.run_describe_with_io(None::<&[u8]>, None::<Vec<u8>>)
139+
}
140+
}
133141

134-
for (i, module) in desc.modules.into_iter().enumerate() {
135-
writeln!(self.output, "\nModule {i}:")?;
136-
if let Some(module) = module {
137-
self.display_module(module)?;
138-
}
139-
}
140-
if self.packaged_extensions {
141-
writeln!(self.output, "Packaged extensions:")?;
142-
let ext_rows: Vec<ExtensionRow> = desc
143-
.packaged_extensions
144-
.into_iter()
145-
.flatten()
146-
.map(Into::into)
147-
.collect();
148-
let ext_table = tabled::Table::new(ext_rows);
149-
writeln!(self.output, "{ext_table}")?;
142+
/// Print a human-readable description of a package.
143+
fn print_description<W: Write + ?Sized>(
144+
desc: PackageDesc,
145+
show_packaged_extensions: bool,
146+
writer: &mut W,
147+
) -> Result<()> {
148+
let header = desc.header();
149+
let n_modules = desc.n_modules();
150+
let n_extensions = desc.n_packaged_extensions();
151+
let module_str = if n_modules == 1 { "module" } else { "modules" };
152+
let extension_str = if n_extensions == 1 {
153+
"extension"
154+
} else {
155+
"extensions"
156+
};
157+
158+
writeln!(
159+
writer,
160+
"{header}\nPackage contains {n_modules} {module_str} and {n_extensions} {extension_str}",
161+
)?;
162+
163+
let summaries: Vec<ModuleSummary> = desc
164+
.modules
165+
.iter()
166+
.map(|m| m.as_ref().map(Into::into).unwrap_or_default())
167+
.collect();
168+
let summary_table = tabled::Table::builder(summaries).index().build();
169+
writeln!(writer, "{summary_table}")?;
170+
171+
for (i, module) in desc.modules.into_iter().enumerate() {
172+
writeln!(writer, "\nModule {i}:")?;
173+
if let Some(module) = module {
174+
display_module(module, writer)?;
150175
}
151-
Ok(())
152176
}
153-
154-
fn output_json(&mut self, package_desc: PackageDesc, res: &Result<Package>) -> Result<()> {
155-
let err_str = res.as_ref().err().map(|e| format!("{e:?}"));
156-
let json_desc = PackageDescriptionJson {
157-
package_desc,
158-
error: err_str,
159-
};
160-
serde_json::to_writer_pretty(&mut self.output, &json_desc)?;
161-
Ok(())
177+
if show_packaged_extensions {
178+
writeln!(writer, "Packaged extensions:")?;
179+
let ext_rows: Vec<ExtensionRow> = desc
180+
.packaged_extensions
181+
.into_iter()
182+
.flatten()
183+
.map(Into::into)
184+
.collect();
185+
let ext_table = tabled::Table::new(ext_rows);
186+
writeln!(writer, "{ext_table}")?;
162187
}
188+
Ok(())
189+
}
163190

164-
fn display_module(&mut self, desc: ModuleDesc) -> Result<()> {
165-
if let Some(exts) = desc.used_extensions_resolved {
166-
let ext_rows: Vec<ExtensionRow> = exts.into_iter().map(Into::into).collect();
167-
let ext_table = tabled::Table::new(ext_rows);
168-
writeln!(self.output, "Resolved extensions:\n{ext_table}")?;
169-
}
191+
/// Output a package description as JSON.
192+
fn output_json<W: Write + ?Sized>(
193+
package_desc: PackageDesc,
194+
res: &Result<Package>,
195+
writer: &mut W,
196+
) -> Result<()> {
197+
let err_str = res.as_ref().err().map(|e| format!("{e:?}"));
198+
let json_desc = PackageDescriptionJson {
199+
package_desc,
200+
error: err_str,
201+
};
202+
serde_json::to_writer_pretty(writer, &json_desc)?;
203+
Ok(())
204+
}
170205

171-
if let Some(syms) = desc.public_symbols {
172-
let sym_table = tabled::Table::new(syms.into_iter().map(|s| SymbolRow { symbol: s }));
173-
writeln!(self.output, "Public symbols:\n{sym_table}")?;
174-
}
206+
/// Display information about a single module.
207+
fn display_module<W: Write + ?Sized>(desc: ModuleDesc, writer: &mut W) -> Result<()> {
208+
if let Some(exts) = desc.used_extensions_resolved {
209+
let ext_rows: Vec<ExtensionRow> = exts.into_iter().map(Into::into).collect();
210+
let ext_table = tabled::Table::new(ext_rows);
211+
writeln!(writer, "Resolved extensions:\n{ext_table}")?;
212+
}
175213

176-
if let Some(exts) = desc.used_extensions_generator {
177-
let ext_rows: Vec<ExtensionRow> = exts.into_iter().map(Into::into).collect();
178-
let ext_table = tabled::Table::new(ext_rows);
179-
writeln!(self.output, "Generator claimed extensions:\n{ext_table}")?;
180-
}
214+
if let Some(syms) = desc.public_symbols {
215+
let sym_table = tabled::Table::new(syms.into_iter().map(|s| SymbolRow { symbol: s }));
216+
writeln!(writer, "Public symbols:\n{sym_table}")?;
217+
}
181218

182-
Ok(())
219+
if let Some(exts) = desc.used_extensions_generator {
220+
let ext_rows: Vec<ExtensionRow> = exts.into_iter().map(Into::into).collect();
221+
let ext_table = tabled::Table::new(ext_rows);
222+
writeln!(writer, "Generator claimed extensions:\n{ext_table}")?;
183223
}
224+
225+
Ok(())
184226
}
185227

186228
#[derive(serde::Serialize, schemars::JsonSchema)]

0 commit comments

Comments
 (0)