Allow selection of debug build interpreters (#11520)

Extends the `PythonVariant` logic to support interpreters with the debug
flag enabled.
This commit is contained in:
Zanie Blue
2025-09-12 08:32:22 -05:00
committed by GitHub
parent 8917b00fd9
commit 8f3583a6e6
17 changed files with 8015 additions and 109 deletions
+155 -57
View File
@@ -166,7 +166,9 @@ pub(crate) struct DiscoveryPreferences {
pub enum PythonVariant {
#[default]
Default,
Debug,
Freethreaded,
FreethreadedDebug,
}
/// A Python discovery version request.
@@ -1296,6 +1298,7 @@ pub(crate) fn find_python_installation(
let installations =
find_python_installations(request, environments, preference, cache, preview);
let mut first_prerelease = None;
let mut first_debug = None;
let mut first_managed = None;
let mut first_error = None;
for result in installations {
@@ -1340,6 +1343,20 @@ pub(crate) fn find_python_installation(
continue;
}
// If it's a debug build and debug builds aren't allowed, skip it — but store it for later
// since we'll use a debug build if no other versions are available.
if installation.key().variant().is_debug()
&& !request.allows_debug()
&& !installation.source.allows_debug()
&& !has_default_executable_name
{
debug!("Skipping debug installation {}", installation.key());
if first_debug.is_none() {
first_debug = Some(installation.clone());
}
continue;
}
// If it's an alternative implementation and alternative implementations aren't allowed,
// skip it. Note we avoid querying these interpreters at all if they're on the search path
// and are not requested, but other sources such as the managed installations can include
@@ -1382,6 +1399,16 @@ pub(crate) fn find_python_installation(
return Ok(Ok(installation));
}
// If we only found debug installations, they're implicitly allowed and we should return the
// first one.
if let Some(installation) = first_debug {
debug!(
"Allowing debug installation {}: no non-debug installations",
installation.key()
);
return Ok(Ok(installation));
}
// If we only found pre-releases, they're implicitly allowed and we should return the first one.
if let Some(installation) = first_prerelease {
debug!(
@@ -1641,18 +1668,47 @@ fn is_windows_store_shim(_path: &Path) -> bool {
impl PythonVariant {
fn matches_interpreter(self, interpreter: &Interpreter) -> bool {
match self {
// TODO(zanieb): Right now, we allow debug interpreters to be selected by default for
// backwards compatibility, but we may want to change this in the future.
Self::Default => !interpreter.gil_disabled(),
Self::Debug => interpreter.debug_enabled(),
Self::Freethreaded => interpreter.gil_disabled(),
Self::FreethreadedDebug => interpreter.gil_disabled() && interpreter.debug_enabled(),
}
}
/// Return the lib or executable suffix for the variant, e.g., `t` for `python3.13t`.
/// Return the executable suffix for the variant, e.g., `t` for `python3.13t`.
///
/// Returns an empty string for the default Python variant.
pub fn suffix(self) -> &'static str {
match self {
Self::Default => "",
Self::Debug => "d",
Self::Freethreaded => "t",
Self::FreethreadedDebug => "td",
}
}
/// Return the lib suffix for the variant, e.g., `t` for `python3.13t` but an empty string for
/// `python3.13d` or `python3.13`.
pub fn lib_suffix(self) -> &'static str {
match self {
Self::Default | Self::Debug => "",
Self::Freethreaded | Self::FreethreadedDebug => "t",
}
}
pub fn is_freethreaded(self) -> bool {
match self {
Self::Default | Self::Debug => false,
Self::Freethreaded | Self::FreethreadedDebug => true,
}
}
pub fn is_debug(self) -> bool {
match self {
Self::Default | Self::Freethreaded => false,
Self::Debug | Self::FreethreadedDebug => true,
}
}
}
@@ -1984,6 +2040,19 @@ impl PythonRequest {
}
}
/// Whether this request opts-in to a debug Python version.
pub(crate) fn allows_debug(&self) -> bool {
match self {
Self::Default => false,
Self::Any => true,
Self::Version(version) => version.is_debug(),
Self::Directory(_) | Self::File(_) | Self::ExecutableName(_) => true,
Self::Implementation(_) => false,
Self::ImplementationVersion(_, _) => true,
Self::Key(request) => request.allows_debug(),
}
}
/// Whether this request opts-in to an alternative Python implementation, e.g., PyPy.
pub(crate) fn allows_alternative_implementations(&self) -> bool {
match self {
@@ -2043,6 +2112,21 @@ impl PythonSource {
}
}
/// Whether a debug Python installation from this source can be used without opt-in.
pub(crate) fn allows_debug(self) -> bool {
match self {
Self::Managed | Self::Registry | Self::MicrosoftStore => false,
Self::SearchPath
| Self::SearchPathFirst
| Self::CondaPrefix
| Self::BaseCondaPrefix
| Self::ProvidedPath
| Self::ParentInterpreter
| Self::ActiveEnvironment
| Self::DiscoveredEnvironment => true,
}
}
/// Whether an alternative Python implementation from this source can be used without opt-in.
pub(crate) fn allows_alternative_implementations(self) -> bool {
match self {
@@ -2423,10 +2507,12 @@ impl VersionRequest {
}
// Include free-threaded variants
if self.is_freethreaded() {
for i in 0..names.len() {
let name = names[i].with_variant(PythonVariant::Freethreaded);
names.push(name);
if let Some(variant) = self.variant() {
if variant != PythonVariant::Default {
for i in 0..names.len() {
let name = names[i].with_variant(variant);
names.push(name);
}
}
}
@@ -2725,6 +2811,18 @@ impl VersionRequest {
}
}
/// Whether this request is for a debug Python variant.
pub(crate) fn is_debug(&self) -> bool {
match self {
Self::Any | Self::Default => false,
Self::Major(_, variant)
| Self::MajorMinor(_, _, variant)
| Self::MajorMinorPatch(_, _, _, variant)
| Self::MajorMinorPrerelease(_, _, _, variant)
| Self::Range(_, variant) => variant.is_debug(),
}
}
/// Whether this request is for a free-threaded Python variant.
pub(crate) fn is_freethreaded(&self) -> bool {
match self {
@@ -2733,7 +2831,7 @@ impl VersionRequest {
| Self::MajorMinor(_, _, variant)
| Self::MajorMinorPatch(_, _, _, variant)
| Self::MajorMinorPrerelease(_, _, _, variant)
| Self::Range(_, variant) => variant == &PythonVariant::Freethreaded,
| Self::Range(_, variant) => variant.is_freethreaded(),
}
}
@@ -2778,24 +2876,43 @@ impl FromStr for VersionRequest {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// Stripping the 't' suffix produces awkward error messages if the user tries a version
// like "latest". HACK: If the version is all letters, don't even try to parse it further.
if s.chars().all(char::is_alphabetic) {
return Err(Error::InvalidVersionRequest(s.to_string()));
}
// Check if the version request is for a free-threaded Python version
let (s, variant) = s
.strip_suffix('t')
.map_or((s, PythonVariant::Default), |s| {
(s, PythonVariant::Freethreaded)
});
if variant == PythonVariant::Freethreaded && s.ends_with('t') {
// More than one trailing "t" is not allowed
return Err(Error::InvalidVersionRequest(format!("{s}t")));
/// Extract the variant from the end of a version request string, returning the prefix and
/// the variant type.
fn parse_variant(s: &str) -> Result<(&str, PythonVariant), Error> {
// This cannot be a valid version, just error immediately
if s.chars().all(char::is_alphabetic) {
return Err(Error::InvalidVersionRequest(s.to_string()));
}
let Some(mut start) = s.rfind(|c: char| c.is_numeric()) else {
return Ok((s, PythonVariant::Default));
};
// Advance past the first digit
start += 1;
// Ensure we're not out of bounds
if start + 1 > s.len() {
return Ok((s, PythonVariant::Default));
}
let variant = &s[start..];
let prefix = &s[..start];
// Strip a leading `+` if present
let variant = variant.strip_prefix('+').unwrap_or(variant);
// TODO(zanieb): Special-case error for use of `dt` instead of `td`
// If there's not a valid variant, fallback to failure in [`Version::from_str`]
let Ok(variant) = PythonVariant::from_str(variant) else {
return Ok((s, PythonVariant::Default));
};
Ok((prefix, variant))
}
let (s, variant) = parse_variant(s)?;
let Ok(version) = Version::from_str(s) else {
return parse_version_specifiers_request(s, variant);
};
@@ -2808,26 +2925,11 @@ impl FromStr for VersionRequest {
return Err(Error::InvalidVersionRequest(s.to_string()));
}
// Check if the local version includes a variant
let variant = if version.local().is_empty() {
variant
} else {
// If we already have a variant, do not allow another to be requested
if variant != PythonVariant::Default {
return Err(Error::InvalidVersionRequest(s.to_string()));
}
let uv_pep440::LocalVersionSlice::Segments([uv_pep440::LocalSegment::String(local)]) =
version.local()
else {
return Err(Error::InvalidVersionRequest(s.to_string()));
};
match local.as_str() {
"freethreaded" => PythonVariant::Freethreaded,
_ => return Err(Error::InvalidVersionRequest(s.to_string())),
}
};
// We don't allow local version suffixes unless they're variants, in which case they'd
// already be stripped.
if !version.local().is_empty() {
return Err(Error::InvalidVersionRequest(s.to_string()));
}
// Cast the release components into u8s since that's what we use in `VersionRequest`
let Ok(release) = try_into_u8_slice(&version.release()) else {
@@ -2879,6 +2981,8 @@ impl FromStr for PythonVariant {
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"t" | "freethreaded" => Ok(Self::Freethreaded),
"d" | "debug" => Ok(Self::Debug),
"td" | "freethreaded+debug" => Ok(Self::FreethreadedDebug),
"" => Ok(Self::Default),
_ => Err(()),
}
@@ -2889,7 +2993,9 @@ impl fmt::Display for PythonVariant {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::Default => f.write_str("default"),
Self::Debug => f.write_str("debug"),
Self::Freethreaded => f.write_str("freethreaded"),
Self::FreethreadedDebug => f.write_str("freethreaded+debug"),
}
}
}
@@ -2919,23 +3025,15 @@ impl fmt::Display for VersionRequest {
match self {
Self::Any => f.write_str("any"),
Self::Default => f.write_str("default"),
Self::Major(major, PythonVariant::Default) => write!(f, "{major}"),
Self::Major(major, PythonVariant::Freethreaded) => write!(f, "{major}t"),
Self::MajorMinor(major, minor, PythonVariant::Default) => write!(f, "{major}.{minor}"),
Self::MajorMinor(major, minor, PythonVariant::Freethreaded) => {
write!(f, "{major}.{minor}t")
Self::Major(major, variant) => write!(f, "{major}{}", variant.suffix()),
Self::MajorMinor(major, minor, variant) => {
write!(f, "{major}.{minor}{}", variant.suffix())
}
Self::MajorMinorPatch(major, minor, patch, PythonVariant::Default) => {
write!(f, "{major}.{minor}.{patch}")
Self::MajorMinorPatch(major, minor, patch, variant) => {
write!(f, "{major}.{minor}.{patch}{}", variant.suffix())
}
Self::MajorMinorPatch(major, minor, patch, PythonVariant::Freethreaded) => {
write!(f, "{major}.{minor}.{patch}t")
}
Self::MajorMinorPrerelease(major, minor, prerelease, PythonVariant::Default) => {
write!(f, "{major}.{minor}{prerelease}")
}
Self::MajorMinorPrerelease(major, minor, prerelease, PythonVariant::Freethreaded) => {
write!(f, "{major}.{minor}{prerelease}t")
Self::MajorMinorPrerelease(major, minor, prerelease, variant) => {
write!(f, "{major}.{minor}{prerelease}{}", variant.suffix())
}
Self::Range(specifiers, _) => write!(f, "{specifiers}"),
}
+5
View File
@@ -497,6 +497,11 @@ impl PythonDownloadRequest {
})
}
/// Whether this download request opts-in to a debug Python version.
pub fn allows_debug(&self) -> bool {
self.version.as_ref().is_some_and(VersionRequest::is_debug)
}
/// Whether this download request opts-in to alternative Python implementations.
pub fn allows_alternative_implementations(&self) -> bool {
self.implementation
+2 -2
View File
@@ -491,7 +491,7 @@ impl fmt::Display for PythonInstallationKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let variant = match self.variant {
PythonVariant::Default => String::new(),
PythonVariant::Freethreaded => format!("+{}", self.variant),
_ => format!("+{}", self.variant),
};
write!(
f,
@@ -632,7 +632,7 @@ impl fmt::Display for PythonInstallationMinorVersionKey {
// and prerelease (with special formatting for the variant).
let variant = match self.0.variant {
PythonVariant::Default => String::new(),
PythonVariant::Freethreaded => format!("+{}", self.0.variant),
_ => format!("+{}", self.0.variant),
};
write!(
f,
+20 -2
View File
@@ -37,6 +37,7 @@ use crate::{
use windows::Win32::Foundation::{APPMODEL_ERROR_NO_PACKAGE, ERROR_CANT_ACCESS_FILE, WIN32_ERROR};
/// A Python executable and its associated platform markers.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
pub struct Interpreter {
platform: Platform,
@@ -59,6 +60,7 @@ pub struct Interpreter {
pointer_size: PointerSize,
gil_disabled: bool,
real_executable: PathBuf,
debug_enabled: bool,
}
impl Interpreter {
@@ -82,6 +84,7 @@ impl Interpreter {
sys_base_exec_prefix: info.sys_base_exec_prefix,
pointer_size: info.pointer_size,
gil_disabled: info.gil_disabled,
debug_enabled: info.debug_enabled,
sys_base_prefix: info.sys_base_prefix,
sys_base_executable: info.sys_base_executable,
sys_executable: info.sys_executable,
@@ -212,7 +215,13 @@ impl Interpreter {
pub fn variant(&self) -> PythonVariant {
if self.gil_disabled() {
PythonVariant::Freethreaded
if self.debug_enabled() {
PythonVariant::FreethreadedDebug
} else {
PythonVariant::Freethreaded
}
} else if self.debug_enabled() {
PythonVariant::Debug
} else {
PythonVariant::default()
}
@@ -508,6 +517,12 @@ impl Interpreter {
self.gil_disabled
}
/// Return whether this is a debug build of Python, as specified by the sysconfig var
/// `Py_DEBUG`.
pub fn debug_enabled(&self) -> bool {
self.debug_enabled
}
/// Return the `--target` directory for this interpreter, if any.
pub fn target(&self) -> Option<&Target> {
self.target.as_ref()
@@ -877,6 +892,7 @@ pub enum InterpreterInfoError {
EmscriptenNotPyodide,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Deserialize, Serialize, Clone)]
struct InterpreterInfo {
platform: Platform,
@@ -895,6 +911,7 @@ struct InterpreterInfo {
standalone: bool,
pointer_size: PointerSize,
gil_disabled: bool,
debug_enabled: bool,
}
impl InterpreterInfo {
@@ -1299,7 +1316,8 @@ mod tests {
"scripts": "bin"
},
"pointer_size": "64",
"gil_disabled": true
"gil_disabled": true,
"debug_enabled": false
}
"##};
+4 -2
View File
@@ -294,7 +294,8 @@ mod tests {
"scripts": "bin"
},
"pointer_size": "64",
"gil_disabled": {FREE_THREADED}
"gil_disabled": {FREE_THREADED},
"debug_enabled": false
}
"##};
@@ -385,7 +386,8 @@ mod tests {
"data": ""
},
"pointer_size": "32",
"gil_disabled": false
"gil_disabled": false,
"debug_enabled": false
}
"##};
+2 -2
View File
@@ -574,7 +574,7 @@ impl ManagedPythonInstallation {
let stdlib = if self.key.os().is_windows() {
self.python_dir().join("Lib")
} else {
let lib_suffix = self.key.variant.suffix();
let lib_suffix = self.key.variant.lib_suffix();
let python = if matches!(
self.key.implementation,
LenientImplementationName::Known(ImplementationName::PyPy)
@@ -605,7 +605,7 @@ impl ManagedPythonInstallation {
self.path(),
self.key.major,
self.key.minor,
self.key.variant.suffix(),
self.key.variant.lib_suffix(),
)?;
}
}
+3 -3
View File
@@ -106,7 +106,7 @@ fn find_sysconfigdata(
.join("lib")
.join(format!("python{major}.{minor}{suffix}"));
if !lib.exists() {
return Err(Error::MissingLib);
return Err(Error::MissingLib(lib));
}
// Probe the `lib` directory for `_sysconfigdata_`.
@@ -270,8 +270,8 @@ fn patch_pkgconfig(contents: &str) -> Option<String> {
pub enum Error {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("Python installation is missing a `lib` directory")]
MissingLib,
#[error("Python installation is missing a `lib` directory at: {0}")]
MissingLib(PathBuf),
#[error("Python installation is missing a `_sysconfigdata_` file")]
MissingSysconfigdata,
#[error(transparent)]