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;