Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ jobs:
target: ${{ matrix.target }}
manylinux: "2014"
container: ${{ matrix.container }}
args: --release --locked --out dist --interpreter ${{ runner.os == 'Linux' && 'python3.10' || 'python' }}
args: --release --locked --strip true --out dist --interpreter ${{ runner.os == 'Linux' && 'python3.10' || 'python' }}
- name: Test installed wheel
run: |
python -m pip install --only-binary=:all: dist/*.whl pytest
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@ python = ["dep:pyo3"]

[dependencies]
log = "0.4"
pyo3 = { version = "0.29.2", features = ["abi3-py310"], optional = true }
pyo3 = { version = "0.29.2", features = ["abi3-py310", "experimental-inspect"], optional = true }
46 changes: 41 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ QIR schema as follows:
- `QIRTUPLE` becomes `TUPLE`

Malformed values are skipped rather than raising, which makes the formatter
safe to use on partially clean result streams.
safe to use on partially clean result streams. Integer records use the signed
64-bit domain defined by the QIR record-output ABI. Python integers outside
`-(2**63)` through `2**63 - 1` are malformed and are skipped with a warning.

## Rust API

Expand All @@ -93,6 +95,27 @@ methods with typed Rust inputs. Writer methods append to a concrete `QirOutput`
buffer containing text and a malformed-value count. Floats use Rust's standard
formatting, not Python's exact decimal notation.

Consumers can rename the package dependency to give the library an idiomatic
crate name:

```toml
[dependencies]
qir_formatter = { package = "qir-formatter", git = "https://github.com/Quantinuum/qir-formatter" }
```

```rust
use qir_formatter::{
QShotValType, QirLabeledFormatter, QirMetadata, QsysShotItemValue, QsysShots,
};

let results: QsysShots = vec![vec![(
"USER:INT:answer".into(),
QsysShotItemValue::Scalar(QShotValType::Int(42)),
)]];
let output = QirLabeledFormatter::new().qir_labeled_output(&results, &QirMetadata::new());
assert!(output.contains("OUTPUT\tINT\t42\tanswer\n"));
```

Malformed-value warnings use Rust's `log` crate with debug-formatted context.
The calling application configures the logger. The Python adapter also emits
the original plain warning through Python's `logging` module.
Expand Down Expand Up @@ -141,10 +164,23 @@ cargo test
uv run pytest
```

The Python compatibility suite in `tests/` verifies the public bindings against
the original Python behavior. Both suites share the fixtures in `src/tests/data/`
and run in CI. After editing Rust source, rebuild the extension with
`uv sync --all-groups --reinstall-package qir-formatter` before running pytest.
The Python compatibility suite in `tests/` is a permanent release gate for the
public bindings and verifies them against the original Python behavior. It
complements the Rust unit tests and should remain in CI. Both suites share the
fixtures in `src/tests/data/` and run in CI. After editing Rust source, rebuild
the extension with `uv sync --all-groups --reinstall-package qir-formatter`
before running pytest.

The private extension stub is generated from the PyO3 declarations. Regenerate
it after changing the Python binding surface:

```sh
uv run maturin generate-stubs --locked --out src
```

The normal check script regenerates the stub and fails in CI if the committed
file is stale. Release wheels package the checked generated stub and exercise it
through the installed-wheel tests.

### Dependency Audit

Expand Down
8 changes: 6 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ Repository = "https://github.com/Quantinuum/qir-formatter"
Issues = "https://github.com/Quantinuum/qir-formatter/issues"

[dependency-groups]
dev = [{ include-group = "lint" }, { include-group = "test" }]
dev = [
{ include-group = "lint" },
{ include-group = "test" },
"maturin>=1.15,<2",
]
lint = [
"ruff>=0.15.8",
"ty>=0.0.26",
Expand All @@ -55,6 +59,7 @@ test = ["pytest>=9.0.3"]
testpaths = ["tests"]

[tool.ruff]
extend-exclude = ["src/qir_formatter/_native.pyi"]
target-version = "py310"

[tool.ty.terminal]
Expand All @@ -77,4 +82,3 @@ auditwheel = "repair"
features = ["pyo3/extension-module"]
module-name = "qir_formatter._native"
python-source = "src"
strip = true
18 changes: 18 additions & 0 deletions src/labeled_formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,22 @@ use std::collections::HashMap;
/// Scalar values in a shot.
#[derive(Clone, Debug, PartialEq)]
pub enum QShotValType {
/// A Boolean value.
Bool(bool),
/// A signed 64-bit integer, matching the QIR record-output ABI.
Int(i64),
/// A double-precision floating-point value.
Float(f64),
}

/// Invalid inputs are retained so the formatter can skip them.
#[derive(Clone, Debug, PartialEq)]
pub enum QsysShotItemValue {
/// A single scalar value.
Scalar(QShotValType),
/// A sequence of scalar values.
List(Vec<QShotValType>),
/// An input that could not be converted to a supported value.
Invalid,
}

Expand All @@ -30,21 +36,31 @@ impl QShotValType {
}
}

/// A named value from one shot, normally using `USER:<TYPE>:<TAG>`.
pub type QsysShotItem = (String, QsysShotItemValue);
/// All named values in one execution shot.
pub type QsysShot = Vec<QsysShotItem>;
/// A collection of execution shots.
pub type QsysShots = Vec<QsysShot>;
/// Metadata written into the first shot header.
pub type QirMetadata = HashMap<String, String>;

/// A concrete output buffer with a malformed-value count.
#[derive(Default)]
pub struct QirOutput {
/// Rendered labeled-output text.
pub text: String,
/// Number of malformed values skipped while rendering.
pub malformed: usize,
}

/// The result of applying type-specific formatting to a value.
pub enum FormattedValue {
/// Preserve the value's ordinary scalar or list representation.
Original,
/// Use the supplied formatted text.
Text(String),
/// Skip the value as malformed.
Invalid,
}

Expand All @@ -53,6 +69,7 @@ pub enum FormattedValue {
pub struct QirLabeledFormatter;

impl QirLabeledFormatter {
/// Construct a formatter.
pub const fn new() -> Self {
Self
}
Expand Down Expand Up @@ -204,6 +221,7 @@ impl QirLabeledFormatter {
self.shot_footer(qo);
}

/// Write all shots, including the schema header and first-shot metadata.
pub fn write_results(
&self,
qo: &mut QirOutput,
Expand Down
58 changes: 45 additions & 13 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,22 @@
//! QIR labeled formatting, with an optional Python compatibility adapter.
//!
//! The Rust API accepts typed shot data and returns QIR 2.1 labeled output:
//!
//! ```
//! use _native::{
//! QShotValType, QirLabeledFormatter, QirMetadata, QsysShotItemValue, QsysShots,
//! };
//!
//! let results: QsysShots = vec![vec![(
//! "USER:INT:answer".into(),
//! QsysShotItemValue::Scalar(QShotValType::Int(42)),
//! )]];
//! let output = QirLabeledFormatter::new().qir_labeled_output(&results, &QirMetadata::new());
//!
//! assert!(output.contains("OUTPUT\tINT\t42\tanswer\n"));
//! ```

#![warn(missing_docs)]

mod labeled_formatter;

Expand All @@ -11,19 +29,10 @@ pub use labeled_formatter::{
mod python;

#[cfg(feature = "python")]
use pyo3::prelude::*;

#[cfg(feature = "python")]
#[pymodule]
fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add_class::<python::PythonQirLabeledFormatter>()?;
let class = module.getattr("QirLabeledFormatter")?;
let validators = pyo3::types::PyTuple::new(
module.py(),
[class.getattr("_val_tag_type")?, class.getattr("_val_null")?],
)?;
class.setattr("val_fns", validators)?;
Ok(())
#[pyo3::pymodule]
mod _native {
#[pymodule_export]
use crate::python::PythonQirLabeledFormatter;
}

#[cfg(test)]
Expand Down Expand Up @@ -62,4 +71,27 @@ mod tests {

assert_eq!(output.text, "OUTPUT\tRESULT_ARRAY\t101\tbits\n");
}

/// Cargo and Python package metadata must describe the same release.
#[test]
fn test_python_and_rust_versions_match() {
let pyproject = include_str!("../pyproject.toml");
let project = pyproject
.split_once("[project]")
.expect("pyproject.toml must contain a [project] table")
.1
.split("\n[")
.next()
.unwrap();
let python_version = project
.lines()
.find_map(|line| {
line.trim()
.strip_prefix("version = \"")
.and_then(|value| value.strip_suffix('"'))
})
.expect("[project] must contain a quoted version");

assert_eq!(python_version, env!("CARGO_PKG_VERSION"));
}
}
70 changes: 19 additions & 51 deletions src/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::{
QsysShotItemValue,
};
use pyo3::prelude::*;
use pyo3::types::{PyBool, PyDict, PyFloat, PyInt, PyList};
use pyo3::types::{PyBool, PyDict, PyFloat, PyInt, PyList, PyTuple};

/// Convert a Python scalar, checking bool before int because bool is an int subclass.
fn scalar(value: &Bound<'_, PyAny>) -> Option<QShotValType> {
Expand Down Expand Up @@ -106,19 +106,32 @@ impl PythonQirLabeledFormatter {
Self(QirLabeledFormatter::new())
}

#[classattr]
fn val_fns(py: Python<'_>) -> PyResult<Py<PyTuple>> {
let class = py.get_type::<Self>();
PyTuple::new(
py,
[class.getattr("_val_tag_type")?, class.getattr("_val_null")?],
)
.map(Bound::unbind)
}

/// No null tags or null values allowed (empty strings permitted for tags)
#[pyo3(signature = (tag: "str", val))]
fn _val_null(&self, tag: &Bound<'_, PyAny>, val: &Bound<'_, PyAny>) -> bool {
self.0
._val_null((!tag.is_none()).then_some(""), &presence(val))
}

/// Tag must be a string
#[pyo3(signature = (tag: "str", _val))]
fn _val_tag_type(&self, tag: &Bound<'_, PyAny>, _val: &Bound<'_, PyAny>) -> bool {
self.0
._val_tag_type(tag.extract::<&str>().ok(), &presence(_val))
}

/// Ensure the tag and value are valid values
#[pyo3(signature = (tag: "str", val))]
fn validate_tag_and_value(&self, tag: &Bound<'_, PyAny>, val: &Bound<'_, PyAny>) -> bool {
self.0
.validate_tag_and_value(tag.extract::<&str>().ok(), &presence(val))
Expand All @@ -130,6 +143,7 @@ impl PythonQirLabeledFormatter {
}

/// Emit opening shot boundary header.
#[pyo3(signature = (qo, attributes: "dict[str, str | None]"))]
fn first_shot_header(
&self,
qo: &Bound<'_, PyAny>,
Expand All @@ -145,6 +159,7 @@ impl PythonQirLabeledFormatter {
}

/// Emit a value with of the given type and tag.
#[pyo3(signature = (qo, ftype: "str", tag: "str", val))]
fn emit(
&self,
qo: &Bound<'_, PyAny>,
Expand All @@ -163,6 +178,7 @@ impl PythonQirLabeledFormatter {
}

/// Format the value if required
#[pyo3(signature = (type_str: "str", val))]
fn format_value(&self, type_str: &str, val: &Bound<'_, PyAny>) -> PyResult<Py<PyAny>> {
match self.0.format_value(type_str, &value(val)) {
FormattedValue::Original => Ok(val.clone().unbind()),
Expand All @@ -178,6 +194,7 @@ impl PythonQirLabeledFormatter {
}

/// Write the first shot, which includes extra metadata
#[pyo3(signature = (qo, shot, attributes: "dict[str, str | None]"))]
fn write_first_shot(
&self,
qo: &Bound<'_, PyAny>,
Expand All @@ -197,6 +214,7 @@ impl PythonQirLabeledFormatter {

/// Given a list of results associated with an `n_qubits` job, return
/// the results in QIR "Labeled" Output Schema format.
#[pyo3(signature = (results, attributes: "dict[str, str | None]"))]
fn qir_labeled_output(
&self,
results: &Bound<'_, PyAny>,
Expand All @@ -215,53 +233,3 @@ impl PythonQirLabeledFormatter {
finish(results.py(), output)
}
}

#[cfg(test)]
mod tests {
use super::PythonQirLabeledFormatter;
use pyo3::impl_::{pyclass::PyClassImpl, pymethods::PyMethodDefType};
use std::{collections::HashMap, ffi::CStr};

/// Editor documentation and runtime help should expose the same docstrings.
#[test]
fn test_formatter_stub_and_runtime_docstrings_match() {
// Inspect the docs PyO3 supplies to Python without initializing an interpreter.
let stub = include_str!("qir_formatter/_native.pyi");
let class = stub.split_once("class QirLabeledFormatter:").unwrap().1;
assert_eq!(
PythonQirLabeledFormatter::RAW_DOC.to_str().unwrap().trim(),
class.split("\"\"\"").nth(1).unwrap().trim()
);
let mut docs = HashMap::new();
for items in PythonQirLabeledFormatter::items_iter() {
for method in items.methods {
if let PyMethodDefType::Method(method) = method {
let method = method.into_raw();
// SAFETY: PyO3 supplies non-null pointers to static C strings.
let name = unsafe { CStr::from_ptr(method.ml_name) }.to_str().unwrap();
let doc = unsafe { CStr::from_ptr(method.ml_doc) }.to_str().unwrap();
docs.insert(
name,
doc.split_once("\n--\n\n")
.map_or(doc, |(_, doc)| doc)
.trim(),
);
}
}
}
for method in class.split(" def ").skip(1) {
let name = method.split_once('(').unwrap().0;
let expected = method
.split("\"\"\"")
.nth(1)
.unwrap()
.trim()
.lines()
.map(str::trim)
.collect::<Vec<_>>()
.join("\n");
assert!(!expected.is_empty(), "Missing stub docstring for {name}");
assert_eq!(docs.get(name).copied(), Some(expected.as_str()), "{name}");
}
}
}
Loading
Loading