From 2d05ba321dc8a196e0d9274428ee66239c8b9840 Mon Sep 17 00:00:00 2001 From: messense Date: Sun, 1 Dec 2024 12:57:35 +0800 Subject: [PATCH 1/2] Refactor `BridgeModel` to include bindings crate version --- src/build_context.rs | 30 ++++++++------ src/build_options.rs | 75 +++++++++++++++++++---------------- src/ci.rs | 15 +++++-- src/compile.rs | 4 +- src/lib.rs | 2 +- src/new_project.rs | 10 +++-- src/python_interpreter/mod.rs | 15 ++++++- 7 files changed, 93 insertions(+), 58 deletions(-) diff --git a/src/build_context.rs b/src/build_context.rs index 409eed016..6638182c5 100644 --- a/src/build_context.rs +++ b/src/build_context.rs @@ -32,16 +32,22 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use tracing::instrument; +/// The name and version of the bindings crate +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Bindings { + /// The name of the bindings crate, `pyo3`, `rust-cpython` or `uniffi` + pub name: String, + /// bindings crate version + pub version: semver::Version, +} + /// The way the rust code is used in the wheel #[derive(Clone, Debug, PartialEq, Eq)] pub enum BridgeModel { /// A rust binary to be shipped a python package - /// The String is the name of the bindings - /// providing crate, e.g. pyo3, the number is the minimum minor python version - Bin(Option<(String, usize)>), - /// A native module with pyo3 or rust-cpython bindings. The String is the name of the bindings - /// providing crate, e.g. pyo3, the number is the minimum minor python version - Bindings(String, usize), + Bin(Option), + /// A native module with pyo3 or rust-cpython bindings. + Bindings(Bindings), /// `Bindings`, but specifically for pyo3 with feature flags that allow building a single wheel /// for all cpython versions (pypy & graalpy still need multiple versions). /// The numbers are the minimum major and minor version @@ -56,7 +62,7 @@ impl BridgeModel { /// Returns the name of the bindings crate pub fn unwrap_bindings(&self) -> &str { match self { - BridgeModel::Bindings(value, _) => value, + BridgeModel::Bindings(bindings) => &bindings.name, _ => panic!("Expected Bindings"), } } @@ -64,8 +70,8 @@ impl BridgeModel { /// Test whether this is using a specific bindings crate pub fn is_bindings(&self, name: &str) -> bool { match self { - BridgeModel::Bin(Some((value, _))) => value == name, - BridgeModel::Bindings(value, _) => value == name, + BridgeModel::Bin(Some(bindings)) => bindings.name == name, + BridgeModel::Bindings(bindings) => bindings.name == name, _ => false, } } @@ -79,9 +85,9 @@ impl BridgeModel { impl Display for BridgeModel { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { - BridgeModel::Bin(Some((name, _))) => write!(f, "{name} bin"), + BridgeModel::Bin(Some(bindings)) => write!(f, "{} bin", bindings.name), BridgeModel::Bin(None) => write!(f, "bin"), - BridgeModel::Bindings(name, _) => write!(f, "{name}"), + BridgeModel::Bindings(bindings) => write!(f, "{}", bindings.name), BridgeModel::BindingsAbi3(..) => write!(f, "pyo3"), BridgeModel::Cffi => write!(f, "cffi"), BridgeModel::UniFfi => write!(f, "uniffi"), @@ -210,7 +216,7 @@ impl BuildContext { let wheels = match self.bridge() { BridgeModel::Bin(None) => self.build_bin_wheel(None)?, BridgeModel::Bin(Some(..)) => self.build_bin_wheels(&self.interpreter)?, - BridgeModel::Bindings(..) => self.build_binding_wheels(&self.interpreter)?, + BridgeModel::Bindings { .. } => self.build_binding_wheels(&self.interpreter)?, BridgeModel::BindingsAbi3(major, minor) => { let abi3_interps: Vec<_> = self .interpreter diff --git a/src/build_options.rs b/src/build_options.rs index 3a4f7be54..7576bc1c5 100644 --- a/src/build_options.rs +++ b/src/build_options.rs @@ -4,8 +4,8 @@ use crate::compile::{CompileTarget, LIB_CRATE_TYPES}; use crate::cross_compile::{find_sysconfigdata, parse_sysconfigdata}; use crate::project_layout::ProjectResolver; use crate::pyproject_toml::ToolMaturin; -use crate::python_interpreter::{InterpreterConfig, InterpreterKind, MINIMUM_PYTHON_MINOR}; -use crate::{BuildContext, PythonInterpreter, Target}; +use crate::python_interpreter::{InterpreterConfig, InterpreterKind}; +use crate::{Bindings, BuildContext, PythonInterpreter, Target}; use anyhow::{bail, format_err, Context, Result}; use cargo_metadata::{CrateType, TargetKind}; use cargo_metadata::{Metadata, Node}; @@ -24,16 +24,6 @@ use tracing::{debug, instrument}; // and more restrictive. const PYO3_BINDING_CRATES: [&str; 2] = ["pyo3-ffi", "pyo3"]; -fn pyo3_minimum_python_minor_version(major_version: u64, minor_version: u64) -> Option { - if (major_version, minor_version) >= (0, 16) { - Some(7) - } else if (major_version, minor_version) >= (0, 23) { - Some(8) - } else { - None - } -} - /// Cargo options for the build process #[derive(Debug, Default, Serialize, Deserialize, clap::Parser, Clone, Eq, PartialEq)] #[serde(default, rename_all = "kebab-case")] @@ -227,7 +217,12 @@ impl BuildOptions { generate_import_lib: bool, ) -> Result> { match bridge { - BridgeModel::Bindings(binding_name, _) | BridgeModel::Bin(Some((binding_name, _))) => { + BridgeModel::Bindings(Bindings { + name: binding_name, .. + }) + | BridgeModel::Bin(Some(Bindings { + name: binding_name, .. + })) => { let mut interpreters = Vec::new(); if let Some(config_file) = env::var_os("PYO3_CONFIG_FILE") { if !binding_name.starts_with("pyo3") { @@ -1011,19 +1006,25 @@ fn is_generating_import_lib(cargo_metadata: &Metadata) -> Result { fn find_bindings( deps: &HashMap<&str, &Node>, packages: &HashMap<&str, &cargo_metadata::Package>, -) -> Option<(String, usize)> { +) -> Option { if deps.get("pyo3").is_some() { - let ver = &packages["pyo3"].version; - let minor = - pyo3_minimum_python_minor_version(ver.major, ver.minor).unwrap_or(MINIMUM_PYTHON_MINOR); - Some(("pyo3".to_string(), minor)) + let version = packages["pyo3"].version.clone(); + Some(Bindings { + name: "pyo3".to_string(), + version, + }) } else if deps.get("pyo3-ffi").is_some() { - let ver = &packages["pyo3-ffi"].version; - let minor = - pyo3_minimum_python_minor_version(ver.major, ver.minor).unwrap_or(MINIMUM_PYTHON_MINOR); - Some(("pyo3-ffi".to_string(), minor)) + let version = packages["pyo3-ffi"].version.clone(); + Some(Bindings { + name: "pyo3-ffi".to_string(), + version, + }) } else if deps.contains_key("uniffi") { - Some(("uniffi".to_string(), MINIMUM_PYTHON_MINOR)) + let version = packages["uniffi"].version.clone(); + Some(Bindings { + name: "uniffi".to_string(), + version, + }) } else { None } @@ -1082,7 +1083,7 @@ pub fn find_bridge(cargo_metadata: &Metadata, bridge: Option<&str>) -> Result
) -> Result
{ cargo_rustc.lib = true; // https://github.com/rust-lang/rust/issues/59302#issue-422994250 @@ -220,7 +220,7 @@ fn cargo_build_command( // https://github.com/PyO3/pyo3/issues/88#issuecomment-337744403 if target.is_macos() { - if let BridgeModel::Bindings(..) | BridgeModel::BindingsAbi3(..) = bridge_model { + if let BridgeModel::Bindings { .. } | BridgeModel::BindingsAbi3(..) = bridge_model { // Change LC_ID_DYLIB to the final .so name for macOS targets to avoid linking with // non-existent library. // See https://github.com/PyO3/setuptools-rust/issues/106 for detail diff --git a/src/lib.rs b/src/lib.rs index 176d3006b..04cd33280 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,7 +23,7 @@ #![deny(missing_docs)] -pub use crate::build_context::{BridgeModel, BuildContext, BuiltWheelMetadata}; +pub use crate::build_context::{Bindings, BridgeModel, BuildContext, BuiltWheelMetadata}; pub use crate::build_options::{BuildOptions, CargoOptions}; pub use crate::cargo_toml::CargoToml; pub use crate::compile::{compile, BuildArtifact}; diff --git a/src/new_project.rs b/src/new_project.rs index fdc62ec1e..1d76cb83c 100644 --- a/src/new_project.rs +++ b/src/new_project.rs @@ -1,11 +1,12 @@ use self::package_name_validations::{cargo_check_name, pypi_check_name}; use crate::ci::GenerateCI; -use crate::BridgeModel; +use crate::{Bindings, BridgeModel}; use anyhow::{bail, Context, Result}; use console::style; use dialoguer::{theme::ColorfulTheme, Select}; use fs_err as fs; use minijinja::{context, Environment}; +use semver::Version; use std::path::Path; /// Mixed Rust/Python project layout @@ -25,7 +26,7 @@ struct ProjectGenerator<'a> { overwrite: bool, } -impl<'a> ProjectGenerator<'a> { +impl ProjectGenerator<'_> { fn new( project_name: String, layout: ProjectLayout, @@ -52,7 +53,10 @@ impl<'a> ProjectGenerator<'a> { "bin" => BridgeModel::Bin(None), "cffi" => BridgeModel::Cffi, "uniffi" => BridgeModel::UniFfi, - _ => BridgeModel::Bindings(bindings.clone(), 7), + _ => BridgeModel::Bindings(Bindings { + name: bindings.clone(), + version: Version::new(0, 23, 1), + }), }; let ci_config = GenerateCI::default().generate_github(&project_name, &bridge_model, true)?; diff --git a/src/python_interpreter/mod.rs b/src/python_interpreter/mod.rs index 92ddefba4..ac4641af0 100644 --- a/src/python_interpreter/mod.rs +++ b/src/python_interpreter/mod.rs @@ -24,6 +24,16 @@ pub const MINIMUM_PYTHON_MINOR: usize = 7; pub const MAXIMUM_PYTHON_MINOR: usize = 13; pub const MAXIMUM_PYPY_MINOR: usize = 10; +fn pyo3_minimum_python_minor_version(major_version: u64, minor_version: u64) -> usize { + if (major_version, minor_version) >= (0, 16) { + 7 + } else if (major_version, minor_version) >= (0, 23) { + 8 + } else { + MINIMUM_PYTHON_MINOR + } +} + /// Identifies conditions where we do not want to build wheels fn windows_interpreter_no_build( major: usize, @@ -759,7 +769,10 @@ impl PythonInterpreter { requires_python: Option<&VersionSpecifiers>, ) -> Result> { let min_python_minor = match bridge { - BridgeModel::Bindings(_, minor) | BridgeModel::Bin(Some((_, minor))) => *minor, + BridgeModel::Bindings(bindings) | BridgeModel::Bin(Some(bindings)) => { + let version = &bindings.version; + pyo3_minimum_python_minor_version(version.major, version.minor) + } _ => MINIMUM_PYTHON_MINOR, }; let executables = if target.is_windows() { From f87bb82af1dc012a7bfdc37e2ce2b7b641a7597b Mon Sep 17 00:00:00 2001 From: messense Date: Sun, 1 Dec 2024 15:02:17 +0800 Subject: [PATCH 2/2] Limit minimal PyPy version based on bindings crate version --- src/build_context.rs | 55 +++++++++++- src/build_options.rs | 20 +++-- src/main.rs | 2 +- src/python_interpreter/config.rs | 21 +++-- src/python_interpreter/mod.rs | 140 ++++++++++++++++++++++++++----- 5 files changed, 207 insertions(+), 31 deletions(-) diff --git a/src/build_context.rs b/src/build_context.rs index 6638182c5..5d703e210 100644 --- a/src/build_context.rs +++ b/src/build_context.rs @@ -41,6 +41,50 @@ pub struct Bindings { pub version: semver::Version, } +impl Bindings { + /// Returns the minimum python minor version supported + pub fn minimal_python_minor_version(&self) -> usize { + use crate::python_interpreter::MINIMUM_PYTHON_MINOR; + + match self.name.as_str() { + "pyo3" | "pyo3-ffi" => { + let major_version = self.version.major; + let minor_version = self.version.minor; + // N.B. must check large minor versions first + if (major_version, minor_version) >= (0, 23) { + 8 + } else if (major_version, minor_version) >= (0, 16) { + 7 + } else { + MINIMUM_PYTHON_MINOR + } + } + _ => MINIMUM_PYTHON_MINOR, + } + } + + /// Returns the minimum PyPy minor version supported + pub fn minimal_pypy_minor_version(&self) -> usize { + use crate::python_interpreter::MINIMUM_PYPY_MINOR; + + match self.name.as_str() { + "pyo3" | "pyo3-ffi" => { + let major_version = self.version.major; + let minor_version = self.version.minor; + // N.B. must check large minor versions first + if (major_version, minor_version) >= (0, 23) { + 9 + } else if (major_version, minor_version) >= (0, 14) { + 7 + } else { + MINIMUM_PYPY_MINOR + } + } + _ => MINIMUM_PYPY_MINOR, + } + } +} + /// The way the rust code is used in the wheel #[derive(Clone, Debug, PartialEq, Eq)] pub enum BridgeModel { @@ -59,8 +103,17 @@ pub enum BridgeModel { } impl BridgeModel { + /// Returns the bindings + pub fn bindings(&self) -> Option<&Bindings> { + match self { + BridgeModel::Bin(Some(bindings)) => Some(bindings), + BridgeModel::Bindings(bindings) => Some(bindings), + _ => None, + } + } + /// Returns the name of the bindings crate - pub fn unwrap_bindings(&self) -> &str { + pub fn unwrap_bindings_name(&self) -> &str { match self { BridgeModel::Bindings(bindings) => &bindings.name, _ => panic!("Expected Bindings"), diff --git a/src/build_options.rs b/src/build_options.rs index 7576bc1c5..9bdcb0c54 100644 --- a/src/build_options.rs +++ b/src/build_options.rs @@ -314,8 +314,12 @@ impl BuildOptions { bail!("{} is not a valid python interpreter", interp.display()); } } - interpreters = - find_interpreter_in_sysconfig(interpreter, target, requires_python)?; + interpreters = find_interpreter_in_sysconfig( + bridge, + interpreter, + target, + requires_python, + )?; if interpreters.is_empty() { bail!( "Couldn't find any python interpreters from '{}'. Please check that both major and minor python version have been specified in -i/--interpreter.", @@ -361,7 +365,7 @@ impl BuildOptions { } let interps = - find_interpreter_in_sysconfig(interpreter, target, requires_python) + find_interpreter_in_sysconfig(bridge,interpreter, target, requires_python) .unwrap_or_default(); if interps.is_empty() && !self.interpreter.is_empty() { // Print error when user supplied `--interpreter` option @@ -466,6 +470,7 @@ impl BuildOptions { // we cannot use host pypy so switch to bundled sysconfig instead if !pypys.is_empty() { interps.extend(find_interpreter_in_sysconfig( + bridge, &pypys, target, requires_python, @@ -1195,7 +1200,7 @@ fn find_interpreter( } if !missing.is_empty() { let sysconfig_interps = - find_interpreter_in_sysconfig(&missing, target, requires_python)?; + find_interpreter_in_sysconfig(bridge, &missing, target, requires_python)?; found_interpreters.extend(sysconfig_interps); } } else { @@ -1251,12 +1256,17 @@ fn find_interpreter_in_host( /// Find python interpreters in the bundled sysconfig fn find_interpreter_in_sysconfig( + bridge: &BridgeModel, interpreter: &[PathBuf], target: &Target, requires_python: Option<&VersionSpecifiers>, ) -> Result> { if interpreter.is_empty() { - return Ok(PythonInterpreter::find_by_target(target, requires_python)); + return Ok(PythonInterpreter::find_by_target( + target, + requires_python, + Some(bridge), + )); } let mut interpreters = Vec::new(); for interp in interpreter { diff --git a/src/main.rs b/src/main.rs index c366cd8e2..6af83339f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -425,7 +425,7 @@ fn run() -> Result<()> { Command::ListPython { target } => { let found = if target.is_some() { let target = Target::from_target_triple(target)?; - PythonInterpreter::find_by_target(&target, None) + PythonInterpreter::find_by_target(&target, None, None) } else { let target = Target::from_target_triple(None)?; // We don't know the targeted bindings yet, so we use the most lenient diff --git a/src/python_interpreter/config.rs b/src/python_interpreter/config.rs index e7372af5c..adc518795 100644 --- a/src/python_interpreter/config.rs +++ b/src/python_interpreter/config.rs @@ -1,4 +1,7 @@ -use super::{InterpreterKind, MAXIMUM_PYPY_MINOR, MAXIMUM_PYTHON_MINOR, MINIMUM_PYTHON_MINOR}; +use super::{ + InterpreterKind, MAXIMUM_PYPY_MINOR, MAXIMUM_PYTHON_MINOR, MINIMUM_PYPY_MINOR, + MINIMUM_PYTHON_MINOR, +}; use crate::target::{Arch, Os}; use crate::Target; use anyhow::{format_err, Context, Result}; @@ -226,11 +229,19 @@ impl InterpreterConfig { /// Lookup wellknown sysconfigs for a given target pub fn lookup_target(target: &Target) -> Vec { let mut configs = Vec::new(); - for (python_impl, max_minor_ver) in [ - (InterpreterKind::CPython, MAXIMUM_PYTHON_MINOR), - (InterpreterKind::PyPy, MAXIMUM_PYPY_MINOR), + for (python_impl, min_minor_ver, max_minor_ver) in [ + ( + InterpreterKind::CPython, + MINIMUM_PYTHON_MINOR, + MAXIMUM_PYTHON_MINOR, + ), + ( + InterpreterKind::PyPy, + MINIMUM_PYPY_MINOR, + MAXIMUM_PYPY_MINOR, + ), ] { - for minor in MINIMUM_PYTHON_MINOR..=max_minor_ver { + for minor in min_minor_ver..=max_minor_ver { if let Some(config) = Self::lookup_one(target, python_impl, (3, minor), "") { configs.push(config); } diff --git a/src/python_interpreter/mod.rs b/src/python_interpreter/mod.rs index ac4641af0..ec7f3fe10 100644 --- a/src/python_interpreter/mod.rs +++ b/src/python_interpreter/mod.rs @@ -20,20 +20,11 @@ mod config; /// version and abi as json through stdout const GET_INTERPRETER_METADATA: &str = include_str!("get_interpreter_metadata.py"); pub const MINIMUM_PYTHON_MINOR: usize = 7; +pub const MINIMUM_PYPY_MINOR: usize = 8; /// Be liberal here to include preview versions pub const MAXIMUM_PYTHON_MINOR: usize = 13; pub const MAXIMUM_PYPY_MINOR: usize = 10; -fn pyo3_minimum_python_minor_version(major_version: u64, minor_version: u64) -> usize { - if (major_version, minor_version) >= (0, 16) { - 7 - } else if (major_version, minor_version) >= (0, 23) { - 8 - } else { - MINIMUM_PYTHON_MINOR - } -} - /// Identifies conditions where we do not want to build wheels fn windows_interpreter_no_build( major: usize, @@ -739,7 +730,15 @@ impl PythonInterpreter { pub fn find_by_target( target: &Target, requires_python: Option<&VersionSpecifiers>, + bridge: Option<&BridgeModel>, ) -> Vec { + let bindings = bridge.and_then(|bridge| bridge.bindings()); + let min_python_minor = bindings + .map(|bindings| bindings.minimal_python_minor_version()) + .unwrap_or(MINIMUM_PYTHON_MINOR); + let min_pypy_minor = bindings + .map(|bindings| bindings.minimal_pypy_minor_version()) + .unwrap_or(MINIMUM_PYPY_MINOR); InterpreterConfig::lookup_target(target) .into_iter() .filter_map(|config| match requires_python { @@ -754,6 +753,23 @@ impl PythonInterpreter { } None => Some(Self::from_config(config)), }) + .filter_map(|config| match config.interpreter_kind { + InterpreterKind::CPython => { + if config.minor >= min_python_minor { + Some(config) + } else { + None + } + } + InterpreterKind::PyPy => { + if config.minor >= min_pypy_minor { + Some(config) + } else { + None + } + } + InterpreterKind::GraalPy => Some(config), + }) .collect() } @@ -770,12 +786,18 @@ impl PythonInterpreter { ) -> Result> { let min_python_minor = match bridge { BridgeModel::Bindings(bindings) | BridgeModel::Bin(Some(bindings)) => { - let version = &bindings.version; - pyo3_minimum_python_minor_version(version.major, version.minor) + bindings.minimal_python_minor_version() } _ => MINIMUM_PYTHON_MINOR, }; + let min_pypy_minor = match bridge { + BridgeModel::Bindings(bindings) | BridgeModel::Bin(Some(bindings)) => { + bindings.minimal_pypy_minor_version() + } + _ => MINIMUM_PYPY_MINOR, + }; let executables = if target.is_windows() { + // TOFIX: add PyPy support to Windows find_all_windows(target, min_python_minor, requires_python)? } else { let mut executables: Vec = (min_python_minor..=MAXIMUM_PYTHON_MINOR) @@ -794,7 +816,7 @@ impl PythonInterpreter { || bridge.is_bindings("pyo3-ffi") { executables.extend( - (min_python_minor..=MAXIMUM_PYPY_MINOR) + (min_pypy_minor..=MAXIMUM_PYPY_MINOR) .filter(|minor| { requires_python .map(|requires_python| { @@ -1003,26 +1025,106 @@ fn calculate_abi_tag(ext_suffix: &str) -> Option { #[cfg(test)] mod tests { + use crate::Bindings; + use expect_test::expect; + use super::*; #[test] fn test_find_interpreter_by_target() { let target = Target::from_target_triple(Some("x86_64-unknown-linux-gnu".to_string())).unwrap(); - let pythons = PythonInterpreter::find_by_target(&target, None); - assert_eq!(pythons.len(), 12); + let pythons = PythonInterpreter::find_by_target(&target, None, None) + .iter() + .map(ToString::to_string) + .collect::>(); + let expected = expect![[r#" + [ + "CPython 3.7m", + "CPython 3.8", + "CPython 3.9", + "CPython 3.10", + "CPython 3.11", + "CPython 3.12", + "CPython 3.13", + "CPython 3.13t", + "PyPy 3.8", + "PyPy 3.9", + "PyPy 3.10", + ] + "#]]; + expected.assert_debug_eq(&pythons); let pythons = PythonInterpreter::find_by_target( &target, Some(&VersionSpecifiers::from_str(">=3.8").unwrap()), - ); - assert_eq!(pythons.len(), 10); + None, + ) + .iter() + .map(ToString::to_string) + .collect::>(); + let expected = expect![[r#" + [ + "CPython 3.8", + "CPython 3.9", + "CPython 3.10", + "CPython 3.11", + "CPython 3.12", + "CPython 3.13", + "CPython 3.13t", + "PyPy 3.8", + "PyPy 3.9", + "PyPy 3.10", + ] + "#]]; + expected.assert_debug_eq(&pythons); let pythons = PythonInterpreter::find_by_target( &target, Some(&VersionSpecifiers::from_str(">=3.10").unwrap()), - ); - assert_eq!(pythons.len(), 6); + None, + ) + .iter() + .map(ToString::to_string) + .collect::>(); + let expected = expect![[r#" + [ + "CPython 3.10", + "CPython 3.11", + "CPython 3.12", + "CPython 3.13", + "CPython 3.13t", + "PyPy 3.10", + ] + "#]]; + expected.assert_debug_eq(&pythons); + + let pythons = PythonInterpreter::find_by_target( + &target, + Some(&VersionSpecifiers::from_str(">=3.8").unwrap()), + Some(&BridgeModel::Bindings(Bindings { + name: "pyo3".to_string(), + version: semver::Version::new(0, 23, 0), + })), + ) + .iter() + .map(ToString::to_string) + .collect::>(); + // should exclude PyPy < 3.9 + let expected = expect![[r#" + [ + "CPython 3.8", + "CPython 3.9", + "CPython 3.10", + "CPython 3.11", + "CPython 3.12", + "CPython 3.13", + "CPython 3.13t", + "PyPy 3.9", + "PyPy 3.10", + ] + "#]]; + expected.assert_debug_eq(&pythons); } #[test]