commit ac1c36584abcfe71b92fb7eb2b74c039d591b552
Author: Jelmer Vernooĳ <jelmer@jelmer.uk>
Date:   Tue May 26 22:24:35 2026 +0100

    fix: make introspection JSON output deterministic
    
    The introspection proc macros (used by the `experimental-inspect`
    feature) accumulated each JSON object's fields in a `HashMap` before
    serializing them in iteration order. Because `std`'s default hasher is
    seeded randomly per process, the emitted JSON key order varied between
    otherwise identical builds, leaving the embedded introspection chunks
    non-reproducible.
    
    Switch to `BTreeMap` so the keys come out in a stable (alphabetical)
    order. JSON objects are unordered by spec, so downstream parsers are
    unaffected.
    
    Caught via Debian's reproducible-builds rebuild of pysequoia 0.1.34-2,
    which fails with diffs like:
      {"name","parent","type","annotation"}
    vs
      {"annotation","parent","type","name"}

index aad7eb1f2..c6b89b192 100644
Index: pyo3-macros-backend/src/introspection.rs
===================================================================
--- pyo3-macros-backend.orig/src/introspection.rs
+++ pyo3-macros-backend/src/introspection.rs
@@ -16,7 +16,7 @@ use proc_macro2::{Span, TokenStream};
 use quote::{format_ident, quote, ToTokens};
 use std::borrow::Cow;
 use std::collections::hash_map::DefaultHasher;
-use std::collections::HashMap;
+use std::collections::BTreeMap;
 use std::hash::{Hash, Hasher};
 use std::mem::take;
 use std::sync::atomic::{AtomicUsize, Ordering};
@@ -64,7 +64,7 @@ pub fn class_introspection_code(
     is_final: bool,
     parent: Option<&Type>,
 ) -> TokenStream {
-    let mut desc = HashMap::from([
+    let mut desc = BTreeMap::from([
         ("type", IntrospectionNode::String("class".into())),
         (
             "id",
@@ -102,7 +102,7 @@ pub fn function_introspection_code(
     is_async: bool,
     parent: Option<&Type>,
 ) -> TokenStream {
-    let mut desc = HashMap::from([
+    let mut desc = BTreeMap::from([
         ("type", IntrospectionNode::String("function".into())),
         ("name", IntrospectionNode::String(name.into())),
         (
@@ -156,7 +156,7 @@ pub fn attribute_introspection_code(
     rust_type: Type,
     is_final: bool,
 ) -> TokenStream {
-    let mut desc = HashMap::from([
+    let mut desc = BTreeMap::from([
         ("type", IntrospectionNode::String("attribute".into())),
         ("name", IntrospectionNode::String(name.into())),
         (
@@ -246,7 +246,7 @@ fn arguments_introspection_data<'a>(
         let Some(FnArg::VarArgs(arg_desc)) = argument_desc.next() else {
             panic!("Fewer arguments than in python signature");
         };
-        let mut params = HashMap::from([("name", IntrospectionNode::String(param.into()))]);
+        let mut params = BTreeMap::from([("name", IntrospectionNode::String(param.into()))]);
         if let Some(annotation) = &arg_desc.annotation {
             params.insert("annotation", annotation.clone().into());
         }
@@ -264,14 +264,14 @@ fn arguments_introspection_data<'a>(
         let Some(FnArg::KwArgs(arg_desc)) = argument_desc.next() else {
             panic!("Less arguments than in python signature");
         };
-        let mut params = HashMap::from([("name", IntrospectionNode::String(param.into()))]);
+        let mut params = BTreeMap::from([("name", IntrospectionNode::String(param.into()))]);
         if let Some(annotation) = &arg_desc.annotation {
             params.insert("annotation", annotation.clone().into());
         }
         kwarg = Some(IntrospectionNode::Map(params));
     }
 
-    let mut map = HashMap::new();
+    let mut map = BTreeMap::new();
     if !posonlyargs.is_empty() {
         map.insert("posonlyargs", IntrospectionNode::List(posonlyargs));
     }
@@ -295,7 +295,7 @@ fn argument_introspection_data<'a>(
     desc: &'a RegularArg<'_>,
     class_type: Option<&Type>,
 ) -> AttributedIntrospectionNode<'a> {
-    let mut params: HashMap<_, _> = [("name", IntrospectionNode::String(name.into()))].into();
+    let mut params: BTreeMap<_, _> = [("name", IntrospectionNode::String(name.into()))].into();
     if let Some(expr) = &desc.default_value {
         params.insert("default", PyExpr::constant_from_expression(expr).into());
     }
@@ -317,7 +317,7 @@ enum IntrospectionNode<'a> {
     Bool(bool),
     IntrospectionId(Option<Cow<'a, Type>>),
     TypeHint(Cow<'a, PyExpr>),
-    Map(HashMap<&'static str, IntrospectionNode<'a>>),
+    Map(BTreeMap<&'static str, IntrospectionNode<'a>>),
     List(Vec<AttributedIntrospectionNode<'a>>),
 }
 
