diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98a1ad1d..1e77a171 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,11 +130,13 @@ jobs: id: settings shell: bash run: | - echo "::set-output name=all_additive_features::macros num-bigint num-complex hashbrown serde multiple-pymethods" + echo "::set-output name=all_additive_features::macros num-bigint num-complex hashbrown indexmap serde multiple-pymethods" - if: matrix.msrv == 'MSRV' name: Prepare minimal package versions (MSRV only) - run: cargo update -p hashbrown --precise 0.9.1 + run: | + cargo update -p indexmap --precise 1.6.2 + cargo update -p hashbrown:0.11.2 --precise 0.9.1 - name: Build docs run: cargo doc --no-deps --no-default-features --features "${{ steps.settings.outputs.all_additive_features }}" @@ -229,7 +231,7 @@ jobs: profile: minimal components: llvm-tools-preview - run: cargo test --no-default-features --no-fail-fast - - run: cargo test --no-default-features --no-fail-fast --features "macros num-bigint num-complex hashbrown serde multiple-pymethods" + - run: cargo test --no-default-features --no-fail-fast --features "macros num-bigint num-complex hashbrown indexmap serde multiple-pymethods" - run: cargo test --manifest-path=pyo3-macros-backend/Cargo.toml - run: cargo test --manifest-path=pyo3-build-config/Cargo.toml # can't yet use actions-rs/grcov with source-based coverage: https://github.com/actions-rs/grcov/issues/105 diff --git a/.github/workflows/guide.yml b/.github/workflows/guide.yml index 728f785b..7e7d2651 100644 --- a/.github/workflows/guide.yml +++ b/.github/workflows/guide.yml @@ -44,7 +44,7 @@ jobs: # This adds the docs to gh-pages-build/doc - name: Build the doc run: | - cargo +nightly rustdoc --lib --no-default-features --features="macros num-bigint num-complex hashbrown serde multiple-pymethods" -- --cfg docsrs + cargo +nightly rustdoc --lib --no-default-features --features="macros num-bigint num-complex hashbrown indexmap serde multiple-pymethods" -- --cfg docsrs cp -r target/doc gh-pages-build/doc echo "" > gh-pages-build/doc/index.html diff --git a/CHANGELOG.md b/CHANGELOG.md index f24b3cd3..725ea24b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- Add `indexmap` feature to add `ToPyObject`, `IntoPy` and `FromPyObject` implementations for `indexmap::IndexMap`. [#1728](https://github.com/PyO3/pyo3/pull/1728) + ### Fixed - Fix regression in 0.14.0 rejecting usage of `#[doc(hidden)]` on structs and functions annotated with PyO3 macros. [#1722](https://github.com/PyO3/pyo3/pull/1722) diff --git a/Cargo.toml b/Cargo.toml index 54df6cc6..7c4420ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ paste = { version = "0.1.18", optional = true } pyo3-macros = { path = "pyo3-macros", version = "=0.14.1", optional = true } unindent = { version = "0.1.4", optional = true } hashbrown = { version = ">= 0.9, < 0.12", optional = true } +indexmap = { version = ">= 1.6, < 1.8", optional = true } serde = {version = "1.0", optional = true} [dev-dependencies] @@ -117,5 +118,5 @@ members = [ [package.metadata.docs.rs] no-default-features = true -features = ["macros", "num-bigint", "num-complex", "hashbrown", "serde", "multiple-pymethods"] +features = ["macros", "num-bigint", "num-complex", "hashbrown", "serde", "multiple-pymethods", "indexmap"] rustdoc-args = ["--cfg", "docsrs"] diff --git a/guide/src/conversions/tables.md b/guide/src/conversions/tables.md index a7fada47..7a8eaccf 100644 --- a/guide/src/conversions/tables.md +++ b/guide/src/conversions/tables.md @@ -20,7 +20,7 @@ The table below contains the Python type and the corresponding function argument | `float` | `f32`, `f64` | `&PyFloat` | | `complex` | `num_complex::Complex`[^1] | `&PyComplex` | | `list[T]` | `Vec` | `&PyList` | -| `dict[K, V]` | `HashMap`, `BTreeMap`, `hashbrown::HashMap`[^2] | `&PyDict` | +| `dict[K, V]` | `HashMap`, `BTreeMap`, `hashbrown::HashMap`[^2], `indexmap::IndexMap`[^3] | `&PyDict` | | `tuple[T, U]` | `(T, U)`, `Vec` | `&PyTuple` | | `set[T]` | `HashSet`, `BTreeSet`, `hashbrown::HashSet`[^2] | `&PySet` | | `frozenset[T]` | `HashSet`, `BTreeSet`, `hashbrown::HashSet`[^2] | `&PyFrozenSet` | @@ -94,3 +94,5 @@ Finally, the following Rust types are also able to convert to Python as return v [^1]: Requires the `num-complex` optional feature. [^2]: Requires the `hashbrown` optional feature. + +[^3]: Requires the `indexmap` optional feature. diff --git a/src/conversions/indexmap.rs b/src/conversions/indexmap.rs new file mode 100644 index 00000000..89f0eda1 --- /dev/null +++ b/src/conversions/indexmap.rs @@ -0,0 +1,219 @@ +//! Conversions to and from [indexmap](https://docs.rs/indexmap/)’s +//! `IndexMap`. +//! +//! [`indexmap::IndexMap`] is a hash table that is closely compatible with the standard [`std::collections::HashMap`], +//! with the difference that it preserves the insertion order when iterating over keys. It was inspired +//! by Python's 3.6+ dict implementation. +//! +//! Dictionary order is guaranteed to be insertion order in Python, hence IndexMap is a good candidate +//! for maintaining an equivalent behaviour in Rust. +//! +//! # Setup +//! +//! To use this feature, add this to your **`Cargo.toml`**: +//! +//! ```toml +//! [dependencies] +//! # change * to the latest versions +//! indexmap = "*" +// workaround for `extended_key_value_attributes`: https://github.com/rust-lang/rust/issues/82768#issuecomment-803935643 +#![cfg_attr(docsrs, cfg_attr(docsrs, doc = concat!("pyo3 = { version = \"", env!("CARGO_PKG_VERSION"), "\", features = [\"indexmap\"] }")))] +#![cfg_attr( + not(docsrs), + doc = "pyo3 = { version = \"*\", features = [\"indexmap\"] }" +)] +//! ``` +//! +//! Note that you must use compatible versions of indexmap and PyO3. +//! The required indexmap version may vary based on the version of PyO3. +//! +//! # Examples +//! +//! Using [indexmap](https://docs.rs/indexmap) to return a dictionary with some statistics +//! about a list of numbers. Because of the insertion order guarantees, the Python code will +//! always print the same result, matching users' expectations about Python's dict. +//! +//! ```rust +//! use indexmap::{indexmap, IndexMap}; +//! use pyo3::prelude::*; +//! +//! fn median(data: &Vec) -> f32 { +//! let sorted_data = data.clone().sort(); +//! let mid = data.len() / 2; +//! if (data.len() % 2 == 0) { +//! data[mid] as f32 +//! } +//! else { +//! (data[mid] + data[mid - 1]) as f32 / 2.0 +//! } +//! } +//! +//! fn mean(data: &Vec) -> f32 { +//! data.iter().sum::() as f32 / data.len() as f32 +//! } +//! fn mode(data: &Vec) -> f32 { +//! let mut frequency = IndexMap::new(); // we can use IndexMap as any hash table +//! +//! for &element in data { +//! *frequency.entry(element).or_insert(0) += 1; +//! } +//! +//! frequency +//! .iter() +//! .max_by(|a, b| a.1.cmp(&b.1)) +//! .map(|(k, _v)| *k) +//! .unwrap() as f32 +//! } +//! +//! #[pyfunction] +//! fn calculate_statistics(data: Vec) -> IndexMap<&'static str, f32> { +//! indexmap!{ +//! "median" => median(&data), +//! "mean" => mean(&data), +//! "mode" => mode(&data), +//! } +//! } +//! +//! #[pymodule] +//! fn my_module(_py: Python, m: &PyModule) -> PyResult<()> { +//! m.add_function(wrap_pyfunction!(calculate_statistics, m)?)?; +//! Ok(()) +//! } +//! ``` +//! +//! Python code: +//! ```python +//! from my_module import calculate_statistics +//! +//! data = [1, 1, 1, 3, 4, 5] +//! print(calculate_statistics(data)) +//! # always prints {"median": 2.0, "mean": 2.5, "mode": 1.0} in the same order +//! # if another hash table was used, the order could be random +//! ``` + +use crate::types::*; +use crate::{FromPyObject, IntoPy, PyErr, PyObject, PyTryFrom, Python, ToPyObject}; +use std::{cmp, hash}; + +impl ToPyObject for indexmap::IndexMap +where + K: hash::Hash + cmp::Eq + ToPyObject, + V: ToPyObject, + H: hash::BuildHasher, +{ + fn to_object(&self, py: Python) -> PyObject { + IntoPyDict::into_py_dict(self, py).into() + } +} + +impl IntoPy for indexmap::IndexMap +where + K: hash::Hash + cmp::Eq + IntoPy, + V: IntoPy, + H: hash::BuildHasher, +{ + fn into_py(self, py: Python) -> PyObject { + let iter = self + .into_iter() + .map(|(k, v)| (k.into_py(py), v.into_py(py))); + IntoPyDict::into_py_dict(iter, py).into() + } +} + +impl<'source, K, V, S> FromPyObject<'source> for indexmap::IndexMap +where + K: FromPyObject<'source> + cmp::Eq + hash::Hash, + V: FromPyObject<'source>, + S: hash::BuildHasher + Default, +{ + fn extract(ob: &'source PyAny) -> Result { + let dict = ::try_from(ob)?; + let mut ret = indexmap::IndexMap::with_capacity_and_hasher(dict.len(), S::default()); + for (k, v) in dict.iter() { + ret.insert(K::extract(k)?, V::extract(v)?); + } + Ok(ret) + } +} + +#[cfg(test)] +mod test_indexmap { + + use crate::types::*; + use crate::{IntoPy, PyObject, PyTryFrom, Python, ToPyObject}; + + #[test] + fn test_indexmap_indexmap_to_python() { + Python::with_gil(|py| { + let mut map = indexmap::IndexMap::::new(); + map.insert(1, 1); + + let m = map.to_object(py); + let py_map = ::try_from(m.as_ref(py)).unwrap(); + + assert!(py_map.len() == 1); + assert!(py_map.get_item(1).unwrap().extract::().unwrap() == 1); + assert_eq!( + map, + py_map.extract::>().unwrap() + ); + }); + } + + #[test] + fn test_indexmap_indexmap_into_python() { + Python::with_gil(|py| { + let mut map = indexmap::IndexMap::::new(); + map.insert(1, 1); + + let m: PyObject = map.into_py(py); + let py_map = ::try_from(m.as_ref(py)).unwrap(); + + assert!(py_map.len() == 1); + assert!(py_map.get_item(1).unwrap().extract::().unwrap() == 1); + }); + } + + #[test] + fn test_indexmap_indexmap_into_dict() { + Python::with_gil(|py| { + let mut map = indexmap::IndexMap::::new(); + map.insert(1, 1); + + let py_map = map.into_py_dict(py); + + assert_eq!(py_map.len(), 1); + assert_eq!(py_map.get_item(1).unwrap().extract::().unwrap(), 1); + }); + } + + #[test] + fn test_indexmap_indexmap_insertion_order_round_trip() { + Python::with_gil(|py| { + let n = 20; + let mut map = indexmap::IndexMap::::new(); + + for i in 1..=n { + if i % 2 == 1 { + map.insert(i, i); + } else { + map.insert(n - i, i); + } + } + + let py_map = map.clone().into_py_dict(py); + + let trip_map = py_map.extract::>().unwrap(); + + for (((k1, v1), (k2, v2)), (k3, v3)) in + map.iter().zip(py_map.iter()).zip(trip_map.iter()) + { + let k2 = k2.extract::().unwrap(); + let v2 = v2.extract::().unwrap(); + assert_eq!((k1, v1), (&k2, &v2)); + assert_eq!((k1, v1), (k3, v3)); + assert_eq!((&k2, &v2), (k3, v3)); + } + }); + } +} diff --git a/src/conversions/mod.rs b/src/conversions/mod.rs index 60c57fc9..ad2e0b91 100644 --- a/src/conversions/mod.rs +++ b/src/conversions/mod.rs @@ -1,5 +1,8 @@ //! This module contains conversions between various Rust object and their representation in Python. mod array; +#[cfg(feature = "indexmap")] +#[cfg_attr(docsrs, doc(cfg(feature = "indexmap")))] +pub mod indexmap; mod osstr; mod path; diff --git a/src/lib.rs b/src/lib.rs index f101f7fe..620a14f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -74,6 +74,10 @@ //! [`HashMap`](https://docs.rs/hashbrown/latest/hashbrown/struct.HashMap.html) and //! [`HashSet`](https://docs.rs/hashbrown/latest/hashbrown/struct.HashSet.html) types. // +//! - [`indexmap`](crate::indexmap): Enables conversions between Python dictionary and +//! [indexmap](https://docs.rs/indexmap)'s +//! [`IndexMap`](https://docs.rs/indexmap/latest/indexmap/map/struct.IndexMap.html). +// //! - `multiple-pymethods`: Enables the use of multiple //! [`#[pymethods]`](crate::proc_macro::pymethods) blocks per //! [`#[pyclass]`](crate::proc_macro::pyclass). This adds a dependency on the @@ -303,6 +307,10 @@ pub mod num_bigint; pub mod num_complex; +#[cfg_attr(docsrs, doc(cfg(feature = "indexmap")))] +#[cfg(feature = "indexmap")] +pub use crate::conversions::indexmap; + #[cfg_attr(docsrs, doc(cfg(feature = "serde")))] #[cfg(feature = "serde")] pub mod serde;