Skip to content

Commit

Permalink
Add optional support for conversion from indexmap::IndexMap (#1728)
Browse files Browse the repository at this point in the history
* Add support to IndexMap

* Fix indexmap version to 1.6.2

* Remove code duplication by mistake

* Fix ambiguity in test

* Minor change for doc.rs

* Add to lib.rs docstring

* Add indexmap to conversion table

* Add indexmap flag in docs.rs action

* Add indexmap feature to CI

* Add note in changelog

* Use with_gil in tests

* Move code to src/conversions/indexmap.rs

* Add PR number to CHANGELOG

Co-authored-by: David Hewitt <1939362+davidhewitt@users.noreply.github.com>

* Add round trip test

* Fix issue in MSRV Ubuntu build

* Fix Github workflow syntax

* Yet Another Attempt to Fix MSRV Ubuntu build

* Specify hashbrown to avoid ambiguity in CI

* Add suggestions

* More flexible version for indexmap

* Add documentation

* Address PR comments

* Export indexmap for docs

Co-authored-by: David Hewitt <1939362+davidhewitt@users.noreply.github.com>
  • Loading branch information
IvanIsCoding and davidhewitt committed Jul 22, 2021
1 parent 9ab7b1f commit bd0e0d8
Show file tree
Hide file tree
Showing 8 changed files with 245 additions and 6 deletions.
8 changes: 5 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/guide.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<meta http-equiv=refresh content=0;url=pyo3/index.html>" > gh-pages-build/doc/index.html
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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"]
4 changes: 3 additions & 1 deletion guide/src/conversions/tables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` | `&PyList` |
| `dict[K, V]` | `HashMap<K, V>`, `BTreeMap<K, V>`, `hashbrown::HashMap<K, V>`[^2] | `&PyDict` |
| `dict[K, V]` | `HashMap<K, V>`, `BTreeMap<K, V>`, `hashbrown::HashMap<K, V>`[^2], `indexmap::IndexMap<K, V>`[^3] | `&PyDict` |
| `tuple[T, U]` | `(T, U)`, `Vec<T>` | `&PyTuple` |
| `set[T]` | `HashSet<T>`, `BTreeSet<T>`, `hashbrown::HashSet<T>`[^2] | `&PySet` |
| `frozenset[T]` | `HashSet<T>`, `BTreeSet<T>`, `hashbrown::HashSet<T>`[^2] | `&PyFrozenSet` |
Expand Down Expand Up @@ -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.
219 changes: 219 additions & 0 deletions src/conversions/indexmap.rs
Original file line number Diff line number Diff line change
@@ -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<i32>) -> 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<i32>) -> f32 {
//! data.iter().sum::<i32>() as f32 / data.len() as f32
//! }
//! fn mode(data: &Vec<i32>) -> 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<i32>) -> 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<K, V, H> ToPyObject for indexmap::IndexMap<K, V, H>
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<K, V, H> IntoPy<PyObject> for indexmap::IndexMap<K, V, H>
where
K: hash::Hash + cmp::Eq + IntoPy<PyObject>,
V: IntoPy<PyObject>,
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<K, V, S>
where
K: FromPyObject<'source> + cmp::Eq + hash::Hash,
V: FromPyObject<'source>,
S: hash::BuildHasher + Default,
{
fn extract(ob: &'source PyAny) -> Result<Self, PyErr> {
let dict = <PyDict as PyTryFrom>::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::<i32, i32>::new();
map.insert(1, 1);

let m = map.to_object(py);
let py_map = <PyDict as PyTryFrom>::try_from(m.as_ref(py)).unwrap();

assert!(py_map.len() == 1);
assert!(py_map.get_item(1).unwrap().extract::<i32>().unwrap() == 1);
assert_eq!(
map,
py_map.extract::<indexmap::IndexMap::<i32, i32>>().unwrap()
);
});
}

#[test]
fn test_indexmap_indexmap_into_python() {
Python::with_gil(|py| {
let mut map = indexmap::IndexMap::<i32, i32>::new();
map.insert(1, 1);

let m: PyObject = map.into_py(py);
let py_map = <PyDict as PyTryFrom>::try_from(m.as_ref(py)).unwrap();

assert!(py_map.len() == 1);
assert!(py_map.get_item(1).unwrap().extract::<i32>().unwrap() == 1);
});
}

#[test]
fn test_indexmap_indexmap_into_dict() {
Python::with_gil(|py| {
let mut map = indexmap::IndexMap::<i32, i32>::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::<i32>().unwrap(), 1);
});
}

#[test]
fn test_indexmap_indexmap_insertion_order_round_trip() {
Python::with_gil(|py| {
let n = 20;
let mut map = indexmap::IndexMap::<i32, i32>::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::<indexmap::IndexMap<i32, i32>>().unwrap();

for (((k1, v1), (k2, v2)), (k3, v3)) in
map.iter().zip(py_map.iter()).zip(trip_map.iter())
{
let k2 = k2.extract::<i32>().unwrap();
let v2 = v2.extract::<i32>().unwrap();
assert_eq!((k1, v1), (&k2, &v2));
assert_eq!((k1, v1), (k3, v3));
assert_eq!((&k2, &v2), (k3, v3));
}
});
}
}
3 changes: 3 additions & 0 deletions src/conversions/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 8 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down

0 comments on commit bd0e0d8

Please sign in to comment.