Skip to content
Closed
1 change: 1 addition & 0 deletions newsfragments/5606.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
If the `sysconfigdata` defines `PYTHONFRAMEWORK`, and the build is configured to link the against Python library, the link will be performed against that framework, using `PYTHONFRAMEWORKPREFIX` as a framework search path.
73 changes: 62 additions & 11 deletions pyo3-build-config/src/impl_.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ pub struct InterpreterConfig {
/// Serialized to `version`.
pub version: PythonVersion,

/// The name of the Python framework (if available)
///
/// If present (and not empty), the named framework will be used for
/// linking using the `python_framework_prefix` as the framework search
/// path. This overrides the use of `shared` and `lib_name` for linking.
///
/// Serialized to `framework`.
pub framework: Option<String>,

/// Whether link library is shared.
///
/// Serialized to `shared`.
Expand Down Expand Up @@ -242,6 +251,10 @@ def print_if_set(varname, value):
if value is not None:
print(varname, value)

def print_if_not_empty(varname, value):
if value:
print(varname, value)

# Windows always uses shared linking
WINDOWS = platform.system() == "Windows"

Expand All @@ -255,8 +268,9 @@ SHARED = bool(get_config_var("Py_ENABLE_SHARED"))
print("implementation", platform.python_implementation())
print("version_major", sys.version_info[0])
print("version_minor", sys.version_info[1])
print_if_not_empty("framework", get_config_var("PYTHONFRAMEWORK"))
print("shared", PYPY or GRAALPY or ANACONDA or WINDOWS or FRAMEWORK or SHARED)
print("python_framework_prefix", FRAMEWORK_PREFIX)
print_if_not_empty("python_framework_prefix", FRAMEWORK_PREFIX)
print_if_set("ld_version", get_config_var("LDVERSION"))
print_if_set("libdir", get_config_var("LIBDIR"))
print_if_set("base_prefix", base_prefix)
Expand Down Expand Up @@ -293,6 +307,7 @@ print("gil_disabled", get_config_var("Py_GIL_DISABLED"))
);
};

let framework = map.get("framework").cloned();
let shared = map["shared"].as_str() == "True";
let python_framework_prefix = map.get("python_framework_prefix").cloned();

Expand Down Expand Up @@ -360,6 +375,7 @@ print("gil_disabled", get_config_var("Py_GIL_DISABLED"))
Ok(InterpreterConfig {
version,
implementation,
framework,
shared,
abi3,
lib_name: Some(lib_name),
Expand Down Expand Up @@ -402,13 +418,16 @@ print("gil_disabled", get_config_var("Py_GIL_DISABLED"))
Some("0") | Some("false") | Some("False") => false,
_ => bail!("expected a bool (1/true/True or 0/false/False) for Py_ENABLE_SHARED"),
};
// macOS framework packages use shared linking (PYTHONFRAMEWORK is the framework name, hence the empty check)
let framework = match sysconfigdata.get_value("PYTHONFRAMEWORK") {
Some(s) => !s.is_empty(),
_ => false,
};
let python_framework_prefix = sysconfigdata
.get_value("PYTHONFRAMEWORKPREFIX")
// macOS framework packages use shared linking (PYTHONFRAMEWORK is the
// framework name, hence the empty check) Empty values are converted to
// None.
let framework = get_key!(sysconfigdata, "PYTHONFRAMEWORK")
.ok()
.filter(|s| !s.is_empty())
.map(str::to_string);
let python_framework_prefix = get_key!(sysconfigdata, "PYTHONFRAMEWORKPREFIX")
.ok()
.filter(|s| !s.is_empty())
.map(str::to_string);
let lib_dir = get_key!(sysconfigdata, "LIBDIR").ok().map(str::to_string);
let gil_disabled = match sysconfigdata.get_value("Py_GIL_DISABLED") {
Expand All @@ -429,11 +448,13 @@ print("gil_disabled", get_config_var("Py_GIL_DISABLED"))
.map(|bytes_width: u32| bytes_width * 8)
.ok();
let build_flags = BuildFlags::from_sysconfigdata(sysconfigdata);
let shared = shared || framework.is_some();

Ok(InterpreterConfig {
implementation,
version,
shared: shared || framework,
framework,
shared,
abi3,
lib_dir,
lib_name,
Expand Down Expand Up @@ -510,6 +531,7 @@ print("gil_disabled", get_config_var("Py_GIL_DISABLED"))

let mut implementation = None;
let mut version = None;
let mut framework = None;
let mut shared = None;
let mut abi3 = None;
let mut lib_name = None;
Expand All @@ -535,6 +557,7 @@ print("gil_disabled", get_config_var("Py_GIL_DISABLED"))
match key {
"implementation" => parse_value!(implementation, value),
"version" => parse_value!(version, value),
"framework" => parse_value!(framework, value),
"shared" => parse_value!(shared, value),
"abi3" => parse_value!(abi3, value),
"lib_name" => parse_value!(lib_name, value),
Expand All @@ -561,6 +584,7 @@ print("gil_disabled", get_config_var("Py_GIL_DISABLED"))
Ok(InterpreterConfig {
implementation,
version,
framework,
shared: shared.unwrap_or(true),
abi3,
lib_name,
Expand Down Expand Up @@ -674,6 +698,7 @@ print("gil_disabled", get_config_var("Py_GIL_DISABLED"))

write_line!(implementation)?;
write_line!(version)?;
write_option_line!(framework)?;
write_line!(shared)?;
write_line!(abi3)?;
write_option_line!(lib_name)?;
Expand Down Expand Up @@ -1625,6 +1650,7 @@ fn default_cross_compile(cross_compile_config: &CrossCompileConfig) -> Result<In
Ok(InterpreterConfig {
implementation,
version,
framework: None,
shared: true,
abi3,
lib_name: Some(lib_name),
Expand Down Expand Up @@ -1668,6 +1694,7 @@ fn default_abi3_config(host: &Triple, version: PythonVersion) -> Result<Interpre
Ok(InterpreterConfig {
implementation,
version,
framework: None,
shared: true,
abi3,
lib_name,
Expand Down Expand Up @@ -2058,11 +2085,12 @@ mod tests {
implementation: PythonImplementation::CPython,
lib_name: Some("lib_name".into()),
lib_dir: Some("lib_dir".into()),
framework: Some("Python".into()),
shared: true,
version: MINIMUM_SUPPORTED_VERSION,
suppress_build_script_link_lines: true,
extra_build_script_lines: vec!["cargo:test1".to_string(), "cargo:test2".to_string()],
python_framework_prefix: None,
python_framework_prefix: Some("python_framework_prefix".into()),
};
let mut buf: Vec<u8> = Vec::new();
config.to_writer(&mut buf).unwrap();
Expand All @@ -2084,6 +2112,7 @@ mod tests {
implementation: PythonImplementation::PyPy,
lib_dir: None,
lib_name: None,
framework: None,
shared: true,
version: PythonVersion {
major: 3,
Expand All @@ -2109,6 +2138,7 @@ mod tests {
implementation: PythonImplementation::CPython,
lib_name: Some("lib_name".into()),
lib_dir: Some("lib_dir\\n".into()),
framework: None,
shared: true,
version: MINIMUM_SUPPORTED_VERSION,
suppress_build_script_link_lines: true,
Expand All @@ -2131,6 +2161,7 @@ mod tests {
InterpreterConfig {
version: PythonVersion { major: 3, minor: 7 },
implementation: PythonImplementation::CPython,
framework: None,
shared: true,
abi3: false,
lib_name: None,
Expand All @@ -2154,6 +2185,7 @@ mod tests {
InterpreterConfig {
version: PythonVersion { major: 3, minor: 7 },
implementation: PythonImplementation::CPython,
framework: None,
shared: true,
abi3: false,
lib_name: None,
Expand Down Expand Up @@ -2262,6 +2294,7 @@ mod tests {
implementation: PythonImplementation::CPython,
lib_dir: Some("/usr/lib".into()),
lib_name: Some("python3.7m".into()),
framework: None,
shared: true,
version: PythonVersion::PY37,
suppress_build_script_link_lines: false,
Expand All @@ -2279,6 +2312,7 @@ mod tests {
// PYTHONFRAMEWORK should override Py_ENABLE_SHARED
sysconfigdata.insert("Py_ENABLE_SHARED", "0");
sysconfigdata.insert("PYTHONFRAMEWORK", "Python");
sysconfigdata.insert("PYTHONFRAMEWORKPREFIX", "/Library/Frameworks");
sysconfigdata.insert("LIBDIR", "/usr/lib");
sysconfigdata.insert("LDVERSION", "3.7m");
sysconfigdata.insert("SIZEOF_VOID_P", "8");
Expand All @@ -2292,11 +2326,12 @@ mod tests {
implementation: PythonImplementation::CPython,
lib_dir: Some("/usr/lib".into()),
lib_name: Some("python3.7m".into()),
framework: Some("Python".into()),
shared: true,
version: PythonVersion::PY37,
suppress_build_script_link_lines: false,
extra_build_script_lines: vec![],
python_framework_prefix: None,
python_framework_prefix: Some("/Library/Frameworks".into()),
}
);

Expand All @@ -2306,6 +2341,7 @@ mod tests {
// An empty PYTHONFRAMEWORK means it is not a framework
sysconfigdata.insert("Py_ENABLE_SHARED", "0");
sysconfigdata.insert("PYTHONFRAMEWORK", "");
sysconfigdata.insert("PYTHONFRAMEWORKPREFIX", "");
sysconfigdata.insert("LIBDIR", "/usr/lib");
sysconfigdata.insert("LDVERSION", "3.7m");
sysconfigdata.insert("SIZEOF_VOID_P", "8");
Expand All @@ -2319,6 +2355,7 @@ mod tests {
implementation: PythonImplementation::CPython,
lib_dir: Some("/usr/lib".into()),
lib_name: Some("python3.7m".into()),
framework: None,
shared: false,
version: PythonVersion::PY37,
suppress_build_script_link_lines: false,
Expand All @@ -2338,6 +2375,7 @@ mod tests {
InterpreterConfig {
implementation: PythonImplementation::CPython,
version: PythonVersion { major: 3, minor: 7 },
framework: None,
shared: true,
abi3: true,
lib_name: Some("python3".into()),
Expand All @@ -2362,6 +2400,7 @@ mod tests {
InterpreterConfig {
implementation: PythonImplementation::CPython,
version: PythonVersion { major: 3, minor: 9 },
framework: None,
shared: true,
abi3: true,
lib_name: None,
Expand Down Expand Up @@ -2397,6 +2436,7 @@ mod tests {
InterpreterConfig {
implementation: PythonImplementation::CPython,
version: PythonVersion { major: 3, minor: 7 },
framework: None,
shared: true,
abi3: false,
lib_name: Some("python37".into()),
Expand Down Expand Up @@ -2432,6 +2472,7 @@ mod tests {
InterpreterConfig {
implementation: PythonImplementation::CPython,
version: PythonVersion { major: 3, minor: 8 },
framework: None,
shared: true,
abi3: false,
lib_name: Some("python38".into()),
Expand Down Expand Up @@ -2467,6 +2508,7 @@ mod tests {
InterpreterConfig {
implementation: PythonImplementation::CPython,
version: PythonVersion { major: 3, minor: 9 },
framework: None,
shared: true,
abi3: false,
lib_name: Some("python3.9".into()),
Expand Down Expand Up @@ -2504,6 +2546,7 @@ mod tests {
major: 3,
minor: 11
},
framework: None,
shared: true,
abi3: false,
lib_name: Some("pypy3.11-c".into()),
Expand Down Expand Up @@ -2897,6 +2940,7 @@ mod tests {
implementation: PythonImplementation::CPython,
lib_dir: None,
lib_name: None,
framework: None,
shared: true,
version: PythonVersion { major: 3, minor: 7 },
suppress_build_script_link_lines: false,
Expand All @@ -2920,6 +2964,7 @@ mod tests {
implementation: PythonImplementation::CPython,
lib_dir: None,
lib_name: None,
framework: None,
shared: true,
version: PythonVersion { major: 3, minor: 7 },
suppress_build_script_link_lines: false,
Expand Down Expand Up @@ -2985,6 +3030,7 @@ mod tests {
implementation: PythonImplementation::CPython,
lib_dir: interpreter_config.lib_dir.to_owned(),
lib_name: interpreter_config.lib_name.to_owned(),
framework: None,
shared: true,
version: interpreter_config.version,
suppress_build_script_link_lines: false,
Expand Down Expand Up @@ -3115,6 +3161,7 @@ mod tests {
major: 3,
minor: 11,
},
framework: None,
shared: true,
abi3: false,
lib_name: Some("python3".into()),
Expand Down Expand Up @@ -3159,6 +3206,7 @@ mod tests {
let interpreter_config = InterpreterConfig {
implementation: PythonImplementation::CPython,
version: PythonVersion { major: 3, minor: 9 },
framework: None,
shared: true,
abi3: true,
lib_name: Some("python3".into()),
Expand Down Expand Up @@ -3207,6 +3255,7 @@ mod tests {
major: 3,
minor: 13,
},
framework: None,
shared: true,
abi3: false,
lib_name: Some("python3".into()),
Expand Down Expand Up @@ -3241,6 +3290,7 @@ mod tests {
let interpreter_config = InterpreterConfig {
implementation: PythonImplementation::CPython,
version: PythonVersion { major: 3, minor: 7 },
framework: None,
shared: true,
abi3: false,
lib_name: Some("python3".into()),
Expand Down Expand Up @@ -3296,6 +3346,7 @@ mod tests {
let mut config = InterpreterConfig {
implementation: PythonImplementation::CPython,
version: PythonVersion { major: 3, minor: 9 },
framework: None,
shared: true,
abi3: false,
lib_name: None,
Expand Down
2 changes: 2 additions & 0 deletions pyo3-build-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ mod tests {
major: 3,
minor: 13,
},
framework: None,
shared: true,
abi3: false,
lib_name: None,
Expand Down Expand Up @@ -455,6 +456,7 @@ mod tests {
major: 3,
minor: 13,
},
framework: None,
shared: true,
abi3: false,
lib_name: None,
Expand Down
19 changes: 15 additions & 4 deletions pyo3-ffi/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@ fn emit_link_config(build_config: &BuildConfig) -> Result<()> {

println!(
"cargo:rustc-link-lib={link_model}{alias}{lib_name}",
link_model = if interpreter_config.shared {
link_model = if interpreter_config.framework.is_some() {
"framework="
Copy link
Member

@davidhewitt davidhewitt Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the 3.11 CI failure has found a problem with this approach.

Seems like framework=Python does not specify the version, and nor does the search prefix which is just /Library/Frameworks (for the official distributions).

$ /Library/Frameworks/Python.framework/Versions/3.13/bin/python3 -m sysconfig | grep -i PYTHONFRAMEWORK
	PYTHONFRAMEWORK = "Python"
	PYTHONFRAMEWORKDIR = "Python.framework"
	PYTHONFRAMEWORKINSTALLDIR = "/Library/Frameworks/Python.framework"
	PYTHONFRAMEWORKINSTALLNAMEPREFIX = "/Library/Frameworks/Python.framework/Versions/3.13"
	PYTHONFRAMEWORKPREFIX = "/Library/Frameworks"

Not sure what implications this has for the iOS build either? Seems like it's fundamentally impossible to use frameworks and link the right version.

I wonder, maybe we don't need this PR? If cross-compile env has the correct lib_dir, potentially PyO3 main is now already doing the correct thing to link against iOS via the versioned .dylib directly with the other patches placed into PyO3 and maturin?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the 3.11 CI failure has found a problem with this approach.

Which failure is this? I'm seeing 11 skips, 32 successful tests in the most recent CI pass.

Seems like framework=Python does not specify the version, and nor does the search prefix which is just /Library/Frameworks (for the official distributions).

$ /Library/Frameworks/Python.framework/Versions/3.13/bin/python3 -m sysconfig | grep -i PYTHONFRAMEWORK
	PYTHONFRAMEWORK = "Python"
	PYTHONFRAMEWORKDIR = "Python.framework"
	PYTHONFRAMEWORKINSTALLDIR = "/Library/Frameworks/Python.framework"
	PYTHONFRAMEWORKINSTALLNAMEPREFIX = "/Library/Frameworks/Python.framework/Versions/3.13"
	PYTHONFRAMEWORKPREFIX = "/Library/Frameworks"

Not sure what implications this has for the iOS build either? Seems like it's fundamentally impossible to use frameworks and link the right version.

So - macOS frameworks and iOS frameworks (and every other non-macOS framework) are structured slightly differently.

macOS frameworks are versioned - there's a single top-level framework, with a "Versions" subfolder; each version of the framework is then contained in the top level framework. A Current symlink exists to point at one specific version, and the top level framework links the content of the current version so that /Library/Frameworks/Python.framework can be used for linking. However, that doesn't give you control over the specific version. If you want a specific version, you need to use -F /Library/Frameworks/Python.framework/Versions/3.13 as your framework search path.

I wonder, maybe we don't need this PR? If cross-compile env has the correct lib_dir, potentially PyO3 main is now already doing the correct thing to link against iOS with the other patches placed into PyO3 and maturin?

So... that doesn't currently work, because iOS doesn't ship a copy of libPython*.dylib in the lib folder. There's a range of reasons why that is the case... but with some other accomodations, that might indeed be an easier way to solve the problem. I'll experiment with this and report back.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you need to use -F /Library/Frameworks/Python.framework/Versions/3.13 as your framework search path.

I think I attempted this yesterday and had no luck. Possibly because Python.Framework only exists in /Library/Frameworks and not in any of the versioned subdirectories?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added CI-build-full label to this PR so any further pushes will run the full suite of versions we run in the merge queue.

} else if interpreter_config.shared {
""
} else {
"static="
Expand All @@ -160,11 +162,20 @@ fn emit_link_config(build_config: &BuildConfig) -> Result<()> {
} else {
""
},
lib_name = interpreter_config.lib_name.as_ref().ok_or(
"attempted to link to Python shared library but config does not contain lib_name"
)?,
lib_name = if let Some(framework) = &interpreter_config.framework {
framework
} else {
interpreter_config.lib_name.as_ref().ok_or(
"attempted to link to Python shared library but config does not contain lib_name",
)?
},
);

if interpreter_config.framework.is_some() {
if let Some(framework_prefix) = &interpreter_config.python_framework_prefix {
println!("cargo:rustc-link-search=framework={framework_prefix}");
}
}
if let Some(lib_dir) = &interpreter_config.lib_dir {
println!("cargo:rustc-link-search=native={lib_dir}");
} else if matches!(build_config.source, BuildConfigSource::CrossCompile) {
Expand Down
Loading