use super::{InterpreterConfig, InterpreterKind, PythonInterpreter};
use crate::cross_compile::{
find_build_details, find_sysconfigdata, parse_build_details_json_file, parse_sysconfigdata,
};
use crate::{BridgeModel, Target};
use anyhow::{Context, Result, bail, format_err};
use pep440_rs::VersionSpecifiers;
use std::env;
use std::path::{Path, PathBuf};
use tracing::debug;
use crate::bridge::{PyO3, StableAbiVersion};
#[derive(Debug, Clone, PartialEq, Eq)]
enum CandidateSource {
Executable,
CrossCompileLib,
Sysconfig,
Placeholder,
}
#[derive(Debug, Clone)]
struct Candidate {
interpreter: PythonInterpreter,
source: CandidateSource,
}
impl Candidate {
fn from_interpreters(interps: Vec<PythonInterpreter>) -> Vec<Self> {
interps
.into_iter()
.map(|interpreter| {
let source = if interpreter.runnable {
CandidateSource::Executable
} else {
CandidateSource::Sysconfig
};
Candidate {
interpreter,
source,
}
})
.collect()
}
fn into_interpreter(self) -> PythonInterpreter {
self.interpreter
}
}
type DiscoveryResult = (Vec<Candidate>, Option<PythonInterpreter>);
#[derive(Debug, Clone)]
struct InterpreterSpec {
kind: InterpreterKind,
major: usize,
minor: usize,
abiflags: String,
}
impl InterpreterSpec {
fn parse(s: &str) -> Result<Option<Self>> {
let (kind, ver_str) = if let Some(ver) = s.strip_prefix("pypy") {
(InterpreterKind::PyPy, ver.strip_prefix('-').unwrap_or(ver))
} else if let Some(ver) = s.strip_prefix("graalpy") {
(
InterpreterKind::GraalPy,
ver.strip_prefix('-').unwrap_or(ver),
)
} else if let Some(ver) = s.strip_prefix("python") {
(
InterpreterKind::CPython,
ver.strip_prefix('-').unwrap_or(ver),
)
} else if s.starts_with(|c: char| c.is_ascii_digit()) {
(InterpreterKind::CPython, s)
} else {
return Ok(None);
};
if ver_str.is_empty() || !ver_str.starts_with(|c: char| c.is_ascii_digit()) {
return Ok(None);
}
let (ver_str, abiflags) = if let Some(v) = ver_str.strip_suffix('t') {
(v, "t")
} else {
(ver_str, "")
};
if !matches!(kind, InterpreterKind::CPython) && abiflags == "t" {
bail!("Free-threaded builds are only supported for CPython, not {kind}");
}
let (major_str, minor_str) = match ver_str.split_once('.') {
Some(parts) => parts,
None => return Ok(None), };
let major = major_str.parse::<usize>().with_context(|| {
format!("Invalid python interpreter major version '{major_str}', expect a digit")
})?;
let minor = minor_str.parse::<usize>().with_context(|| {
format!("Invalid python interpreter minor version '{minor_str}', expect a digit")
})?;
if (major, minor) < (3, 13) && abiflags == "t" {
bail!("Free-threaded Python interpreter is only supported on 3.13 and later.");
}
Ok(Some(InterpreterSpec {
kind,
major,
minor,
abiflags: abiflags.to_string(),
}))
}
fn try_parse_filename(s: &str) -> Option<Self> {
Self::parse(s).ok().flatten()
}
}
pub struct InterpreterResolver<'a> {
target: &'a Target,
bridge: &'a BridgeModel,
requires_python: Option<&'a VersionSpecifiers>,
user_interpreters: &'a [PathBuf],
find_interpreter: bool,
generate_import_lib: bool,
}
impl<'a> InterpreterResolver<'a> {
pub fn new(
target: &'a Target,
bridge: &'a BridgeModel,
requires_python: Option<&'a VersionSpecifiers>,
user_interpreters: &'a [PathBuf],
find_interpreter: bool,
generate_import_lib: bool,
) -> Self {
Self {
target,
bridge,
requires_python,
user_interpreters,
find_interpreter,
generate_import_lib,
}
}
pub fn resolve(&self) -> Result<(Vec<PythonInterpreter>, Option<PathBuf>)> {
match self.bridge {
BridgeModel::Cffi => self.resolve_single("cffi").map(|i| (vec![i], None)),
BridgeModel::Bin(None) | BridgeModel::UniFfi => Ok((vec![], None)),
BridgeModel::PyO3(pyo3) | BridgeModel::Bin(Some(pyo3)) => self.resolve_pyo3(pyo3),
}
}
fn resolve_pyo3(&self, pyo3: &PyO3) -> Result<(Vec<PythonInterpreter>, Option<PathBuf>)> {
if let Some(config_file) = env::var_os("PYO3_CONFIG_FILE") {
let config = InterpreterConfig::from_pyo3_config(config_file.as_ref(), self.target)
.context("Invalid PYO3_CONFIG_FILE")?;
return Ok((vec![PythonInterpreter::from_config(config)], None));
}
let fixed_abi3 = match &pyo3.stable_abi {
Some(stable_abi) => match stable_abi.version {
StableAbiVersion::Version(major, minor) => Some((major, minor)),
_ => None,
},
_ => None,
};
let (candidates, host_python_interp) = self.discover_candidates(fixed_abi3)?;
let host_python = host_python_interp.as_ref().map(|h| h.executable.clone());
let interpreters = if let Some((major, minor)) = fixed_abi3 {
let filtered = if !self.find_interpreter && !self.user_interpreters.is_empty() {
candidates
} else {
self.filter_for_abi3(candidates)
};
self.finalize_abi3(filtered, major, minor)?
} else {
Self::print_found_candidates(&candidates);
Self::candidates_to_interpreters(candidates)
};
Ok((interpreters, host_python))
}
fn discover_candidates(&self, fixed_abi3: Option<(u8, u8)>) -> Result<DiscoveryResult> {
if self.target.cross_compiling()
&& let Some(cross_lib_dir) = env::var_os("PYO3_CROSS_LIB_DIR")
{
if let Some((major, minor)) = fixed_abi3
&& self.target.is_windows()
{
return Ok((
vec![Candidate {
interpreter: PythonInterpreter::placeholder(
major as usize,
minor as usize,
self.target,
),
source: CandidateSource::Placeholder,
}],
None,
));
}
return self.discover_from_cross_lib_dir(cross_lib_dir.as_ref());
}
if self.target.cross_compiling() && fixed_abi3.is_none() {
return self.discover_cross_sysconfig();
}
self.discover_native(fixed_abi3)
}
fn discover_from_cross_lib_dir(&self, cross_lib_path: &Path) -> Result<DiscoveryResult> {
if let Some(build_details_path) = find_build_details(cross_lib_path) {
eprintln!("🐍 Using build-details.json for cross-compiling preparation");
let config = parse_build_details_json_file(&build_details_path)?;
let host_python = self.find_host_python()?;
let soabi = InterpreterConfig::soabi_from_ext_suffix(&config.ext_suffix);
let implementation_name = config.interpreter_kind.to_string().to_ascii_lowercase();
let interp = PythonInterpreter {
config,
executable: PathBuf::new(),
platform: None,
runnable: false,
implementation_name,
soabi,
};
Ok((
vec![Candidate {
interpreter: interp,
source: CandidateSource::CrossCompileLib,
}],
Some(host_python),
))
} else {
let host_python = self.find_host_python()?;
eprintln!("🐍 Using host {host_python} for cross-compiling preparation");
let sysconfig_path = find_sysconfigdata(cross_lib_path, self.target)?;
let sysconfig_data = parse_sysconfigdata(&host_python, sysconfig_path)?;
let interpreter = self.interpreter_from_sysconfigdata(&sysconfig_data)?;
Ok((
vec![Candidate {
interpreter,
source: CandidateSource::CrossCompileLib,
}],
Some(host_python),
))
}
}
fn discover_cross_sysconfig(&self) -> Result<DiscoveryResult> {
if self.user_interpreters.is_empty() && !self.find_interpreter {
bail!(
"Couldn't find any python interpreters. \
Please specify at least one with -i"
);
}
for interp in self.user_interpreters {
if interp.components().count() > 1
&& super::discovery::check_executable(interp, self.target, self.bridge)?.is_none()
{
bail!("{} is not a valid python interpreter", interp.display());
}
}
let interpreters = self.find_in_sysconfig(self.user_interpreters)?;
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.",
self.user_interpreters
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
);
}
Ok((
interpreters
.into_iter()
.map(|interpreter| Candidate {
interpreter,
source: CandidateSource::Sysconfig,
})
.collect(),
None,
))
}
fn discover_native(&self, fixed_abi3: Option<(u8, u8)>) -> Result<DiscoveryResult> {
let found = if self.find_interpreter {
super::discovery::find_all(self.target, self.bridge, self.requires_python)
.context("Finding python interpreters failed")?
} else {
self.find_specified_interpreters()?
};
if !found.is_empty() {
return Ok((Candidate::from_interpreters(found), None));
}
if fixed_abi3.is_some() {
if self.target.is_windows() && !self.generate_import_lib {
bail!(
"Need a Python interpreter to compile for Windows without \
PyO3's `generate-import-lib` feature"
);
}
let sysconfig_result = self.find_in_sysconfig(self.user_interpreters);
let sysconfig_interps = if self.user_interpreters.is_empty() {
sysconfig_result.unwrap_or_default()
} else {
sysconfig_result?
};
if !sysconfig_interps.is_empty() || self.user_interpreters.is_empty() {
return Ok((
sysconfig_interps
.into_iter()
.map(|interpreter| Candidate {
interpreter,
source: CandidateSource::Sysconfig,
})
.collect(),
None,
));
}
}
if self.find_interpreter {
if let Some(requires_python) = self.requires_python {
bail!(
"Couldn't find any python interpreters with {requires_python}. \
Please specify at least one with -i"
);
} else {
bail!(
"Couldn't find any python interpreters. \
Please specify at least one with -i"
);
}
} else {
let default_python;
let to_check: &[PathBuf] = if !self.user_interpreters.is_empty() {
self.user_interpreters
} else {
default_python = self.get_default_python();
std::slice::from_ref(&default_python)
};
let interps_str = to_check
.iter()
.map(|path| format!("'{}'", path.display()))
.collect::<Vec<_>>()
.join(", ");
bail!("Couldn't find any python interpreters from {interps_str}.");
}
}
fn find_specified_interpreters(&self) -> Result<Vec<PythonInterpreter>> {
let default_python;
let to_check: &[PathBuf] = if !self.user_interpreters.is_empty() {
self.user_interpreters
} else {
default_python = self.get_default_python();
std::slice::from_ref(&default_python)
};
let mut found = Vec::new();
let mut missing = Vec::new();
for interp in to_check {
match super::discovery::check_executable(interp.clone(), self.target, self.bridge)? {
Some(interp) => found.push(interp),
None => missing.push(interp.clone()),
}
}
if !missing.is_empty() {
let sysconfig_interps = self.find_in_sysconfig(&missing)?;
if !sysconfig_interps.is_empty()
&& self.target.is_windows()
&& !self.generate_import_lib
{
let names = sysconfig_interps
.iter()
.map(|i| format!("{} {}.{}", i.interpreter_kind, i.major, i.minor))
.collect::<Vec<_>>();
bail!(
"Interpreters {names:?} were found in maturin's bundled sysconfig, \
but compiling for Windows without an interpreter requires \
PyO3's `generate-import-lib` feature"
);
}
found.extend(sysconfig_interps);
}
if self.user_interpreters.is_empty()
&& let Some(requires_python) = self.requires_python
{
found.retain(|interp| {
requires_python.contains(&pep440_rs::Version::new([
interp.major as u64,
interp.minor as u64,
]))
});
}
Ok(found)
}
fn filter_for_abi3(&self, candidates: Vec<Candidate>) -> Vec<Candidate> {
if candidates.is_empty() {
return candidates;
}
let user_requested_pypy = self.user_requested_pypy();
let user_requested_free_threaded = self.user_requested_free_threaded();
let (abi3_capable, non_abi3): (Vec<_>, Vec<_>) = candidates
.into_iter()
.partition(|c| c.interpreter.has_stable_api());
let mut result = abi3_capable;
for candidate in non_abi3 {
let excluded = match candidate.interpreter.interpreter_kind {
InterpreterKind::PyPy => !user_requested_pypy,
InterpreterKind::CPython if candidate.interpreter.gil_disabled => {
!user_requested_free_threaded
}
_ => false,
};
if !excluded {
result.push(candidate);
}
}
result
}
fn user_requested_pypy(&self) -> bool {
self.user_interpreters.iter().any(|p| {
let name = p
.file_name()
.map(|f| f.to_string_lossy())
.unwrap_or_default();
InterpreterSpec::try_parse_filename(&name)
.is_some_and(|s| s.kind == InterpreterKind::PyPy)
|| name.starts_with("pypy")
})
}
fn user_requested_free_threaded(&self) -> bool {
self.user_interpreters.iter().any(|p| {
let name = p
.file_name()
.map(|f| f.to_string_lossy())
.unwrap_or_default();
InterpreterSpec::try_parse_filename(&name).is_some_and(|s| s.abiflags == "t")
})
}
fn finalize_abi3(
&self,
candidates: Vec<Candidate>,
major: u8,
minor: u8,
) -> Result<Vec<PythonInterpreter>> {
let candidates = if self.target.cross_compiling() {
self.handle_abi3_cross(candidates)?
} else {
candidates
};
if self.target.is_windows() {
return self.finalize_abi3_windows(candidates, major, minor);
}
if !candidates.is_empty() {
Self::print_found_candidates(&candidates);
Ok(Self::candidates_to_interpreters(candidates))
} else if self.user_interpreters.is_empty() {
eprintln!("🐍 Not using a specific python interpreter");
Ok(vec![PythonInterpreter::placeholder(
major as usize,
minor as usize,
self.target,
)])
} else {
bail!("Failed to find any python interpreter");
}
}
fn handle_abi3_cross(&self, candidates: Vec<Candidate>) -> Result<Vec<Candidate>> {
let mut interps = Vec::with_capacity(candidates.len());
let mut pypys = Vec::new();
for candidate in candidates {
if candidate.interpreter.interpreter_kind.is_pypy() {
if self.user_requested_pypy() {
pypys.push(PathBuf::from(format!(
"pypy{}.{}",
candidate.interpreter.major, candidate.interpreter.minor
)));
}
} else {
interps.push(candidate);
}
}
if !pypys.is_empty() {
let sysconfig_interps = self.find_in_sysconfig(&pypys)?;
interps.extend(sysconfig_interps.into_iter().map(|interpreter| Candidate {
interpreter,
source: CandidateSource::Sysconfig,
}));
}
Ok(interps)
}
fn finalize_abi3_windows(
&self,
candidates: Vec<Candidate>,
major: u8,
minor: u8,
) -> Result<Vec<PythonInterpreter>> {
let interpreters = Self::candidates_to_interpreters(candidates);
if self.generate_import_lib {
eprintln!(
"🐍 Not using a specific python interpreter \
(automatically generating windows import library)"
);
let mut result = interpreters;
if result.is_empty() {
result.push(PythonInterpreter::placeholder(
major as usize,
minor as usize,
self.target,
));
}
return Ok(result);
}
if interpreters.is_empty() {
bail!("Failed to find any python interpreter");
}
Ok(interpreters)
}
fn find_host_python(&self) -> Result<PythonInterpreter> {
let interpreters = if !self.user_interpreters.is_empty() {
super::discovery::check_executables(self.user_interpreters, self.target, self.bridge)?
} else {
super::discovery::find_all(self.target, self.bridge, self.requires_python)
.context("Finding python interpreters failed")?
};
if interpreters.is_empty() {
if let Some(requires_python) = self.requires_python {
bail!(
"Couldn't find any python interpreters with {}. \
Please specify at least one with -i",
requires_python
);
} else {
bail!(
"Couldn't find any python interpreters. \
Please specify at least one with -i"
);
}
}
Ok(interpreters
.into_iter()
.next()
.expect("checked non-empty above"))
}
fn find_in_sysconfig(&self, interpreters: &[PathBuf]) -> Result<Vec<PythonInterpreter>> {
if interpreters.is_empty() {
return Ok(super::discovery::lookup_target(
self.target,
self.requires_python,
Some(self.bridge),
));
}
let mut result = Vec::new();
for interp in interpreters {
let interp_display = interp.display().to_string();
let python_name = interp
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(&interp_display);
let spec = match InterpreterSpec::parse(python_name)? {
Some(spec) => spec,
None => {
eprintln!(
"⚠️ Warning: Skipping '{interp_display}': could not determine version \
from interpreter name '{python_name}'. \
Specify a version like '{python_name}3.11'."
);
continue;
}
};
let sysconfig = InterpreterConfig::lookup_one(
self.target,
spec.kind,
(spec.major, spec.minor),
&spec.abiflags,
)
.with_context(|| {
format!(
"Failed to find a {} {}.{} interpreter in known sysconfig",
spec.kind, spec.major, spec.minor
)
})?;
debug!(
"Found {} {}.{}{} in bundled sysconfig",
sysconfig.interpreter_kind, sysconfig.major, sysconfig.minor, sysconfig.abiflags
);
result.push(PythonInterpreter::from_config(sysconfig.clone()));
}
Ok(result)
}
fn interpreter_from_sysconfigdata(
&self,
data: &std::collections::HashMap<String, String>,
) -> Result<PythonInterpreter> {
let major = data
.get("version_major")
.context("version_major is not defined")?
.parse::<usize>()
.context("Could not parse value of version_major")?;
let minor = data
.get("version_minor")
.context("version_minor is not defined")?
.parse::<usize>()
.context("Could not parse value of version_minor")?;
let abiflags = data
.get("ABIFLAGS")
.map(ToString::to_string)
.unwrap_or_default();
let gil_disabled = data
.get("Py_GIL_DISABLED")
.map(|x| x == "1")
.unwrap_or_default();
let ext_suffix = data
.get("EXT_SUFFIX")
.context("sysconfig didn't define an `EXT_SUFFIX` ಠ_ಠ")?;
let soabi = data.get("SOABI");
let interpreter_kind = soabi
.and_then(|tag| {
if tag.starts_with("pypy") {
Some(InterpreterKind::PyPy)
} else if tag.starts_with("cpython") {
Some(InterpreterKind::CPython)
} else if tag.starts_with("graalpy") {
Some(InterpreterKind::GraalPy)
} else {
None
}
})
.context("unsupported Python interpreter")?;
Ok(PythonInterpreter {
config: InterpreterConfig {
major,
minor,
interpreter_kind,
abiflags,
ext_suffix: ext_suffix.to_string(),
pointer_width: None,
gil_disabled,
},
executable: PathBuf::new(),
platform: None,
runnable: false,
implementation_name: interpreter_kind.to_string().to_ascii_lowercase(),
soabi: soabi.cloned(),
})
}
fn resolve_single(&self, bridge_name: &str) -> Result<PythonInterpreter> {
let executable = if self.user_interpreters.is_empty() {
self.target.get_python()
} else if self.user_interpreters.len() == 1 {
self.user_interpreters[0].clone()
} else {
bail!(
"You can only specify one python interpreter for {}",
bridge_name
);
};
let err_message = format!(
"Failed to find a python interpreter from `{}`",
executable.display()
);
let interp = super::discovery::check_executable(executable, self.target, self.bridge)
.context(format_err!(err_message.clone()))?
.ok_or_else(|| format_err!(err_message))?;
eprintln!("🐍 Using {interp} to generate the {bridge_name} bindings");
Ok(interp)
}
fn get_default_python(&self) -> PathBuf {
if self.bridge.is_pyo3() {
std::env::var("PYO3_PYTHON")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| self.target.get_python())
} else {
self.target.get_python()
}
}
fn print_found_candidates(candidates: &[Candidate]) {
let displayable: Vec<_> = candidates
.iter()
.filter(|c| {
matches!(
c.source,
CandidateSource::Executable | CandidateSource::Sysconfig
)
})
.map(|c| c.interpreter.to_string())
.collect();
if !displayable.is_empty() {
eprintln!("🐍 Found {}", displayable.join(", "));
}
}
fn candidates_to_interpreters(candidates: Vec<Candidate>) -> Vec<PythonInterpreter> {
candidates
.into_iter()
.map(Candidate::into_interpreter)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_interpreter_spec_cpython_versions() {
let spec = InterpreterSpec::parse("python3.9").unwrap().unwrap();
assert_eq!(spec.kind, InterpreterKind::CPython);
assert_eq!(spec.major, 3);
assert_eq!(spec.minor, 9);
assert_eq!(spec.abiflags, "");
let spec = InterpreterSpec::parse("python-3.12").unwrap().unwrap();
assert_eq!(spec.kind, InterpreterKind::CPython);
assert_eq!(spec.major, 3);
assert_eq!(spec.minor, 12);
assert_eq!(spec.abiflags, "");
}
#[test]
fn test_interpreter_spec_bare_version() {
let spec = InterpreterSpec::parse("3.9").unwrap().unwrap();
assert_eq!(spec.kind, InterpreterKind::CPython);
assert_eq!(spec.major, 3);
assert_eq!(spec.minor, 9);
assert_eq!(spec.abiflags, "");
let spec = InterpreterSpec::parse("3.14").unwrap().unwrap();
assert_eq!(spec.major, 3);
assert_eq!(spec.minor, 14);
}
#[test]
fn test_interpreter_spec_free_threaded() {
let spec = InterpreterSpec::parse("python3.14t").unwrap().unwrap();
assert_eq!(spec.kind, InterpreterKind::CPython);
assert_eq!(spec.major, 3);
assert_eq!(spec.minor, 14);
assert_eq!(spec.abiflags, "t");
let spec = InterpreterSpec::parse("3.13t").unwrap().unwrap();
assert_eq!(spec.kind, InterpreterKind::CPython);
assert_eq!(spec.major, 3);
assert_eq!(spec.minor, 13);
assert_eq!(spec.abiflags, "t");
}
#[test]
fn test_interpreter_spec_free_threaded_too_old() {
let err = InterpreterSpec::parse("python3.12t").unwrap_err();
assert!(
err.to_string().contains("3.13"),
"expected version constraint in error: {err}"
);
}
#[test]
fn test_interpreter_spec_pypy() {
let spec = InterpreterSpec::parse("pypy3.11").unwrap().unwrap();
assert_eq!(spec.kind, InterpreterKind::PyPy);
assert_eq!(spec.major, 3);
assert_eq!(spec.minor, 11);
assert_eq!(spec.abiflags, "");
let spec = InterpreterSpec::parse("pypy-3.10").unwrap().unwrap();
assert_eq!(spec.kind, InterpreterKind::PyPy);
assert_eq!(spec.major, 3);
assert_eq!(spec.minor, 10);
}
#[test]
fn test_interpreter_spec_pypy_free_threaded_rejected() {
let err = InterpreterSpec::parse("pypy3.11t").unwrap_err();
assert!(
err.to_string().contains("Free-threaded"),
"expected free-threaded error: {err}"
);
}
#[test]
fn test_interpreter_spec_graalpy() {
let spec = InterpreterSpec::parse("graalpy3.10").unwrap().unwrap();
assert_eq!(spec.kind, InterpreterKind::GraalPy);
assert_eq!(spec.major, 3);
assert_eq!(spec.minor, 10);
let spec = InterpreterSpec::parse("graalpy-3.10").unwrap().unwrap();
assert_eq!(spec.kind, InterpreterKind::GraalPy);
assert_eq!(spec.major, 3);
assert_eq!(spec.minor, 10);
}
#[test]
fn test_interpreter_spec_graalpy_free_threaded_rejected() {
let err = InterpreterSpec::parse("graalpy3.10t").unwrap_err();
assert!(
err.to_string().contains("Free-threaded"),
"expected free-threaded error: {err}"
);
}
#[test]
fn test_interpreter_spec_version_less_returns_none() {
assert!(InterpreterSpec::parse("python").unwrap().is_none());
assert!(InterpreterSpec::parse("pypy").unwrap().is_none());
assert!(InterpreterSpec::parse("graalpy").unwrap().is_none());
}
#[test]
fn test_interpreter_spec_unrecognized_name() {
assert!(InterpreterSpec::parse("jython3.9").unwrap().is_none());
assert!(
InterpreterSpec::parse("/usr/bin/python3")
.unwrap()
.is_none()
);
assert!(InterpreterSpec::parse("python.exe").unwrap().is_none());
assert!(InterpreterSpec::parse("python3").unwrap().is_none());
}
#[test]
fn test_interpreter_spec_try_parse_filename() {
let spec = InterpreterSpec::try_parse_filename("python3.14t").unwrap();
assert_eq!(spec.kind, InterpreterKind::CPython);
assert_eq!(spec.abiflags, "t");
let spec = InterpreterSpec::try_parse_filename("pypy3.10").unwrap();
assert_eq!(spec.kind, InterpreterKind::PyPy);
assert!(InterpreterSpec::try_parse_filename("python").is_none());
assert!(InterpreterSpec::try_parse_filename("jython3.9").is_none());
}
}