diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..575a597 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,101 @@ +name: python release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + +permissions: + contents: write + +jobs: + create-release: + name: create-release + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Get the release version from the tag + if: env.VERSION == '' + run: echo "VERSION=${{ github.ref_name }}" >> $GITHUB_ENV + outputs: + version: ${{ env.VERSION }} + + linux: + runs-on: ubuntu-22.04 + needs: ["create-release"] + + strategy: + matrix: + target: [x86_64, aarch64] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --manifest-path python/Cargo.toml --out dist --find-interpreter + sccache: "true" + manylinux: auto + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-linux-${{ matrix.target }} + path: dist + + macos: + runs-on: macos-latest + needs: ["create-release"] + strategy: + matrix: + target: [x86_64, aarch64] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --manifest-path python/Cargo.toml --out dist --find-interpreter + sccache: "true" + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.target }} + path: dist + + sdist: + # switch back to ubuntu-22.04 once this issue is resolved: + # https://github.com/PyO3/maturin-action/issues/291 + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --manifest-path python/Cargo.toml --out dist + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: dist + + release: + name: Release + runs-on: ubuntu-22.04 + if: "startsWith(github.ref, 'refs/tags/')" + needs: [linux, macos, sdist] + steps: + - uses: actions/download-artifact@v4 + - name: Publish to PyPI + uses: PyO3/maturin-action@v1 + env: + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + with: + command: upload + args: --non-interactive --skip-existing wheels-*/* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 695cb44..c9d898f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -201,6 +201,33 @@ jobs: - name: run run: cargo run --release -p simplify -- inputs/smtlib/arbiter_array_cex_w32d32q16n4b34.smt2 out.smt + python: + name: Test Python Bindings + runs-on: ubuntu-24.04 + timeout-minutes: 5 + strategy: + matrix: + toolchain: + - stable + python: + - "3.10" + + steps: + - name: Update Rust to ${{ matrix.toolchain }} + run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} + - name: Install z3, CVC5, boolector + run: sudo apt install -y z3 cvc5 boolector + - name: Install uv and set the Python version + uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python }} + - uses: actions/checkout@v4 + - name: Install the project + working-directory: python + run: uv sync --locked --all-extras --dev + - name: Test + working-directory: python + run: uv run pytest semver: name: Check Semantic Versioning of Patronus diff --git a/.gitignore b/.gitignore index 93c8763..ae14c3c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ Cargo.lock /perf.data /perf.data.old replay.smt + + +# Python +__pycache__/ diff --git a/Cargo.toml b/Cargo.toml index 638417c..34c2973 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["patronus", "patronus-egraphs", "patronus-dse", "tools/bmc", "tools/egraphs-cond-synth", "tools/sim", "tools/simplify", "tools/view"] +members = ["patronus", "patronus-egraphs", "patronus-dse", "tools/bmc", "tools/egraphs-cond-synth", "tools/sim", "tools/simplify", "tools/view", "python"] [workspace.package] edition = "2024" @@ -9,6 +9,8 @@ repository = "https://github.com/cucapra/patronus" readme = "Readme.md" license = "BSD-3-Clause" rust-version = "1.85.0" +# used by main patronus library and python bindings +version = "0.34.1" [workspace.dependencies] rustc-hash = "2.x" diff --git a/patronus/Cargo.toml b/patronus/Cargo.toml index b06a21a..2e7a70f 100644 --- a/patronus/Cargo.toml +++ b/patronus/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "patronus" -version = "0.34.1" +version.workspace = true description = "Hardware bug-finding toolkit." homepage = "https://kevinlaeufer.com" keywords = ["RTL", "btor", "model-checking", "SMT", "bit-vector"] diff --git a/patronus/src/expr/context.rs b/patronus/src/expr/context.rs index fa936f2..2039112 100644 --- a/patronus/src/expr/context.rs +++ b/patronus/src/expr/context.rs @@ -142,6 +142,24 @@ impl Index for Context { } } +impl Context { + /// Returns the number of interned expressions in this context. + pub fn num_exprs(&self) -> usize { + self.exprs.len() + } + + /// Returns a reference to the expression for the given reference. + /// Panics if the reference is invalid (use indices in range 0..num_exprs()). + pub fn get_expr(&self, r: ExprRef) -> &Expr { + &self[r] + } + + /// Returns the zero-based intern index of the given expression reference. + pub fn expr_index(&self, r: ExprRef) -> usize { + r.index() + } +} + /// Convenience methods to construct IR nodes. impl Context { // helper functions to construct expressions diff --git a/patronus/src/mc/smt.rs b/patronus/src/mc/smt.rs index 79e909c..171410c 100644 --- a/patronus/src/mc/smt.rs +++ b/patronus/src/mc/smt.rs @@ -42,6 +42,12 @@ impl> SmtModelChecker { k_max: u64, ) -> Result { assert!(k_max > 0 && k_max <= 2000, "unreasonable k_max={}", k_max); + + // if there are no assertions, there cannot be an error! + if sys.bad_states.is_empty() { + return Ok(ModelCheckResult::Success); + } + let replay_file = if self.opts.save_smt_replay { Some(std::fs::File::create("replay.smt")?) } else { diff --git a/patronus/src/smt.rs b/patronus/src/smt.rs index e1f84b3..3cc3bfd 100644 --- a/patronus/src/smt.rs +++ b/patronus/src/smt.rs @@ -7,6 +7,6 @@ mod parser; mod serialize; mod solver; -pub use parser::{parse_command, parse_expr}; +pub use parser::{SmtParserError, parse_command, parse_expr}; pub use serialize::serialize_cmd; pub use solver::*; diff --git a/patronus/src/smt/solver.rs b/patronus/src/smt/solver.rs index 52a6ef2..5e3c748 100644 --- a/patronus/src/smt/solver.rs +++ b/patronus/src/smt/solver.rs @@ -416,6 +416,15 @@ pub const YICES2: SmtLibSolver = SmtLibSolver { supports_const_array: false, }; +pub const Z3: SmtLibSolver = SmtLibSolver { + name: "z3", + args: &["-in"], + options: &[], + supports_uf: true, + supports_check_assuming: true, + supports_const_array: true, +}; + #[cfg(test)] mod tests { use super::*; diff --git a/patronus/src/system/transition_system.rs b/patronus/src/system/transition_system.rs index ca19348..55aaca5 100644 --- a/patronus/src/system/transition_system.rs +++ b/patronus/src/system/transition_system.rs @@ -5,7 +5,7 @@ use crate::expr::{Context, ExprMap, ExprRef, SparseExprMap, StringRef}; use rustc_hash::{FxHashMap, FxHashSet}; -#[derive(Debug, PartialEq, Eq, Clone, Hash)] +#[derive(Debug, PartialEq, Eq, Clone, Hash, Copy)] pub struct State { pub symbol: ExprRef, pub init: Option, diff --git a/python/Cargo.toml b/python/Cargo.toml new file mode 100644 index 0000000..eb30890 --- /dev/null +++ b/python/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "pypatronus" +description = "PyO3 bindings for Patronus" +version.workspace = true +rust-version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "pypatronus" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = { version = "0.26.0" , features = ["num-bigint"]} +patronus = { path = "../patronus" } +baa = { version = "0.17.1", features = ["rand1", "bigint"] } +num-bigint = "0.4.6" +rustc-hash.workspace = true diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..ecda27d --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "pypatronus" +requires-python = ">=3.9" +dynamic = ["version"] +license = "BSD-3-Clause" +description = "The hardware bug-finding toolkit for Python programmers." +authors = [ + { name = "Adwait Godbole", email = "adwait@berkeley.edu" }, + { name = "Kevin Laeufer", email = "laeufer@cornell.edu" }, +] +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python", + "License :: OSI Approved :: BSD License" +] + +[project.urls] +Homepage = "https://github.com/cucapra/patronus" +Issues = "https://github.com/cucapra/patronus/issues" + +[build-system] +requires = ["maturin>=1.5,<2.0"] +build-backend = "maturin" + +[tool.maturin] +features = ["pyo3/extension-module"] + +[dependency-groups] +dev = [ + "pytest>=8.4.2", +] + +[tool.uv] +cache-keys = [{file = "pyproject.toml"}, {file = "Cargo.toml"}, {file = "src/*.rs"}] \ No newline at end of file diff --git a/python/src/ctx.rs b/python/src/ctx.rs new file mode 100644 index 0000000..3348496 --- /dev/null +++ b/python/src/ctx.rs @@ -0,0 +1,79 @@ +// Copyright 2025 Cornell University +// released under BSD 3-Clause License +// author: Kevin Laeufer + +use pyo3::prelude::*; +use std::ops::{Deref, DerefMut}; +use std::sync::{LazyLock, RwLock, RwLockReadGuard, RwLockWriteGuard}; + +/// Context that is used by default for all operations. +/// Python usage is expected to be less performance critical, so using a single global +/// context seems acceptable and will simplify use. +static DEFAULT_CONTEXT: LazyLock> = + LazyLock::new(|| RwLock::new(::patronus::expr::Context::default())); + +/// Exposes the Context object to python +#[pyclass] +pub struct Context(::patronus::expr::Context); + +pub(crate) enum ContextGuardWrite<'a> { + // TODO: reintroduce local context support! + // Local(&'a mut Context), + Shared(RwLockWriteGuard<'a, ::patronus::expr::Context>), +} + +// TODO: reintroduce local context support! +// impl<'a> From> for ContextGuardWrite<'a> { +// fn from(value: Option<&'a mut Context>) -> Self { +// value +// .map(|ctx| Self::Local(ctx)) +// .unwrap_or_else(|| Self::Shared(DEFAULT_CONTEXT.write().unwrap())) +// } +// } + +impl<'a> Default for ContextGuardWrite<'a> { + fn default() -> Self { + Self::Shared(DEFAULT_CONTEXT.write().unwrap()) + } +} + +impl<'a> ContextGuardWrite<'a> { + pub fn deref_mut(&mut self) -> &mut ::patronus::expr::Context { + match self { + // TODO: reintroduce local context support! + // Self::Local(ctx) => &mut ctx.0, + Self::Shared(guard) => guard.deref_mut(), + } + } +} + +pub(crate) enum ContextGuardRead<'a> { + // TODO: reintroduce local context support! + // Local(&'a Context), + Shared(RwLockReadGuard<'a, ::patronus::expr::Context>), +} + +// TODO: reintroduce local context support! +// impl<'a> From> for ContextGuardRead<'a> { +// fn from(value: Option<&'a mut Context>) -> Self { +// value +// .map(|ctx| Self::Local(ctx)) +// .unwrap_or_else(|| Self::default()) +// } +// } + +impl<'a> Default for ContextGuardRead<'a> { + fn default() -> Self { + Self::Shared(DEFAULT_CONTEXT.read().unwrap()) + } +} + +impl<'a> ContextGuardRead<'a> { + pub fn deref(&self) -> &::patronus::expr::Context { + match self { + // TODO: reintroduce local context support! + // Self::Local(ctx) => &ctx.0, + Self::Shared(guard) => guard.deref(), + } + } +} diff --git a/python/src/expr.rs b/python/src/expr.rs new file mode 100644 index 0000000..a463ed9 --- /dev/null +++ b/python/src/expr.rs @@ -0,0 +1,201 @@ +// Copyright 2025 Cornell University +// released under BSD 3-Clause License +// author: Kevin Laeufer + +use crate::ctx::{ContextGuardRead, ContextGuardWrite}; +use ::patronus::expr::SerializableIrNode; +use baa::BitVecValue; +use num_bigint::BigInt; +use patronus::expr::{SparseExprMap, TypeCheck, WidthInt}; +use pyo3::exceptions::PyTypeError; +use pyo3::prelude::*; +use std::ops::DerefMut; +use std::sync::{LazyLock, RwLock}; + +#[pyclass] +#[derive(Clone, Copy)] +pub struct ExprRef(pub(crate) patronus::expr::ExprRef); + +/// Helper for binary ops that require a and b to be bitvectors of the same width +fn bv_bin_op( + a: &ExprRef, + b: &ExprRef, + op_str: &str, + op: fn( + &mut patronus::expr::Context, + patronus::expr::ExprRef, + patronus::expr::ExprRef, + ) -> patronus::expr::ExprRef, +) -> PyResult { + match (a.width(), b.width()) { + (Some(left), Some(right)) if left == right => { + let mut guard = ContextGuardWrite::default(); + let res = op(guard.deref_mut(), a.0, b.0); + Ok(ExprRef(res)) + } + _ => Err(PyTypeError::new_err(format!( + "Can only apply {op_str} two bit vectors of the same width" + ))), + } +} + +#[pymethods] +impl ExprRef { + pub(crate) fn __str__(&self) -> String { + self.0.serialize_to_str(ContextGuardRead::default().deref()) + } + + fn __repr__(&self) -> String { + self.__str__() + } + + fn __lt__(&self, other: &Self) -> PyResult { + // we default to signed, just like z3 + // a < b <=> b > a + bv_bin_op(self, other, "less than", |ctx, a, b| { + ctx.greater_signed(b, a) + }) + } + + fn __gt__(&self, other: &Self) -> PyResult { + // we default to signed, just like z3 + bv_bin_op(self, other, "greater than", |ctx, a, b| { + ctx.greater_signed(a, b) + }) + } + + fn equals(&self, other: &Self) -> PyResult { + bv_bin_op(self, other, "equal", |ctx, a, b| ctx.equal(a, b)) + } + + fn __add__(&self, other: &Self) -> PyResult { + bv_bin_op(self, other, "add", |ctx, a, b| ctx.add(a, b)) + } + + fn __sub__(&self, other: &Self) -> PyResult { + bv_bin_op(self, other, "sub", |ctx, a, b| ctx.sub(a, b)) + } + + fn __mul__(&self, other: &Self) -> PyResult { + bv_bin_op(self, other, "mul", |ctx, a, b| ctx.mul(a, b)) + } + + fn __or__(&self, other: &Self) -> PyResult { + bv_bin_op(self, other, "or", |ctx, a, b| ctx.or(a, b)) + } + + fn __and__(&self, other: &Self) -> PyResult { + bv_bin_op(self, other, "and", |ctx, a, b| ctx.and(a, b)) + } + + fn __xor__(&self, other: &Self) -> PyResult { + bv_bin_op(self, other, "xor", |ctx, a, b| ctx.xor(a, b)) + } + + fn __invert__(&self) -> PyResult { + Ok(ExprRef( + ContextGuardWrite::default().deref_mut().not(self.0), + )) + } + + fn __neg__(&self) -> PyResult { + Ok(ExprRef( + ContextGuardWrite::default().deref_mut().negate(self.0), + )) + } + + // TODO: find a way to accept "invalid" slices + // fn __getitem__<'py>(&self, index: Bound<'py, PySlice>)-> PyResult { + // let mut guard = ContextGuardWrite::default(); + // let ctx = guard.deref_mut(); + // if let Some(width) = ctx[self.0].get_bv_type(ctx) { + // let indices = index.as_borrowed().indices(width as isize)?; + // Ok(ExprRef(ctx.slice(self.0, indices.stop as WidthInt, indices.start as WidthInt))) + // } else { + // Err(PyRuntimeError::new_err("Can only slice bit vectors!")) + // } + // + // } + + fn width(&self) -> Option { + let c = ContextGuardRead::default(); + c.deref()[self.0].get_bv_type(c.deref()) + } + + /// Compares reference equality. + /// This is different from the Z3 API where `==` builds an SMT expressoion + fn __eq__(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +/// Simplifier that is used by default for all operations. +/// Python usage is expected to be less performance critical, so using a single global +/// simplifier seems acceptable and will simplify use. +static DEFAULT_SIMPLIFIER: LazyLock< + RwLock<::patronus::expr::Simplifier>>>, +> = LazyLock::new(|| RwLock::new(::patronus::expr::Simplifier::new(SparseExprMap::default()))); + +#[pyfunction] +pub fn simplify(e: ExprRef) -> ExprRef { + let mut guard = DEFAULT_SIMPLIFIER.write().unwrap(); + let r = guard + .deref_mut() + .simplify(ContextGuardWrite::default().deref_mut(), e.0); + ExprRef(r) +} + +#[pyfunction] +#[pyo3(name = "BitVec")] +pub fn bit_vec(name: &str, width: WidthInt) -> ExprRef { + ExprRef( + ContextGuardWrite::default() + .deref_mut() + .bv_symbol(name, width), + ) +} + +#[pyfunction] +#[pyo3(name = "BitVecVal")] +pub fn bit_vec_val(value: BigInt, width: WidthInt) -> ExprRef { + let value = BitVecValue::from_big_int(&value, width); + ExprRef(ContextGuardWrite::default().deref_mut().bv_lit(&value)) +} + +#[pyfunction] +#[pyo3(name = "If")] +pub fn if_expr(cond: ExprRef, tru: ExprRef, fals: ExprRef) -> ExprRef { + ExprRef( + ContextGuardWrite::default() + .deref_mut() + .ite(cond.0, tru.0, fals.0), + ) +} + +#[pyfunction] +#[pyo3(name = "SignExt")] +pub fn sext(n: WidthInt, a: ExprRef) -> ExprRef { + ExprRef(ContextGuardWrite::default().deref_mut().sign_extend(a.0, n)) +} + +#[pyfunction] +#[pyo3(name = "ZeroExt")] +pub fn zext(n: WidthInt, a: ExprRef) -> ExprRef { + ExprRef(ContextGuardWrite::default().deref_mut().zero_extend(a.0, n)) +} + +#[pyfunction] +#[pyo3(name = "Extract")] +pub fn extract(high: WidthInt, low: WidthInt, a: ExprRef) -> ExprRef { + slice(high, low, a) +} + +#[pyfunction] +#[pyo3(name = "Slice")] +pub fn slice(high: WidthInt, low: WidthInt, a: ExprRef) -> ExprRef { + ExprRef( + ContextGuardWrite::default() + .deref_mut() + .slice(a.0, high, low), + ) +} diff --git a/python/src/lib.rs b/python/src/lib.rs new file mode 100644 index 0000000..762d85f --- /dev/null +++ b/python/src/lib.rs @@ -0,0 +1,327 @@ +// Copyright 2025 The Regents of the University of California +// Copyright 2025 Cornell University +// released under BSD 3-Clause License +// author: Kevin Laeufer +// author: Adwait Godbole + +mod ctx; +mod expr; +mod mc; +mod sim; +mod smt; + +pub use ctx::Context; +use ctx::{ContextGuardRead, ContextGuardWrite}; +pub use expr::*; +pub use mc::*; +pub use sim::{Simulator, interpreter}; +pub use smt::*; +use std::path::PathBuf; + +use patronus::btor2; +use patronus::expr::{SerializableIrNode, TypeCheck, WidthInt}; +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; + +#[pyclass] +#[derive(Clone)] +pub struct Output(patronus::system::Output); + +#[pymethods] +impl Output { + #[new] + fn create(name: &str, expr: ExprRef) -> Self { + let output = patronus::system::Output { + name: ContextGuardWrite::default().deref_mut().string(name.into()), + expr: expr.0, + }; + Self(output) + } + + #[getter] + fn name(&self) -> String { + ContextGuardRead::default().deref()[self.0.name].to_string() + } + + #[getter] + fn expr(&self) -> ExprRef { + ExprRef(self.0.expr) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct State(patronus::system::State); + +#[pymethods] +impl State { + #[new] + #[pyo3(signature = (name, width=None, init=None, next=None))] + fn create( + name: &str, + width: Option, + init: Option, + next: Option, + ) -> PyResult { + let mut ctx_guard = ContextGuardWrite::default(); + let ctx = ctx_guard.deref_mut(); + let init_width = init.as_ref().and_then(|i| ctx[i.0].get_bv_type(ctx)); + let next_width = next.as_ref().and_then(|n| ctx[n.0].get_bv_type(ctx)); + let width = width.or(init_width).or(next_width); + if let Some(width) = width { + if let Some(iw) = init_width + && iw != width + { + Err(PyRuntimeError::new_err(format!( + "Width of init expression ({iw}) does not match width of {name} ({width})" + ))) + } else if let Some(nw) = next_width + && nw != width + { + Err(PyRuntimeError::new_err(format!( + "Width of next expression ({nw}) does not match width of {name} ({width})" + ))) + } else { + let symbol = ctx.bv_symbol(name, width); + let state = patronus::system::State { + symbol, + init: init.map(|i| i.0), + next: next.map(|n| n.0), + }; + Ok(Self(state)) + } + } else { + Err(PyRuntimeError::new_err("No width provided!")) + } + } + + #[getter] + fn symbol(&self) -> ExprRef { + ExprRef(self.0.symbol) + } + + #[getter] + fn name(&self) -> String { + ContextGuardRead::default() + .deref() + .get_symbol_name(self.0.symbol) + .unwrap() + .to_string() + } + + #[getter] + fn next(&self) -> Option { + self.0.next.map(ExprRef) + } + + #[getter] + fn init(&self) -> Option { + self.0.init.map(ExprRef) + } +} + +#[pyclass] +pub struct TransitionSystem(patronus::system::TransitionSystem); + +#[pymethods] +impl TransitionSystem { + #[new] + #[pyo3(signature = (name, inputs=None, states=None, outputs=None, bad_states=None, constraints=None))] + fn create( + name: &str, + inputs: Option>, + states: Option>, + outputs: Option>, + bad_states: Option>, + constraints: Option>, + ) -> Self { + Self(patronus::system::TransitionSystem { + name: name.to_string(), + states: states + .map(|v| v.into_iter().map(|e| e.0).collect()) + .unwrap_or_default(), + inputs: inputs + .map(|v| v.into_iter().map(|e| e.0).collect()) + .unwrap_or_default(), + outputs: outputs + .map(|v| v.into_iter().map(|e| e.0).collect()) + .unwrap_or_default(), + bad_states: bad_states + .map(|v| v.into_iter().map(|e| e.0).collect()) + .unwrap_or_default(), + constraints: constraints + .map(|v| v.into_iter().map(|e| e.0).collect()) + .unwrap_or_default(), + names: Default::default(), + }) + } + + #[getter] + fn name(&self) -> &str { + &self.0.name + } + + #[setter(name)] + fn set_name(&mut self, name: &str) { + self.0.name = name.to_string(); + } + + #[getter] + fn inputs(&self) -> Vec { + self.0.inputs.iter().map(|e| ExprRef(*e)).collect() + } + + #[setter(inputs)] + fn set_inputs(&mut self, inputs: Vec) { + // TODO: validate that all inputs are symbols! + self.0.inputs = inputs.into_iter().map(|e| e.0).collect(); + } + + fn add_input(&mut self, symbol: ExprRef) -> PyResult<()> { + let is_symbol = ContextGuardRead::default().deref()[symbol.0].is_symbol(); + if !is_symbol { + Err(PyRuntimeError::new_err(format!( + "{} is not a symbol", + symbol.__str__() + ))) + } else { + self.0.inputs.push(symbol.0); + Ok(()) + } + } + + #[getter] + fn outputs(&self) -> Vec { + self.0.outputs.iter().map(|e| Output(*e)).collect() + } + + #[setter(outputs)] + fn set_outputs(&mut self, outputs: Vec) { + self.0.outputs = outputs.into_iter().map(|e| e.0).collect(); + } + + fn add_output(&mut self, name: String, expr: ExprRef) { + let name_id = ContextGuardWrite::default().deref_mut().string(name.into()); + self.0.outputs.push(patronus::system::Output { + name: name_id, + expr: expr.0, + }); + } + + #[getter] + fn states(&self) -> Vec { + self.0.states.iter().map(|e| State(*e)).collect() + } + + #[setter(states)] + fn set_states(&mut self, states: Vec) { + self.0.states = states.into_iter().map(|e| e.0).collect(); + } + + #[getter] + fn bad_states(&self) -> Vec { + self.0.bad_states.iter().map(|e| ExprRef(*e)).collect() + } + + fn add_bad_state(&mut self, name: String, expr: ExprRef) { + self.0.bad_states.push(expr.0); + let name_id = ContextGuardWrite::default().deref_mut().string(name.into()); + self.0.names[expr.0] = Some(name_id); + } + + fn add_assertion(&mut self, name: &str, expr: ExprRef) { + let not_name = format!("not_{name}"); + let not_expr = ContextGuardWrite::default().deref_mut().not(expr.0); + self.add_bad_state(not_name, ExprRef(not_expr)); + } + + #[setter(bad_states)] + fn set_bad_states(&mut self, bad_states: Vec) { + self.0.bad_states = bad_states.into_iter().map(|e| e.0).collect(); + } + + #[getter] + fn constraints(&self) -> Vec { + self.0.constraints.iter().map(|e| ExprRef(*e)).collect() + } + + #[setter(constraints)] + fn set_constraints(&mut self, constraints: Vec) { + self.0.constraints = constraints.into_iter().map(|e| e.0).collect(); + } + + fn add_constraint(&mut self, name: String, expr: ExprRef) { + self.0.constraints.push(expr.0); + let name_id = ContextGuardWrite::default().deref_mut().string(name.into()); + self.0.names[expr.0] = Some(name_id); + } + + fn __str__(&self) -> String { + self.0.serialize_to_str(ContextGuardRead::default().deref()) + } + + /// look up states + fn __getitem__(&self, key: &str) -> Option { + let ctx_guard = ContextGuardRead::default(); + let ctx = ctx_guard.deref(); + self.0 + .states + .iter() + .find(|s| ctx.get_symbol_name(s.symbol).unwrap() == key) + .map(|s| State(*s)) + } + + fn to_btor2_str(&self) -> String { + btor2::serialize_to_str(ContextGuardRead::default().deref(), &self.0) + } +} + +#[pyfunction] +#[pyo3(signature = (content, name=None))] +pub fn parse_btor2_str(content: &str, name: Option<&str>) -> PyResult { + let mut ctx_guard = ContextGuardWrite::default(); + let ctx = ctx_guard.deref_mut(); + match btor2::parse_str(ctx, content, name) { + Some(sys) => Ok(TransitionSystem(sys)), + None => Err(PyValueError::new_err("failed to parse btor")), + } +} + +#[pyfunction] +pub fn parse_btor2_file(filename: PathBuf) -> PyResult { + let mut ctx_guard = ContextGuardWrite::default(); + let ctx = ctx_guard.deref_mut(); + match btor2::parse_file_with_ctx(filename, ctx) { + Some(sys) => Ok(TransitionSystem(sys)), + None => Err(PyValueError::new_err("failed to parse btor")), + } +} + +#[pymodule] +#[pyo3(name = "pypatronus")] +fn pypatronus(_py: Python<'_>, m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(parse_btor2_str, m)?)?; + m.add_function(wrap_pyfunction!(parse_btor2_file, m)?)?; + // sim + m.add_function(wrap_pyfunction!(interpreter, m)?)?; + // expr + m.add_function(wrap_pyfunction!(bit_vec, m)?)?; + m.add_function(wrap_pyfunction!(bit_vec_val, m)?)?; + m.add_function(wrap_pyfunction!(if_expr, m)?)?; + m.add_function(wrap_pyfunction!(simplify, m)?)?; + m.add_function(wrap_pyfunction!(zext, m)?)?; + m.add_function(wrap_pyfunction!(sext, m)?)?; + m.add_function(wrap_pyfunction!(extract, m)?)?; + m.add_function(wrap_pyfunction!(slice, m)?)?; + // smt + m.add_function(wrap_pyfunction!(solver, m)?)?; + m.add_function(wrap_pyfunction!(parse_smtlib_expr, m)?)?; + // mc + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/python/src/mc.rs b/python/src/mc.rs new file mode 100644 index 0000000..49056fc --- /dev/null +++ b/python/src/mc.rs @@ -0,0 +1,77 @@ +// Copyright 2025 Cornell University +// released under BSD 3-Clause License +// author: Kevin Laeufer + +use crate::ctx::ContextGuardWrite; +use crate::smt::{convert_smt_err, name_to_solver}; +use crate::{Model, TransitionSystem}; +use patronus::smt::SmtLibSolver; +use pyo3::prelude::*; + +#[pyclass] +pub struct SmtModelChecker(::patronus::mc::SmtModelChecker); + +#[pymethods] +impl SmtModelChecker { + #[new] + #[pyo3(signature = (solver, check_constraints=true, check_bad_states_individually=false, save_smt_replay=false))] + fn create( + solver: &str, + check_constraints: bool, + check_bad_states_individually: bool, + save_smt_replay: bool, + ) -> PyResult { + let solver = name_to_solver(solver)?; + let opts = patronus::mc::SmtModelCheckerOptions { + check_constraints, + check_bad_states_individually, + save_smt_replay, + }; + let checker = patronus::mc::SmtModelChecker::new(solver, opts); + Ok(Self(checker)) + } + + fn check(&self, sys: &TransitionSystem, k_max: u64) -> PyResult { + let mut ctx_guard = ContextGuardWrite::default(); + let ctx = ctx_guard.deref_mut(); + self.0 + .check(ctx, &sys.0, k_max) + .map(ModelCheckResult) + .map_err(convert_smt_err) + } +} + +#[pyclass] +pub struct ModelCheckResult(::patronus::mc::ModelCheckResult); + +#[pymethods] +impl ModelCheckResult { + fn __str__(&self) -> String { + match &self.0 { + patronus::mc::ModelCheckResult::Success => "unsat".to_string(), + patronus::mc::ModelCheckResult::Fail(_) => "sat".to_string(), + } + } + + fn __len__(&self) -> usize { + match &self.0 { + patronus::mc::ModelCheckResult::Success => 0, + patronus::mc::ModelCheckResult::Fail(w) => w.inputs.len(), + } + } + + #[getter] + fn inits(&self) -> Option { + match &self.0 { + patronus::mc::ModelCheckResult::Success => None, + patronus::mc::ModelCheckResult::Fail(_w) => { + todo!() + } + } + } + + #[getter] + fn inputs(&self) -> Vec { + todo!() + } +} diff --git a/python/src/sim.rs b/python/src/sim.rs new file mode 100644 index 0000000..9447005 --- /dev/null +++ b/python/src/sim.rs @@ -0,0 +1,45 @@ +// Copyright 2025 Cornell University +// released under BSD 3-Clause License +// author: Kevin Laeufer + +use crate::ctx::ContextGuardRead; +use crate::{ExprRef, TransitionSystem}; +use baa::{BitVecOps, Value}; +use num_bigint::BigInt; +use patronus::sim::InitKind; +use pyo3::prelude::*; + +#[pyclass] +pub struct Simulator(::patronus::sim::Interpreter); + +#[pymethods] +impl Simulator { + pub fn init(&mut self) { + use patronus::sim::Simulator; + self.0.init(InitKind::Zero); + } + + pub fn step(&mut self) { + use patronus::sim::Simulator; + self.0.step(); + } + + /// access the value of an expression + fn __getitem__(&self, key: ExprRef) -> Option { + use patronus::sim::Simulator; + let value = self.0.get(key.0); + match value { + Value::Array(_) => { + todo!("Array support!") + } + Value::BitVec(bv) => Some(bv.to_big_int()), + } + } +} + +#[pyfunction] +#[pyo3(name = "Interpreter")] +pub fn interpreter(sys: &TransitionSystem) -> Simulator { + let interp = ::patronus::sim::Interpreter::new(ContextGuardRead::default().deref(), &sys.0); + Simulator(interp) +} diff --git a/python/src/smt.rs b/python/src/smt.rs new file mode 100644 index 0000000..f878a4e --- /dev/null +++ b/python/src/smt.rs @@ -0,0 +1,214 @@ +// Copyright 2025 Cornell University +// released under BSD 3-Clause License +// author: Kevin Laeufer + +use crate::ExprRef; +use crate::ctx::{ContextGuardRead, ContextGuardWrite}; +use baa::{BitVecOps, Value}; +use num_bigint::BigInt; +use patronus::expr::{Context, TypeCheck}; +use patronus::mc::get_smt_value; +use patronus::smt::*; +use pyo3::exceptions::PyRuntimeError; +use pyo3::prelude::*; +use rustc_hash::{FxHashMap, FxHashSet}; +use std::fs::File; + +#[pyclass] +pub struct SolverCtx { + underlying: SmtLibSolverCtx, + declared_symbols: Vec>, +} + +impl SolverCtx { + fn new(underlying: SmtLibSolverCtx) -> Self { + Self { + underlying, + declared_symbols: vec![FxHashMap::default()], + } + } + + fn symbol_by_name(&self, name: &str) -> Option { + // from inner to outer + for map in self.declared_symbols.iter().rev() { + if let Some(symbol) = map.get(name) { + return Some(*symbol); + } + } + None + } +} + +fn find_symbols(ctx: &Context, e: patronus::expr::ExprRef) -> FxHashSet { + let mut out = FxHashSet::default(); + patronus::expr::traversal::bottom_up(ctx, e, |ctx, e, _| { + if ctx[e].is_symbol() { + out.insert(e); + } + }); + out +} + +#[pymethods] +impl SolverCtx { + #[pyo3(signature = (*assertions))] + fn check(&mut self, assertions: Vec) -> PyResult { + if !assertions.is_empty() { + self.push()?; + for a in assertions.iter() { + self.add(*a)?; + } + } + let r = self + .underlying + .check_sat() + .map(CheckSatResult) + .map_err(convert_smt_err)?; + if !assertions.is_empty() { + self.pop()?; + } + Ok(r) + } + + fn push(&mut self) -> PyResult<()> { + self.underlying.push().map_err(convert_smt_err)?; + self.declared_symbols.push(FxHashMap::default()); + Ok(()) + } + + fn pop(&mut self) -> PyResult<()> { + self.underlying.pop().map_err(convert_smt_err)?; + self.declared_symbols.pop(); + Ok(()) + } + + fn add(&mut self, assertion: ExprRef) -> PyResult<()> { + let ctx_guard = ContextGuardRead::default(); + let ctx = ctx_guard.deref(); + let a = assertion.0; + // scan the expression for any unknown symbols and declare them + let symbols = find_symbols(ctx, a); + for symbol in symbols.into_iter() { + let tpe = ctx[symbol].get_type(ctx); + let name = ctx[symbol].get_symbol_name(ctx).unwrap(); + if let Some(existing) = self.symbol_by_name(name) { + // check for compatible type for existing symbols + let existing_tpe = ctx[existing].get_type(ctx); + if existing_tpe != tpe { + return Err(PyRuntimeError::new_err(format!( + "There is already a symbol `{name}` with incompatible type {existing_tpe} != {tpe}" + ))); + } + } else { + // declare if symbol does not exist + self.underlying + .declare_const(ctx, symbol) + .map_err(convert_smt_err)?; + self.declared_symbols + .last_mut() + .unwrap() + .insert(name.to_string(), symbol); + } + } + + self.underlying.assert(ctx, a).map_err(convert_smt_err)?; + Ok(()) + } + + fn model(&mut self) -> PyResult { + let mut ctx_guard = ContextGuardWrite::default(); + let ctx = ctx_guard.deref_mut(); + let mut entries = vec![]; + for s in self.declared_symbols.iter().flat_map(|m| m.values()) { + let value = get_smt_value(ctx, &mut self.underlying, *s).map_err(convert_smt_err)?; + entries.push((*s, value)); + } + Ok(Model(entries)) + } +} + +#[pyclass] +pub struct Model(Vec<(patronus::expr::ExprRef, Value)>); + +#[pymethods] +impl Model { + fn __str__(&self) -> String { + "TODO".to_string() + } + + fn __len__(&self) -> usize { + self.0.len() + } + + fn __getitem__(&self, symbol: ExprRef) -> Option { + self.0 + .iter() + .find(|(e, _)| *e == symbol.0) + .map(|(_, value)| match value { + Value::Array(_) => { + todo!("Array support!") + } + Value::BitVec(bv) => bv.to_big_int(), + }) + } +} + +#[pyclass] +pub struct CheckSatResult(CheckSatResponse); + +#[pymethods] +impl CheckSatResult { + fn __str__(&self) -> String { + match self.0 { + CheckSatResponse::Sat => "sat".to_string(), + CheckSatResponse::Unsat => "unsat".to_string(), + CheckSatResponse::Unknown => "unknonw".to_string(), + } + } +} + +#[pyfunction] +#[pyo3(name = "Solver")] +pub fn solver(name: &str) -> PyResult { + let solver = name_to_solver(name)?; + Ok(SolverCtx::new(solver.start(None).map_err(convert_smt_err)?)) +} + +pub(crate) fn name_to_solver(name: &str) -> PyResult { + match name.to_ascii_lowercase().as_str() { + "z3" => Ok(Z3), + "bitwuzla" => Ok(BITWUZLA), + "yices" | "yices2" | "yices2-smt" => Ok(YICES2), + _ => Err(PyRuntimeError::new_err(format!( + "Unknonw or unsupported solver: {name}" + ))), + } +} + +#[pyfunction] +#[pyo3(signature = (value, symbols=None))] +pub fn parse_smtlib_expr( + value: &str, + symbols: Option>, +) -> PyResult { + let symbols = symbols + .unwrap_or_default() + .into_iter() + .map(|(k, v)| (k, v.0)) + .collect(); + parse_expr( + ContextGuardWrite::default().deref_mut(), + &symbols, + value.as_bytes(), + ) + .map_err(convert_smt_parse_err) + .map(ExprRef) +} + +pub(crate) fn convert_smt_err(e: Error) -> PyErr { + PyRuntimeError::new_err(format!("smt: {e}")) +} + +fn convert_smt_parse_err(e: SmtParserError) -> PyErr { + PyRuntimeError::new_err(format!("smt: {e}")) +} diff --git a/python/tests/test_expr.py b/python/tests/test_expr.py new file mode 100644 index 0000000..03d1226 --- /dev/null +++ b/python/tests/test_expr.py @@ -0,0 +1,20 @@ +# Copyright 2025 Cornell University +# released under BSD 3-Clause License +# author: Kevin Laeufer + +from pypatronus import * + +def test_simplify(): + # by default this uses a global simplifier + true = BitVecVal(1, 1) + false = BitVecVal(0,1) + a = BitVec('a', 1) + assert simplify((~a) & a) == false + assert simplify((~a) | a) == true + + assert simplify(SignExt(1, false)) == BitVecVal(0b00, 2) + assert simplify(SignExt(1, true)) == BitVecVal(0b11, 2) + + assert simplify(BitVecVal(0, 4).equals(Extract(8, 5, ZeroExt(4, BitVec('a', 5))))) == true + + diff --git a/python/tests/test_lib.py b/python/tests/test_lib.py new file mode 100644 index 0000000..3ae0de6 --- /dev/null +++ b/python/tests/test_lib.py @@ -0,0 +1,87 @@ +# Copyright 2025 Cornell University +# Copyright 2025 The Regents of the University of California +# released under BSD 3-Clause License +# author: Kevin Laeufer +# author: Adwait Godbole + +import pathlib +import pytest +from pypatronus import * + + +repo_root = (pathlib.Path(__file__) / '..' / '..' / '..').resolve() + +COUNT_2 = """ +1 sort bitvec 3 +2 zero 1 +3 state 1 +4 init 1 3 2 +5 one 1 +6 add 1 3 5 +7 next 1 3 6 +8 ones 1 +9 sort bitvec 1 +10 eq 9 3 8 +11 bad 10 +""" + +def test_parse_and_serialize_count2(): + sys = parse_btor2_str(COUNT_2, "count2") + assert sys.name == "count2" + + expected_system = """ +count2 +bad _bad_0 : bv<1> = eq(_state_0, 3'b111) +state _state_0 : bv<3> + [init] 3'b000 + [next] add(_state_0, 3'b001) + """ + assert expected_system.strip() == str(sys).strip() + +@pytest.mark.skip(reason="btor2 serialization is not yet implemented in paronus") +def btor2_serialize(): + sys = parse_btor2_str(COUNT_2, "count2") + assert sys.to_btor2_str().strip() == COUNT_2.strip() + +def test_transition_system_fields(): + sys = parse_btor2_str(COUNT_2, "count2") + assert sys.inputs == [] + assert sys.constraints == [] + assert [str(e) for e in sys.bad_states] == ["eq(_state_0, 3'b111)"] + assert len(sys.states) == 1 + state = sys.states[0] + assert state.name == "_state_0" + assert str(state.symbol) == "_state_0" + assert str(state.init) == "3'b000" + assert str(state.next) == "add(_state_0, 3'b001)" + assert len(sys.outputs) == 0 + + +def test_expression_builder(): + # we are emulating the Z3 API as much as possible + a = BitVec('a', 3) + b = BitVec('b', 3) + assert str(a < b) == "sgt(b, a)" + + +def test_transition_system_builder(): + sys = TransitionSystem("test") + en, count_s = BitVec('en', 1), BitVec('count_s', 8) + sys.inputs = [en] + sys.states = [State('count_s', init=BitVecVal(0, 8), next=If(en, count_s + BitVecVal(1, 8), count_s))] + sys.outputs = [Output('count', count_s)] + # TODO: there is a big pitfall here: you cannot just `append` to the bad_states, inputs, etc. because we use + # a getter / setter approach + sys.add_bad_state('count_is_123', count_s.equals(BitVecVal(123, 8))) + expected_system = """ +test +input en : bv<1> +output count : bv<8> = count_s +bad count_is_123 : bv<1> = eq(count, 8'b01111011) +state count_s : bv<8> + [init] 8'b00000000 + [next] ite(en, add(count, 8'b00000001), count) + """ + assert str(sys).strip() == expected_system.strip() + + diff --git a/python/tests/test_mc.py b/python/tests/test_mc.py new file mode 100644 index 0000000..4ae350b --- /dev/null +++ b/python/tests/test_mc.py @@ -0,0 +1,32 @@ +# Copyright 2025 Cornell University +# released under BSD 3-Clause License +# author: Kevin Laeufer + +import pathlib +import pytest +from pypatronus import * + +repo_root = (pathlib.Path(__file__) / '..' / '..' / '..').resolve() + + + +def test_transition_system_model_checking(): + sys = parse_btor2_file(repo_root / "inputs" / "unittest" / "swap.btor") + mc = SmtModelChecker('z3') + # there are no assertions in this circuit, so it cannot fail + assert str(mc.check(sys, 4)) == "unsat" + + # add an assertion + a = next(s.symbol for s in sys.states if str(s.symbol) == 'a') + b = next(s.symbol for s in sys.states if str(s.symbol) == 'b') + sys.add_assertion("a_is_0", a.equals(BitVecVal(0, 8))) + r = mc.check(sys, 4) + assert str(r) == "sat" + + # check model + assert len(r) == 2, "fail in step 2" + + # check initial values + # TODO! + # assert r[a] == BitVecVal(0, 8) + # assert r[b] == BitVecVal(1, 8) \ No newline at end of file diff --git a/python/tests/test_sim.py b/python/tests/test_sim.py new file mode 100644 index 0000000..c134f68 --- /dev/null +++ b/python/tests/test_sim.py @@ -0,0 +1,28 @@ +# Copyright 2025 Cornell University +# released under BSD 3-Clause License +# author: Kevin Laeufer + +import pathlib +import pytest +from pypatronus import * + +repo_root = (pathlib.Path(__file__) / '..' / '..' / '..').resolve() + + + +def test_transition_system_simulation(): + sys = parse_btor2_file(repo_root / "inputs" / "unittest" / "swap.btor") + sim = Interpreter(sys) + a, b = sys['a'].symbol, sys['b'].symbol + + sim.init() + assert sim[a] == 0, "a@0" + assert sim[b] == 1, "b@0" + + sim.step() + assert sim[a] == 1 + assert sim[b] == 0 + + sim.step() + assert sim[a] == 0 + assert sim[b] == 1 diff --git a/python/tests/test_smt.py b/python/tests/test_smt.py new file mode 100644 index 0000000..5a0f498 --- /dev/null +++ b/python/tests/test_smt.py @@ -0,0 +1,45 @@ +# Copyright 2025 Cornell University +# released under BSD 3-Clause License +# author: Kevin Laeufer + +import pathlib +import pytest +from pypatronus import * + +repo_root = (pathlib.Path(__file__) / '..' / '..' / '..').resolve() + +def test_call_smt_solver(): + a = BitVec('a', 3) + b = BitVec('b', 3) + s = Solver('z3') + r = s.check(a < b) + assert str(r) == "sat" + + r = s.check(a < b, a > b) + assert str(r) == "unsat" + + # to generate a model, we need to actually add the assertion! + s.add(a < b) + s.check() + m = s.model() + assert len(m) == 2 + assert isinstance(m[a], int) + assert isinstance(m[b], int) + assert m[a] < m[b] + + +def test_parse_smt_lib_expr(): + symbols = { + 'x_0': BitVec('x_0', 32), + 'y_0': BitVec('y_0', 32), + 'x_1': BitVec('x_1', 32), + } + a = parse_smtlib_expr("(= x_1 (bvadd x_0 y_0))", symbols) + assert str(a) == "eq(x_1, add(x_0, y_0))" + + +@pytest.mark.skip(reason="parsing commands is not implemented yet") +def test_parse_smt_lib_commands(): + parse_smtlib_cmd("(set-logic QF_BV)") + parse_smtlib_cmd("(set-option :produce-models true)") + parse_smtlib_cmd("(declare-const x_0 (_ BitVec 32))") diff --git a/python/uv.lock b/python/uv.lock new file mode 100644 index 0000000..1f6a851 --- /dev/null +++ b/python/uv.lock @@ -0,0 +1,150 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pypatronus" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.4.2" }] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]