diff --git a/src/builder/python.rs b/src/builder/python.rs index 4bd09ea..dd18a0c 100644 --- a/src/builder/python.rs +++ b/src/builder/python.rs @@ -35,6 +35,7 @@ pub fn build( ) -> Result<()> { let flags = &spec.build.flags; let actual_src = resolve_actual_src(spec, src_dir)?; + let config_settings = normalize_pep517_config_settings(spec)?; fs::create_dir_all(destdir) .with_context(|| format!("Failed to create DESTDIR: {}", destdir.display()))?; @@ -63,9 +64,14 @@ pub fn build( match detect_frontend(&actual_src)? { BuildFrontend::Pep517(cfg) => { - build_wheel_pep517(&actual_src, &dist_dir, &env_vars, &cfg)? + build_wheel_pep517(&actual_src, &dist_dir, &env_vars, &cfg, &config_settings)? } BuildFrontend::LegacySetupPy => { + if !config_settings.is_empty() { + bail!( + "build.flags.config_setting is only supported for PEP 517 builds (pyproject.toml)" + ); + } build_wheel_setup_py(&actual_src, &dist_dir, &env_vars)?; } } @@ -93,6 +99,39 @@ pub fn build( Ok(()) } +fn normalize_pep517_config_settings(spec: &PackageSpec) -> Result> { + let mut out = Vec::new(); + for raw in &spec.build.flags.config_settings { + let expanded = spec.expand_vars(raw); + let trimmed = expanded.trim(); + if trimmed.is_empty() { + continue; + } + if trimmed.contains('\n') || trimmed.contains('\r') { + bail!( + "Invalid build.flags.config_setting entry '{}': newlines are not allowed", + raw + ); + } + + let key = trimmed.split_once('=').map(|(k, _)| k).unwrap_or(trimmed); + let key = key.trim(); + if key.is_empty() || key.chars().any(char::is_whitespace) { + bail!( + "Invalid build.flags.config_setting entry '{}'; expected KEY=VALUE or KEY", + raw + ); + } + + if let Some((_, value)) = trimmed.split_once('=') { + out.push(format!("{key}={value}")); + } else { + out.push(format!("{key}=")); + } + } + Ok(out) +} + fn resolve_actual_src(spec: &PackageSpec, src_dir: &Path) -> Result { let source_subdir = spec.expand_vars(&spec.build.flags.source_subdir); if source_subdir.is_empty() { @@ -264,6 +303,7 @@ fn build_wheel_pep517( dist_dir: &Path, env_vars: &[(String, String)], cfg: &Pep517Config, + config_settings: &[String], ) -> Result<()> { crate::log_info!("Building wheel via PEP 517 backend {}...", cfg.backend); @@ -284,6 +324,11 @@ fn build_wheel_pep517( .collect::>() .join("\n"), ); + crate::builder::set_env_var( + &mut build_env, + "DEPOT_PY_CONFIG_SETTINGS", + config_settings.join("\n"), + ); crate::builder::prepare_command(&mut cmd, &build_env); let output = cmd.output().with_context(|| { @@ -687,6 +732,20 @@ if object_path: backend = getattr(backend, attr) config_settings = {} +for raw in [p for p in os.environ.get("DEPOT_PY_CONFIG_SETTINGS", "").splitlines() if p]: + key, _, value = raw.partition("=") + key = key.strip() + if not key: + raise RuntimeError(f"Invalid DEPOT_PY_CONFIG_SETTINGS entry: {raw!r}") + existing = config_settings.get(key) + if existing is None: + config_settings[key] = value + elif isinstance(existing, list): + existing.append(value) + else: + config_settings[key] = [existing, value] +if not config_settings: + config_settings = None if not hasattr(backend, "build_wheel"): raise RuntimeError(f"Backend {backend_spec!r} does not provide build_wheel") wheel_name = backend.build_wheel(str(dist_dir), config_settings, None) @@ -734,6 +793,39 @@ mod tests { } } + #[test] + fn normalize_pep517_config_settings_accepts_key_value_and_key_only() -> Result<()> { + let mut spec = mk_spec(); + spec.package.version = "1.2.3".into(); + spec.build.flags.config_settings = vec![ + "editable_mode=compat".into(), + "setup-args=--plat-name=x86_64".into(), + "builddir".into(), + "version=$version".into(), + ]; + + let normalized = normalize_pep517_config_settings(&spec)?; + assert_eq!( + normalized, + vec![ + "editable_mode=compat".to_string(), + "setup-args=--plat-name=x86_64".to_string(), + "builddir=".to_string(), + "version=1.2.3".to_string(), + ] + ); + Ok(()) + } + + #[test] + fn normalize_pep517_config_settings_rejects_invalid_key() { + let mut spec = mk_spec(); + spec.build.flags.config_settings = vec!["bad key=value".into()]; + let err = normalize_pep517_config_settings(&spec) + .expect_err("invalid config setting key should fail"); + assert!(err.to_string().contains("expected KEY=VALUE or KEY")); + } + #[test] fn parse_console_scripts_basic() -> Result<()> { let text = r#" diff --git a/src/package/interactive.rs b/src/package/interactive.rs index 15412e4..79da7b4 100644 --- a/src/package/interactive.rs +++ b/src/package/interactive.rs @@ -614,6 +614,13 @@ pub fn create_interactive() -> Result { .prompt()?; } + if matches!(build_type, BuildType::Python) && show_advanced { + flags.config_settings = prompt_repeating_list( + "PEP 517 config-setting", + "Format: KEY=VALUE (repeat keys to pass multiple values)", + )?; + } + if matches!(build_type, BuildType::Bin) { flags.binary_type = prompt_optional_text( "Binary type (optional):", @@ -1222,6 +1229,19 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { ), ); } + if !spec.build.flags.config_settings.is_empty() { + flags_tbl.insert( + "config_setting".into(), + Value::Array( + spec.build + .flags + .config_settings + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } if spec.build.flags.bindir != defaults.bindir { flags_tbl.insert( "bindir".into(), @@ -1445,6 +1465,7 @@ mod tests { flags.makefile_commands = vec!["make".into()]; flags.makefile_install_commands = vec!["make DESTDIR=$DESTDIR install".into()]; flags.cargs = vec!["--locked".into()]; + flags.config_settings = vec!["editable_mode=compat".into()]; flags.rustflags = vec!["-Ctarget-cpu=native".into()]; flags.cxxflags = vec!["-O2".into(), "-fno-rtti".into()]; flags.target = "x86_64-unknown-linux-gnu".into(); @@ -1501,6 +1522,7 @@ mod tests { assert!(toml.contains("makefile_commands = [")); assert!(toml.contains("makefile_install_commands = [")); assert!(toml.contains("cargs = [")); + assert!(toml.contains("config_setting = [")); assert!(toml.contains("rustflags = [")); assert!(toml.contains("cxxflags = [")); assert!(toml.contains("target = \"x86_64-unknown-linux-gnu\"")); diff --git a/src/package/spec.rs b/src/package/spec.rs index 59ae6d7..4958451 100755 --- a/src/package/spec.rs +++ b/src/package/spec.rs @@ -531,6 +531,17 @@ impl PackageSpec { self.build.flags.configure_lib32 = vec![s.to_string()]; } } + "config_setting" | "config_settings" | "config-setting" | "config-settings" => { + if let Some(arr) = v.as_array() { + self.build.flags.config_settings = arr + .iter() + .filter_map(|x| x.as_str()) + .map(String::from) + .collect(); + } else if let Some(s) = v.as_str() { + self.build.flags.config_settings = vec![s.to_string()]; + } + } "configure_file" | "configure-file" => { if let Some(s) = v.as_str() { self.build.flags.configure_file = s.to_string(); @@ -706,6 +717,18 @@ impl PackageSpec { } } } + "config_setting" | "config_settings" | "config-setting" | "config-settings" => { + for v in values { + if let Some(arr) = v.as_array() { + self.build + .flags + .config_settings + .extend(arr.iter().filter_map(|x| x.as_str()).map(String::from)); + } else if let Some(s) = v.as_str() { + self.build.flags.config_settings.push(s.to_string()); + } + } + } "configure_file" | "configure-file" => { if let Some(s) = values.last().and_then(|v| v.as_str()) { self.build.flags.configure_file = s.to_string(); @@ -1358,6 +1381,45 @@ type = "python" assert!(matches!(spec.build.build_type, BuildType::Python)); } + #[test] + fn parse_python_config_settings_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "python" + +[build.flags] +config-setting = ["editable_mode=compat", "setup-args=--plat-name=x86_64"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.build.flags.config_settings, + vec![ + "editable_mode=compat".to_string(), + "setup-args=--plat-name=x86_64".to_string() + ] + ); + } + #[test] fn parse_multiple_licenses() { let tmp = tempfile::tempdir().unwrap(); @@ -1601,6 +1663,7 @@ no_compress_man = true skip_tests = true keep = ["etc/locale.gen"] configure_file = "configure.gnu" +config-setting = ["editable_mode=compat"] post_configure = ["echo configured"] "#, ) @@ -1666,6 +1729,12 @@ post_configure = ["echo configured"] "build.flags.configure_file".to_string(), vec![toml::Value::String("build-aux/configure".to_string())], ); + config.appends.insert( + "build.flags.config-setting".to_string(), + vec![toml::Value::String( + "setup-args=--plat-name=x86_64".to_string(), + )], + ); config.appends.insert( "build.flags.post_configure".to_string(), vec![toml::Value::String("touch configured.stamp".to_string())], @@ -1754,6 +1823,18 @@ post_configure = ["echo configured"] .contains(&"tools".to_string()) ); assert_eq!(spec.build.flags.configure_file, "build-aux/configure"); + assert!( + spec.build + .flags + .config_settings + .contains(&"editable_mode=compat".to_string()) + ); + assert!( + spec.build + .flags + .config_settings + .contains(&"setup-args=--plat-name=x86_64".to_string()) + ); assert!( spec.build .flags @@ -2689,6 +2770,16 @@ pub struct BuildFlags { pub build_32: bool, #[serde(default)] pub configure: Vec, + /// PEP 517 config settings for Python builds (each entry is `KEY=VALUE` or `KEY`). + #[serde( + default, + alias = "config-setting", + alias = "config-settings", + alias = "config_setting", + alias = "config_settings", + deserialize_with = "deserialize_string_or_array_no_split" + )] + pub config_settings: Vec, /// Configure arguments used only for the lib32 build variant (replaces `configure` when set). #[serde(default, alias = "configure-lib32", alias = "configure_lib32")] pub configure_lib32: Vec, @@ -2911,6 +3002,7 @@ impl Default for BuildFlags { skip_tests: false, build_32: false, configure: Vec::new(), + config_settings: Vec::new(), configure_lib32: Vec::new(), configure_file: String::new(), cc: default_cc(), @@ -2977,6 +3069,26 @@ where } } +fn deserialize_string_or_array_no_split<'de, D>( + deserializer: D, +) -> std::result::Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrArray { + String(String), + Array(Vec), + } + + match Option::::deserialize(deserializer)? { + Some(StringOrArray::String(s)) => Ok(vec![s]), + Some(StringOrArray::Array(a)) => Ok(a), + None => Ok(Vec::new()), + } +} + fn deserialize_boolish<'de, D>(deserializer: D) -> std::result::Result where D: Deserializer<'de>,