Skip to main content

pyforge_build_config/
lib.rs

1//! Configuration used by PyForge for conditional support of varying Python versions.
2//!
3//! This crate exposes functionality to be called from build scripts to simplify building crates
4//! which depend on PyForge.
5//!
6//! It used internally by the PyForge crate's build script to apply the same configuration.
7
8#![warn(elided_lifetimes_in_paths, unused_lifetimes)]
9
10mod errors;
11mod impl_;
12
13#[cfg(feature = "resolve-config")]
14use std::{
15    io::Cursor,
16    path::{Path, PathBuf},
17};
18
19use std::{env, process::Command, str::FromStr, sync::OnceLock};
20
21pub use impl_::{
22    cross_compiling_from_to, find_all_sysconfigdata, parse_sysconfigdata, BuildFlag, BuildFlags,
23    CrossCompileConfig, InterpreterConfig, PythonImplementation, PythonVersion, Triple,
24};
25
26use target_lexicon::OperatingSystem;
27
28/// Adds all the [`#[cfg]` flags](index.html) to the current compilation.
29///
30/// This should be called from a build script.
31///
32/// The full list of attributes added are the following:
33///
34/// | Flag | Description |
35/// | ---- | ----------- |
36/// | `#[cfg(Py_3_8)]`, `#[cfg(Py_3_9)]`, `#[cfg(Py_3_10)]`, `#[cfg(Py_3_11)]`, ... | These attributes mark code only for a given Python version and up. For example, `#[cfg(Py_3_8)]` marks code which can run on Python 3.8 **and newer**. There is one attribute for each Python version currently supported by PyForge. |
37/// | `#[cfg(Py_LIMITED_API)]` | This marks code which is run when compiling with PyForge's `abi3` feature enabled. |
38/// | `#[cfg(Py_GIL_DISABLED)]` | This marks code which is run on the free-threaded interpreter. |
39///
40/// For examples of how to use these attributes,
41#[doc = concat!("[see PyForge's guide](https://github.com/abdulwahed-sweden/pyforge/v", env!("CARGO_PKG_VERSION"), "/building-and-distribution/multiple_python_versions.html)")]
42/// .
43#[cfg(feature = "resolve-config")]
44pub fn use_pyo3_cfgs() {
45    print_expected_cfgs();
46    for cargo_command in get().build_script_outputs() {
47        println!("{cargo_command}")
48    }
49}
50
51/// Adds linker arguments suitable for linking an extension module.
52///
53/// This should be called from a build script.
54///
55/// The following link flags are added:
56/// - macOS: `-undefined dynamic_lookup`
57/// - wasm32-unknown-emscripten: for Rust <= 1.95, `-sSIDE_MODULE=2 -sWASM_BIGINT`
58///
59/// All other platforms currently are no-ops, however this may change as necessary
60/// in future.
61pub fn add_extension_module_link_args() {
62    _add_extension_module_link_args(
63        &impl_::target_triple_from_env(),
64        std::io::stdout(),
65        rustc_minor_version(),
66    )
67}
68
69fn _add_extension_module_link_args(
70    triple: &Triple,
71    mut writer: impl std::io::Write,
72    rustc_minor_version: Option<u32>,
73) {
74    if matches!(triple.operating_system, OperatingSystem::Darwin(_)) {
75        writeln!(writer, "cargo:rustc-cdylib-link-arg=-undefined").unwrap();
76        writeln!(writer, "cargo:rustc-cdylib-link-arg=dynamic_lookup").unwrap();
77    } else if triple == &Triple::from_str("wasm32-unknown-emscripten").unwrap()
78        && rustc_minor_version.is_some_and(|version| version < 95)
79    {
80        writeln!(writer, "cargo:rustc-cdylib-link-arg=-sSIDE_MODULE=2").unwrap();
81        writeln!(writer, "cargo:rustc-cdylib-link-arg=-sWASM_BIGINT").unwrap();
82    }
83}
84
85/// Adds linker arguments to set rpath when embedding Python within a Rust binary.
86///
87/// When running tests or binaries built with PyForge, the Python dynamic library needs
88/// to be found at runtime.
89///
90/// This can be done by setting environment variables like `DYLD_LIBRARY_PATH` on macOS,
91/// `LD_LIBRARY_PATH` on Linux, or `PATH` on Windows.
92///
93/// Altrnatively (as per this function) rpath can be set at link time to point to the
94/// directory containing the Python dynamic library. This avoids the need to set environment
95/// variables, so can be convenient, however may not be appropriate for binaries packaged
96/// for distribution.
97///
98#[doc = concat!("[See PyForge's guide](https://github.com/abdulwahed-sweden/pyforge/v", env!("CARGO_PKG_VERSION"), "/building-and-distribution#dynamically-embedding-the-python-interpreter)")]
99/// for more details.
100#[cfg(feature = "resolve-config")]
101pub fn add_libpython_rpath_link_args() {
102    let target = impl_::target_triple_from_env();
103    _add_libpython_rpath_link_args(
104        get(),
105        impl_::is_linking_libpython_for_target(&target),
106        std::io::stdout(),
107    )
108}
109
110#[cfg(feature = "resolve-config")]
111fn _add_libpython_rpath_link_args(
112    interpreter_config: &InterpreterConfig,
113    is_linking_libpython: bool,
114    mut writer: impl std::io::Write,
115) {
116    if is_linking_libpython {
117        if let Some(lib_dir) = interpreter_config.lib_dir.as_ref() {
118            writeln!(writer, "cargo:rustc-link-arg=-Wl,-rpath,{lib_dir}").unwrap();
119        }
120    }
121}
122
123/// Adds linker arguments suitable for linking against the Python framework on macOS.
124///
125/// This should be called from a build script.
126///
127/// The following link flags are added:
128/// - macOS: `-Wl,-rpath,<framework_prefix>`
129///
130/// All other platforms currently are no-ops.
131#[cfg(feature = "resolve-config")]
132pub fn add_python_framework_link_args() {
133    let target = impl_::target_triple_from_env();
134    _add_python_framework_link_args(
135        get(),
136        &target,
137        impl_::is_linking_libpython_for_target(&target),
138        std::io::stdout(),
139    )
140}
141
142#[cfg(feature = "resolve-config")]
143fn _add_python_framework_link_args(
144    interpreter_config: &InterpreterConfig,
145    triple: &Triple,
146    link_libpython: bool,
147    mut writer: impl std::io::Write,
148) {
149    if matches!(triple.operating_system, OperatingSystem::Darwin(_)) && link_libpython {
150        if let Some(framework_prefix) = interpreter_config.python_framework_prefix.as_ref() {
151            writeln!(writer, "cargo:rustc-link-arg=-Wl,-rpath,{framework_prefix}").unwrap();
152        }
153    }
154}
155
156/// Loads the configuration determined from the build environment.
157///
158/// Because this will never change in a given compilation run, this is cached in a `OnceLock`.
159#[cfg(feature = "resolve-config")]
160pub fn get() -> &'static InterpreterConfig {
161    static CONFIG: OnceLock<InterpreterConfig> = OnceLock::new();
162    CONFIG.get_or_init(|| {
163        // Check if we are in a build script and cross compiling to a different target.
164        let cross_compile_config_path = resolve_cross_compile_config_path();
165        let cross_compiling = cross_compile_config_path
166            .as_ref()
167            .map(|path| path.exists())
168            .unwrap_or(false);
169
170        #[allow(
171            clippy::const_is_empty,
172            reason = "CONFIG_FILE is generated in build.rs, content can vary"
173        )]
174        if let Some(interpreter_config) = InterpreterConfig::from_cargo_dep_env() {
175            interpreter_config
176        } else if let Some(interpreter_config) = config_from_pyo3_config_file_env() {
177            Ok(interpreter_config)
178        } else if cross_compiling {
179            InterpreterConfig::from_path(cross_compile_config_path.as_ref().unwrap())
180        } else {
181            InterpreterConfig::from_reader(Cursor::new(HOST_CONFIG))
182        }
183        .expect("failed to parse PyForge config")
184    })
185}
186
187/// Build configuration provided by `PYO3_CONFIG_FILE`, inlined into the `pyo3-build-config` binary.
188#[cfg(feature = "resolve-config")]
189fn config_from_pyo3_config_file_env() -> Option<InterpreterConfig> {
190    #[doc(hidden)]
191    const CONFIG_FILE: &str = include_str!(concat!(env!("OUT_DIR"), "/pyo3-build-config-file.txt"));
192
193    #[allow(
194        clippy::const_is_empty,
195        reason = "CONFIG_FILE is generated in build.rs, content can vary"
196    )]
197    if !CONFIG_FILE.is_empty() {
198        let config = InterpreterConfig::from_reader(Cursor::new(CONFIG_FILE))
199            .expect("contents of CONFIG_FILE should always be valid (generated by pyo3-build-config's build.rs)");
200        Some(config)
201    } else {
202        None
203    }
204}
205
206/// Build configuration discovered by `pyo3-build-config` build script. Not aware of
207/// cross-compilation settings. Not generated if `PYO3_CONFIG_FILE` is set.
208#[doc(hidden)]
209#[cfg(feature = "resolve-config")]
210const HOST_CONFIG: &str = include_str!(concat!(env!("OUT_DIR"), "/pyo3-build-config.txt"));
211
212/// Returns the path where PyForge's build.rs writes its cross compile configuration.
213///
214/// The config file will be named `$OUT_DIR/<triple>/pyo3-build-config.txt`.
215///
216/// Must be called from a build script, returns `None` if not.
217#[doc(hidden)]
218#[cfg(feature = "resolve-config")]
219fn resolve_cross_compile_config_path() -> Option<PathBuf> {
220    env::var_os("TARGET").map(|target| {
221        let mut path = PathBuf::from(env!("OUT_DIR"));
222        path.push(Path::new(&target));
223        path.push("pyo3-build-config.txt");
224        path
225    })
226}
227
228/// Helper to print a feature cfg with a minimum rust version required.
229fn print_feature_cfg(minor_version_required: u32, cfg: &str) {
230    let minor_version = rustc_minor_version().unwrap_or(0);
231
232    if minor_version >= minor_version_required {
233        println!("cargo:rustc-cfg={cfg}");
234    }
235
236    // rustc 1.80.0 stabilized `rustc-check-cfg` feature, don't emit before
237    if minor_version >= 80 {
238        println!("cargo:rustc-check-cfg=cfg({cfg})");
239    }
240}
241
242/// Use certain features if we detect the compiler being used supports them.
243///
244/// Features may be removed or added as MSRV gets bumped or new features become available,
245/// so this function is unstable.
246#[doc(hidden)]
247pub fn print_feature_cfgs() {
248    print_feature_cfg(85, "fn_ptr_eq");
249    print_feature_cfg(86, "from_bytes_with_nul_error");
250}
251
252/// Registers `pyo3`s config names as reachable cfg expressions
253///
254/// - <https://github.com/rust-lang/cargo/pull/13571>
255/// - <https://doc.rust-lang.org/nightly/cargo/reference/build-scripts.html#rustc-check-cfg>
256#[doc(hidden)]
257pub fn print_expected_cfgs() {
258    if rustc_minor_version().is_some_and(|version| version < 80) {
259        // rustc 1.80.0 stabilized `rustc-check-cfg` feature, don't emit before
260        return;
261    }
262
263    println!("cargo:rustc-check-cfg=cfg(Py_LIMITED_API)");
264    println!("cargo:rustc-check-cfg=cfg(Py_GIL_DISABLED)");
265    println!("cargo:rustc-check-cfg=cfg(py_sys_config, values(\"Py_DEBUG\", \"Py_REF_DEBUG\", \"Py_TRACE_REFS\", \"COUNT_ALLOCS\"))");
266    println!("cargo:rustc-check-cfg=cfg(pyo3_disable_reference_pool)");
267    println!("cargo:rustc-check-cfg=cfg(pyo3_leak_on_drop_without_reference_pool)");
268
269    // allow `Py_3_*` cfgs — start from 8 because FFI code uses Py_3_8+ as feature gates
270    for i in 8..=impl_::ABI3_MAX_MINOR + 1 {
271        println!("cargo:rustc-check-cfg=cfg(Py_3_{i})");
272    }
273
274    // pyo3_dll cfg for raw-dylib linking on Windows
275    let mut dll_names = vec!["python3".to_string(), "python3_d".to_string()];
276    for i in 8..=impl_::ABI3_MAX_MINOR + 1 {
277        dll_names.push(format!("python3{i}"));
278        dll_names.push(format!("python3{i}_d"));
279        if i >= 13 {
280            dll_names.push(format!("python3{i}t"));
281            dll_names.push(format!("python3{i}t_d"));
282        }
283    }
284    // PyForge: PyPy DLL names removed — CPython only
285    let values = dll_names
286        .iter()
287        .map(|n| format!("\"{n}\""))
288        .collect::<Vec<_>>()
289        .join(", ");
290    println!("cargo:rustc-check-cfg=cfg(pyo3_dll, values({values}))");
291}
292
293/// Private exports used in PyForge's build.rs
294///
295/// Please don't use these - they could change at any time.
296#[doc(hidden)]
297#[cfg(feature = "resolve-config")]
298pub mod pyo3_build_script_impl {
299    use crate::errors::{Context, Result};
300
301    use super::*;
302
303    pub mod errors {
304        pub use crate::errors::*;
305    }
306    pub use crate::impl_::{
307        cargo_env_var, env_var, is_linking_libpython_for_target, make_cross_compile_config,
308        target_triple_from_env, InterpreterConfig, PythonVersion,
309    };
310    pub enum BuildConfigSource {
311        /// Config was provided by `PYO3_CONFIG_FILE`.
312        ConfigFile,
313        /// Config was found by an interpreter on the host system.
314        Host,
315        /// Config was configured by cross-compilation settings.
316        CrossCompile,
317    }
318
319    pub struct BuildConfig {
320        pub interpreter_config: InterpreterConfig,
321        pub source: BuildConfigSource,
322    }
323
324    /// Gets the configuration for use from `pyo3-ffi`'s build script.
325    ///
326    /// Differs from `.get()` in three ways:
327    /// 1. The cargo_dep_env config is not yet available (exported by `pyo3-ffi`'s build script).
328    /// 1. If `PYO3_CONFIG_FILE` is set, lib name is fixed up and the windows import libs might be generated.
329    /// 2. The cross-compile config file is generated if necessary.
330    ///
331    /// Steps 2 and 3 are necessary because `pyo3-ffi`'s build script is the first code run which knows
332    /// the correct target triple.
333    pub fn resolve_build_config(target: &Triple) -> Result<BuildConfig> {
334        #[allow(
335            clippy::const_is_empty,
336            reason = "CONFIG_FILE is generated in build.rs, content can vary"
337        )]
338        if let Some(mut interpreter_config) = config_from_pyo3_config_file_env() {
339            interpreter_config.apply_default_lib_name_to_config_file(target);
340            Ok(BuildConfig {
341                interpreter_config,
342                source: BuildConfigSource::ConfigFile,
343            })
344        } else if let Some(interpreter_config) = make_cross_compile_config()? {
345            // This is a cross compile and need to write the config file.
346            let path = resolve_cross_compile_config_path()
347                .expect("resolve_build_config() must be called from a build script");
348            let parent_dir = path.parent().ok_or_else(|| {
349                format!(
350                    "failed to resolve parent directory of config file {}",
351                    path.display()
352                )
353            })?;
354            std::fs::create_dir_all(parent_dir).with_context(|| {
355                format!(
356                    "failed to create config file directory {}",
357                    parent_dir.display()
358                )
359            })?;
360            interpreter_config.to_writer(&mut std::fs::File::create(&path).with_context(
361                || format!("failed to create config file at {}", path.display()),
362            )?)?;
363            Ok(BuildConfig {
364                interpreter_config,
365                source: BuildConfigSource::CrossCompile,
366            })
367        } else {
368            let interpreter_config = InterpreterConfig::from_reader(Cursor::new(HOST_CONFIG))?;
369            Ok(BuildConfig {
370                interpreter_config,
371                source: BuildConfigSource::Host,
372            })
373        }
374    }
375
376    /// Helper to generate an error message when the configured Python version is newer
377    /// than PyForge's current supported version.
378    pub struct MaximumVersionExceeded {
379        message: String,
380    }
381
382    impl MaximumVersionExceeded {
383        pub fn new(
384            interpreter_config: &InterpreterConfig,
385            supported_version: PythonVersion,
386        ) -> Self {
387            let implementation = match interpreter_config.implementation {
388                PythonImplementation::CPython => "Python",
389                PythonImplementation::PyPy => "PyPy",
390                PythonImplementation::GraalPy => "GraalPy",
391            };
392            let version = &interpreter_config.version;
393            let message = format!(
394                "the configured {implementation} version ({version}) is newer than PyForge's maximum supported version ({supported_version})\n\
395                = help: this package is being built with PyForge version {current_version}\n\
396                = help: check https://crates.io/crates/pyo3 for the latest PyForge version available\n\
397                = help: updating this package to the latest version of PyForge may provide compatibility with this {implementation} version",
398                current_version = env!("CARGO_PKG_VERSION")
399            );
400            Self { message }
401        }
402
403        pub fn add_help(&mut self, help: &str) {
404            self.message.push_str("\n= help: ");
405            self.message.push_str(help);
406        }
407
408        pub fn finish(self) -> String {
409            self.message
410        }
411    }
412}
413
414fn rustc_minor_version() -> Option<u32> {
415    static RUSTC_MINOR_VERSION: OnceLock<Option<u32>> = OnceLock::new();
416    *RUSTC_MINOR_VERSION.get_or_init(|| {
417        let rustc = env::var_os("RUSTC")?;
418        let output = Command::new(rustc).arg("--version").output().ok()?;
419        let version = core::str::from_utf8(&output.stdout).ok()?;
420        let mut pieces = version.split('.');
421        if pieces.next() != Some("rustc 1") {
422            return None;
423        }
424        pieces.next()?.parse().ok()
425    })
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn extension_module_link_args() {
434        let mut buf = Vec::new();
435
436        // Does nothing on non-mac
437        _add_extension_module_link_args(
438            &Triple::from_str("x86_64-pc-windows-msvc").unwrap(),
439            &mut buf,
440            None,
441        );
442        assert_eq!(buf, Vec::new());
443
444        _add_extension_module_link_args(
445            &Triple::from_str("x86_64-apple-darwin").unwrap(),
446            &mut buf,
447            None,
448        );
449        assert_eq!(
450            std::str::from_utf8(&buf).unwrap(),
451            "cargo:rustc-cdylib-link-arg=-undefined\n\
452             cargo:rustc-cdylib-link-arg=dynamic_lookup\n"
453        );
454
455        buf.clear();
456        _add_extension_module_link_args(
457            &Triple::from_str("wasm32-unknown-emscripten").unwrap(),
458            &mut buf,
459            Some(94),
460        );
461        assert_eq!(
462            std::str::from_utf8(&buf).unwrap(),
463            "cargo:rustc-cdylib-link-arg=-sSIDE_MODULE=2\n\
464             cargo:rustc-cdylib-link-arg=-sWASM_BIGINT\n"
465        );
466        buf.clear();
467        _add_extension_module_link_args(
468            &Triple::from_str("wasm32-unknown-emscripten").unwrap(),
469            &mut buf,
470            Some(95),
471        );
472        assert_eq!(std::str::from_utf8(&buf).unwrap(), "");
473    }
474
475    #[cfg(feature = "resolve-config")]
476    #[test]
477    fn python_framework_link_args() {
478        let mut buf = Vec::new();
479
480        let interpreter_config = InterpreterConfig {
481            implementation: PythonImplementation::CPython,
482            version: PythonVersion {
483                major: 3,
484                minor: 13,
485            },
486            shared: true,
487            abi3: false,
488            lib_name: None,
489            lib_dir: None,
490            executable: None,
491            pointer_width: None,
492            build_flags: BuildFlags::default(),
493            suppress_build_script_link_lines: false,
494            extra_build_script_lines: vec![],
495            python_framework_prefix: Some(
496                "/Applications/Xcode.app/Contents/Developer/Library/Frameworks".to_string(),
497            ),
498        };
499        // Does nothing on non-mac
500        _add_python_framework_link_args(
501            &interpreter_config,
502            &Triple::from_str("x86_64-pc-windows-msvc").unwrap(),
503            true,
504            &mut buf,
505        );
506        assert_eq!(buf, Vec::new());
507
508        _add_python_framework_link_args(
509            &interpreter_config,
510            &Triple::from_str("x86_64-apple-darwin").unwrap(),
511            true,
512            &mut buf,
513        );
514        assert_eq!(
515            std::str::from_utf8(&buf).unwrap(),
516            "cargo:rustc-link-arg=-Wl,-rpath,/Applications/Xcode.app/Contents/Developer/Library/Frameworks\n"
517        );
518    }
519
520    #[test]
521    #[cfg(feature = "resolve-config")]
522    fn test_maximum_version_exceeded_formatting() {
523        let interpreter_config = InterpreterConfig {
524            implementation: PythonImplementation::CPython,
525            version: PythonVersion {
526                major: 3,
527                minor: 13,
528            },
529            shared: true,
530            abi3: false,
531            lib_name: None,
532            lib_dir: None,
533            executable: None,
534            pointer_width: None,
535            build_flags: BuildFlags::default(),
536            suppress_build_script_link_lines: false,
537            extra_build_script_lines: vec![],
538            python_framework_prefix: None,
539        };
540        let mut error = pyo3_build_script_impl::MaximumVersionExceeded::new(
541            &interpreter_config,
542            PythonVersion {
543                major: 3,
544                minor: 12,
545            },
546        );
547        error.add_help("this is a help message");
548        let error = error.finish();
549        let expected = concat!("\
550            the configured Python version (3.13) is newer than PyForge's maximum supported version (3.12)\n\
551            = help: this package is being built with PyForge version ", env!("CARGO_PKG_VERSION"), "\n\
552            = help: check https://crates.io/crates/pyo3 for the latest PyForge version available\n\
553            = help: updating this package to the latest version of PyForge may provide compatibility with this Python version\n\
554            = help: this is a help message"
555        );
556        assert_eq!(error, expected);
557    }
558}