diff --git a/.gitignore b/.gitignore index 4ef5d39..2ea44d2 100644 --- a/.gitignore +++ b/.gitignore @@ -138,10 +138,10 @@ celerybeat.pid # Environments .env -.venv -env/ -venv/ -ENV/ +.venv*/ +env*/ +venv*/ +ENV*/ env.bak/ venv.bak/ @@ -188,4 +188,30 @@ pyrightconfig.json .DS_Store Thumbs.db -todo.txt \ No newline at end of file +todo.txt + + +# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode + +*.code-workspace diff --git a/crates/ci-python/.vscode/settings.json b/crates/ci-python/.vscode/settings.json new file mode 100644 index 0000000..b2b8866 --- /dev/null +++ b/crates/ci-python/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "test" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/crates/ci-python/Cargo.toml b/crates/ci-python/Cargo.toml index 4b9ff1a..7a09229 100644 --- a/crates/ci-python/Cargo.toml +++ b/crates/ci-python/Cargo.toml @@ -9,12 +9,15 @@ repository = "" [dependencies] ci_core = { path = "../ci-core" } -pyo3 = { version = "0.28", features = ["extension-module"] } -numpy = { version = "0.28" } ndarray = { workspace = true } +numpy = { version = "0.28" } +pyo3 = { version = "0.28", features = ["extension-module"] } +pyo3-stub-gen = "0.22.1" +pyo3-stub-gen-derive = "0.22.1" [lints] workspace = true [lib] -crate-type = ["cdylib"] +name = "ci_python" +crate-type = ["cdylib", "rlib"] diff --git a/crates/ci-python/benchmarks/power_divergence.py b/crates/ci-python/benchmarks/power_divergence.py index c5aba3b..769f356 100644 --- a/crates/ci-python/benchmarks/power_divergence.py +++ b/crates/ci-python/benchmarks/power_divergence.py @@ -1,11 +1,11 @@ from pgmpy.estimators.CITests import pearsonr, power_divergence import numpy as np -from ci_python import PyRegistry +from ci_python import Registry import time import pandas as pd -registry = PyRegistry() +registry = Registry() test = registry.get_test("pearson_correlation") N_ITER = 50 diff --git a/crates/ci-python/build.rs b/crates/ci-python/build.rs new file mode 100644 index 0000000..6075b2f --- /dev/null +++ b/crates/ci-python/build.rs @@ -0,0 +1,83 @@ +use std::env; +use std::fs; +use std::path::Path; + +fn main() { + let out_dir = env::var_os("OUT_DIR").unwrap(); + + // Struct and impl definitions + let defs_path = Path::new(&out_dir).join("ci_tests.rs"); + fs::write( + &defs_path, + r#" + #[gen_stub_pyclass] + #[pyclass(frozen, name = "ChiSquared", module = "ci_python._ci_python")] + pub struct PyChiSquared { + registry: Arc, + test_name: String, + } + + #[gen_stub_pymethods] + #[pymethods] + impl PyChiSquared { + #[new] + #[must_use] + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self { registry: Arc::new(Registry::new()), test_name: "chi_squared".to_string() } + } + + #[allow(clippy::needless_pass_by_value)] + #[pyo3(signature = (z, x, y))] + pub fn __call__( + &self, + py: Python<'_>, + z: PyReadonlyArray2<'_, f64>, + x: PyReadonlyArray1<'_, f64>, + y: PyReadonlyArray1<'_, f64>, + ) -> PyResult> { + let z: Array2 = z.as_array().to_owned(); + let x: Array1 = x.as_array().to_owned(); + let y: Array1 = y.as_array().to_owned(); + + let test = self + .registry + .get_test(&self.test_name) + .map_err(|e| PyErr::new::(e.to_string()))?; + + let result = test + .run_test(x, y, z) + .map_err(|e| PyErr::new::(e.to_string()))?; + + match result { + TestResult::Boolean(b) => Ok(b.into_pyobject(py)?.to_owned().into_any().unbind()), + TestResult::PValue(p_value, coefficient) => Ok((p_value, coefficient) + .into_pyobject(py)? + .into_any() + .unbind()), + TestResult::Statistic(p_value, statistic, dof) => Ok((p_value, statistic, dof) + .into_pyobject(py)? + .into_any() + .unbind()), + } + } + } + "#, + ) + .unwrap(); + + // Module registration calls – included inside the #[pymodule_init] fn + let init_path = Path::new(&out_dir).join("ci_tests_init.rs"); + fs::write( + &init_path, + r#" + use pyo3::prelude::*; + + pub fn init(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + Ok(()) + } + "#, + ) + .unwrap(); +} diff --git a/crates/ci-python/ci_python/__init__.py b/crates/ci-python/ci_python/__init__.py new file mode 100644 index 0000000..a860440 --- /dev/null +++ b/crates/ci-python/ci_python/__init__.py @@ -0,0 +1,9 @@ +"""PyO3 bindings for conditional independence testing.""" + +from ci_python._ci_python import ChiSquared, CITest, Registry + +__all__ = [ + "CITest", + "ChiSquared", + "Registry", +] diff --git a/crates/ci-python/ci_python/_ci_python/__init__.pyi b/crates/ci-python/ci_python/_ci_python/__init__.pyi new file mode 100644 index 0000000..5b4bcb9 --- /dev/null +++ b/crates/ci-python/ci_python/_ci_python/__init__.pyi @@ -0,0 +1,35 @@ +# This file is automatically generated by pyo3_stub_gen +# ruff: noqa: E501, F401, F403, F405 + +import builtins +import numpy +import numpy.typing +import typing +__all__ = [ + "CITest", + "ChiSquared", + "Registry", +] + +@typing.final +class CITest: + def __call__(self, z: numpy.typing.NDArray[numpy.float64], x: numpy.typing.NDArray[numpy.float64], y: numpy.typing.NDArray[numpy.float64]) -> typing.Any: + r""" + Run the conditional independence test on the given data. + + # Errors + + Returns `PyRuntimeError` if the test lookup fails or the test itself returns an error. + """ + +@typing.final +class ChiSquared: + def __new__(cls) -> ChiSquared: ... + def __call__(self, z: numpy.typing.NDArray[numpy.float64], x: numpy.typing.NDArray[numpy.float64], y: numpy.typing.NDArray[numpy.float64]) -> typing.Any: ... + +@typing.final +class Registry: + def __new__(cls) -> Registry: ... + def list_all_tests(self) -> builtins.list[builtins.str]: ... + def get_test(self, test_name: builtins.str) -> CITest: ... + diff --git a/crates/ci-python/pyproject.toml b/crates/ci-python/pyproject.toml new file mode 100644 index 0000000..3dffeec --- /dev/null +++ b/crates/ci-python/pyproject.toml @@ -0,0 +1,69 @@ +[project] +name = "ci-python" +version = "0.1.0" +requires-python = ">=3.10,<3.15" +dependencies = [] +classifiers = [ + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: Unix", + "Operating System :: Microsoft :: Windows", + "Operating System :: MacOS", +] + +[build-system] +requires = ["maturin>=1.11,<2.0"] +build-backend = "maturin" + +[tool.maturin] +module-name = "ci_python._ci_python" +python-source = "." + +[tool.pyo3-stub-gen] +generate-init-py = false + +[project.optional-dependencies] +test = [ + "pytest", +] + +[tool.ruff] +line-length = 120 + +# Exclude generated files. +extend-exclude = [ + "ci_python/_ci_python", +] + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "ANN002", # missing-type-args: Checks that function *args arguments have type annotations. + "ANN003", # missing-type-kwargs: Checks that function **kwargs arguments have type annotations. + "COM812", # missing-trailing-comma: Checks for the absence of trailing commas. + "E741", # ambiguous-variable-name: Checks for the use of the characters 'l', 'O', or 'I' as variable names. + "EM101", # raw-string-in-exception: Checks for the use of string literals in exception constructors. + "EM102", # f-string-in-exception: Checks for the use of f-strings in exception constructors. + "F403", # undefined-local-with-import-star: Checks for the use of wildcard imports. + "FBT001", # boolean-type-hint-positional-argument: Checks for the use of boolean positional arguments in function definitions, as determined by the presence of a type hint containing bool as an evident subtype - e.g. bool, bool | int, typing.Optional[bool], etc. + "FBT002", # boolean-default-value-positional-argument: Checks for the use of boolean positional arguments in function definitions, as determined by the presence of a boolean default value. + "FBT003", # boolean-positional-value-in-call: Checks for boolean positional arguments in function calls. + "N812", # lowercase-imported-as-non-lowercase: Checks for lowercase imports that are aliased to non-lowercase names. + "PLR0913", # too-many-arguments: Checks for function definitions that include too many arguments. + "PLR1714", # repeated-equality-comparison: Checks for repeated equality comparisons that can be rewritten using the in operator. + "PLR2004", # magic-value-comparison: Checks for the use of unnamed numerical constants ("magic") values in comparisons. + "S101", # assert: Checks for uses of the assert keyword. + "SIM108", # if-else-block-instead-of-if-exp: Check for if-else-blocks that can be replaced with a ternary or binary operator. + "TD002", # missing-todo-author: Checks that a TODO comment includes an author. + "TD003", # missing-todo-link: Checks that a TODO comment is associated with a link to a relevant issue or ticket. + "TRY003", # raise-vanilla-args: Checks for long exception messages that are not defined in the exception class itself. +] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.format] +quote-style = "double" diff --git a/crates/ci-python/src/bin/stub_gen.rs b/crates/ci-python/src/bin/stub_gen.rs new file mode 100644 index 0000000..c72419c --- /dev/null +++ b/crates/ci-python/src/bin/stub_gen.rs @@ -0,0 +1,8 @@ +use ci_python::stub_info; +use pyo3_stub_gen::Result; + +fn main() -> Result<()> { + let stub = stub_info()?; + stub.generate()?; + Ok(()) +} diff --git a/crates/ci-python/src/ci_tests_init.rs b/crates/ci-python/src/ci_tests_init.rs new file mode 100644 index 0000000..3b68279 --- /dev/null +++ b/crates/ci-python/src/ci_tests_init.rs @@ -0,0 +1 @@ +include!(concat!(env!("OUT_DIR"), "/ci_tests_init.rs")); diff --git a/crates/ci-python/src/lib.rs b/crates/ci-python/src/lib.rs index 884f135..68bb949 100644 --- a/crates/ci-python/src/lib.rs +++ b/crates/ci-python/src/lib.rs @@ -1,93 +1,108 @@ -use ci_core::registry::Registry; -use ci_core::strategy::TestResult; -use ndarray::{Array1, Array2}; -use numpy::{PyReadonlyArray1, PyReadonlyArray2}; -use pyo3::prelude::*; -use std::sync::Arc; +use pyo3_stub_gen::define_stub_info_gatherer; +mod ci_tests_init; -#[pyclass(frozen)] -pub struct PyRegistry(Arc); +#[pyo3::pymodule] +mod _ci_python { + use ci_core::registry::Registry; + use ci_core::strategy::TestResult; + use ndarray::{Array1, Array2}; + use numpy::{PyReadonlyArray1, PyReadonlyArray2}; + use pyo3::prelude::*; + use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; + use std::sync::Arc; -#[pymethods] -impl PyRegistry { - #[new] - #[must_use] - #[allow(clippy::new_without_default)] - pub fn new() -> Self { - Self(Arc::new(Registry::new())) - } + use crate::ci_tests_init; + + include!(concat!(env!("OUT_DIR"), "/ci_tests.rs")); - fn list_all_tests(&self) -> PyResult> { - let tests = self - .0 - .all_tests() - .map_err(|e| PyErr::new::(e.to_string()))?; - Ok(tests.collect()) + #[pymodule_init] + fn init(m: &Bound<'_, PyModule>) -> PyResult<()> { + ci_tests_init::init(m) } - fn get_test(&self, test_name: &str) -> PyResult { - self.0 - .get_test(test_name) - .map_err(|e| PyErr::new::(e.to_string()))?; - Ok(PyCITest { - registry: self.0.clone(), - test_name: test_name.to_string(), - }) + #[gen_stub_pyclass] + #[pyclass(frozen, name = "Registry", module = "ci_python._ci_python")] + pub struct PyRegistry(Arc); + + #[gen_stub_pymethods] + #[pymethods] + impl PyRegistry { + #[new] + #[must_use] + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self(Arc::new(Registry::new())) + } + + fn list_all_tests(&self) -> PyResult> { + let tests = self + .0 + .all_tests() + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(tests.collect()) + } + + fn get_test(&self, test_name: &str) -> PyResult { + self.0 + .get_test(test_name) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(PyCITest { + registry: self.0.clone(), + test_name: test_name.to_string(), + }) + } } -} -#[pyclass(frozen)] -pub struct PyCITest { - registry: Arc, - test_name: String, -} + #[gen_stub_pyclass] + #[pyclass(frozen, name = "CITest", module = "ci_python._ci_python")] + pub struct PyCITest { + registry: Arc, + test_name: String, + } -#[pymethods] -impl PyCITest { - /// Run the conditional independence test on the given data. - /// - /// # Errors - /// - /// Returns `PyRuntimeError` if the test lookup fails or the test itself returns an error. - #[allow(clippy::needless_pass_by_value)] - #[pyo3(signature = (z, x, y))] - pub fn __call__( - &self, - py: Python<'_>, - z: PyReadonlyArray2<'_, f64>, - x: PyReadonlyArray1<'_, f64>, - y: PyReadonlyArray1<'_, f64>, - ) -> PyResult> { - let z: Array2 = z.as_array().to_owned(); - let x: Array1 = x.as_array().to_owned(); - let y: Array1 = y.as_array().to_owned(); + #[gen_stub_pymethods] + #[pymethods] + impl PyCITest { + /// Run the conditional independence test on the given data. + /// + /// # Errors + /// + /// Returns `PyRuntimeError` if the test lookup fails or the test itself returns an error. + #[allow(clippy::needless_pass_by_value)] + #[pyo3(signature = (z, x, y))] + pub fn __call__( + &self, + py: Python<'_>, + z: PyReadonlyArray2<'_, f64>, + x: PyReadonlyArray1<'_, f64>, + y: PyReadonlyArray1<'_, f64>, + ) -> PyResult> { + let z: Array2 = z.as_array().to_owned(); + let x: Array1 = x.as_array().to_owned(); + let y: Array1 = y.as_array().to_owned(); - let test = self - .registry - .get_test(&self.test_name) - .map_err(|e| PyErr::new::(e.to_string()))?; + let test = self + .registry + .get_test(&self.test_name) + .map_err(|e| PyErr::new::(e.to_string()))?; - let result = test - .run_test(x, y, z) - .map_err(|e| PyErr::new::(e.to_string()))?; + let result = test + .run_test(x, y, z) + .map_err(|e| PyErr::new::(e.to_string()))?; - match result { - TestResult::Boolean(b) => Ok(b.into_pyobject(py)?.to_owned().into_any().unbind()), - TestResult::PValue(p_value, coefficient) => Ok((p_value, coefficient) - .into_pyobject(py)? - .into_any() - .unbind()), - TestResult::Statistic(p_value, statistic, dof) => Ok((p_value, statistic, dof) - .into_pyobject(py)? - .into_any() - .unbind()), + match result { + TestResult::Boolean(b) => Ok(b.into_pyobject(py)?.to_owned().into_any().unbind()), + TestResult::PValue(p_value, coefficient) => Ok((p_value, coefficient) + .into_pyobject(py)? + .into_any() + .unbind()), + TestResult::Statistic(p_value, statistic, dof) => Ok((p_value, statistic, dof) + .into_pyobject(py)? + .into_any() + .unbind()), + } } } } -#[pymodule] -fn ci_python(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_class::()?; - m.add_class::()?; - Ok(()) -} +define_stub_info_gatherer!(stub_info); diff --git a/crates/ci-python/test/__init__.py b/crates/ci-python/test/__init__.py new file mode 100644 index 0000000..0fa104c --- /dev/null +++ b/crates/ci-python/test/__init__.py @@ -0,0 +1 @@ +"""Unit tests for `ci_python`.""" diff --git a/crates/ci-python/test/test_Registry.py b/crates/ci-python/test/test_Registry.py new file mode 100644 index 0000000..23992d0 --- /dev/null +++ b/crates/ci-python/test/test_Registry.py @@ -0,0 +1,9 @@ +import pytest + +from ci_python import Registry + + +class TestRegistry: + """Tests for the :class:`Registry`.""" + + # TODO: https://github.com/GiPHouse/Conditional-Independence-Testing/issues/142