From ad80ffbd036a63436f5ece4e71253039f9323379 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Thu, 15 Jan 2026 15:50:25 +0100 Subject: [PATCH 1/3] test: lock-file remains unsatisfiable with run-exports --- .../tests/integration_rust/build_tests.rs | 144 ++++++++++++++++++ .../pixi_build_backend_passthrough/src/lib.rs | 132 +++++++++++++--- crates/pixi_test_utils/src/lib.rs | 4 +- 3 files changed, 260 insertions(+), 20 deletions(-) diff --git a/crates/pixi/tests/integration_rust/build_tests.rs b/crates/pixi/tests/integration_rust/build_tests.rs index 1f49d9131c..fd31b5f09c 100644 --- a/crates/pixi/tests/integration_rust/build_tests.rs +++ b/crates/pixi/tests/integration_rust/build_tests.rs @@ -668,3 +668,147 @@ my-package = {{ path = "./my-package" }} assert_eq!(events.len(), 1, "Expected another build for sdl2-32"); } + +/// Test that verifies when we generate a lock-file with a source package, +/// a second invocation of generating the lock-file should report it's already up to date. +/// +/// This test creates a noarch: generic package with all fields that are compared +/// in `package_records_are_equal`: +/// - name, version, build, build_number +/// - depends, constrains +/// - license, license_family +/// - noarch, subdir +/// - features, track_features +/// - purls, python_site_packages_path +/// - run_exports +#[tokio::test] +async fn test_source_package_lock_file_up_to_date() { + use pixi_test_utils::create_conda_package; + use rattler_conda_types::{NoArchType, package::RunExportsJson}; + + setup_tracing(); + + // Create a PixiControl instance with PassthroughBackend + let backend_override = BackendOverride::from_memory(PassthroughBackend::instantiator()); + let pixi = PixiControl::new() + .unwrap() + .with_backend_override(backend_override); + + // Create a source package directory + let source_dir = pixi.workspace_path().join("source-package"); + fs::create_dir_all(&source_dir).unwrap(); + + // Create run_exports for the package + let run_exports = RunExportsJson { + weak: vec!["weak-dep >=1.0".to_string()], + strong: vec!["strong-dep >=2.0".to_string()], + ..Default::default() + }; + + // Create a Package with all fields from package_records_are_equal + let mut package = pixi_test_utils::Package::build("test-source-pkg", "1.2.3") + .with_build("test_build_0") + .with_build_number(0) + .with_subdir(Platform::NoArch) + .with_dependency("some-dependency >=1.0") + .with_run_exports(run_exports) + .finish(); + + // Modify the package_record to include all fields compared in package_records_are_equal + package.package_record.license = Some("MIT".to_string()); + package.package_record.license_family = Some("MIT".to_string()); + package.package_record.noarch = NoArchType::generic(); + package.package_record.constrains = vec!["constrained-pkg <2.0".to_string()]; + package.package_record.track_features = vec!["test_feature".to_string()]; + package.package_record.features = Some("test_features".to_string()); + // Note: purls, python_site_packages_path, and experimental_extra_depends + // are left as defaults since they're optional and the equality check handles None values + + // Create the .conda package file in the source directory + let package_filename = format!( + "{}-{}-{}.conda", + package.package_record.name.as_normalized(), + package.package_record.version, + package.package_record.build + ); + let package_path = source_dir.join(&package_filename); + create_conda_package(&package, &package_path).expect("Failed to create conda package"); + + // Create the pixi.toml for the source package that configures + // PassthroughBackend to use the pre-built package + let source_pixi_toml = format!( + r#" +[package] +name = "test-source-pkg" +version = "1.2.3" + +[package.build] +backend = {{ name = "passthrough", version = "0.1.0" }} + +[package.build.config] +package = "{}" +"#, + package_filename + ); + fs::write(source_dir.join("pixi.toml"), source_pixi_toml).unwrap(); + + // Create the workspace manifest that depends on the source package + let manifest_content = format!( + r#" +[workspace] +channels = [] +platforms = ["{}"] +preview = ["pixi-build"] + +[dependencies] +test-source-pkg = {{ path = "./source-package" }} +"#, + Platform::current() + ); + + fs::write(pixi.manifest_path(), manifest_content).unwrap(); + + // First invocation: Generate the lock-file + let workspace = pixi.workspace().unwrap(); + let (lock_file_data, was_updated) = workspace + .update_lock_file(pixi_core::UpdateLockFileOptions::default()) + .await + .expect("First lock file generation should succeed"); + + // Verify the lock-file was actually created/updated + assert!(was_updated, "First invocation should update the lock-file"); + + // Verify the package is in the lock-file + let lock_file = lock_file_data.into_lock_file(); + assert!( + lock_file.contains_conda_package( + consts::DEFAULT_ENVIRONMENT_NAME, + Platform::current(), + "test-source-pkg", + ), + "Lock file should contain the source package" + ); + + // Verify we can find the package with the expected version + assert!( + lock_file.contains_match_spec( + consts::DEFAULT_ENVIRONMENT_NAME, + Platform::current(), + "test-source-pkg ==1.2.3" + ), + "Lock file should contain test-source-pkg with version 1.2.3" + ); + + // Second invocation: Load the workspace again and check if lock-file is up to date + let workspace = pixi.workspace().unwrap(); + let (_, was_updated_second) = workspace + .update_lock_file(pixi_core::UpdateLockFileOptions::default()) + .await + .expect("Second lock file check should succeed"); + + // The second invocation should NOT update the lock-file since it's already up to date + assert!( + !was_updated_second, + "Second invocation should report lock-file is already up to date" + ); +} diff --git a/crates/pixi_build_backend_passthrough/src/lib.rs b/crates/pixi_build_backend_passthrough/src/lib.rs index c02d7294ae..1afbf20c82 100644 --- a/crates/pixi_build_backend_passthrough/src/lib.rs +++ b/crates/pixi_build_backend_passthrough/src/lib.rs @@ -19,7 +19,7 @@ use pixi_build_frontend::{ json_rpc::CommunicationError, }; use pixi_build_types::{ - BackendCapabilities, BinaryPackageSpec, NamedSpec, PackageSpec, ProjectModel, + BackendCapabilities, BinaryPackageSpec, ConstraintSpec, NamedSpec, PackageSpec, ProjectModel, SourcePackageName, Target, TargetSelector, Targets, VariantValue, procedures::{ conda_build_v1::{CondaBuildV1Params, CondaBuildV1Result}, @@ -59,6 +59,8 @@ pub struct PassthroughBackend { /// Run exports configuration for simulating package run_exports. /// Maps package names to their run_exports definitions. run_exports: BTreeMap, + /// Run exports read from the package file (when config.package is specified). + package_run_exports: Option, } impl PassthroughBackend { @@ -92,6 +94,7 @@ impl InMemoryBackend for PassthroughBackend { &self.index_json, ¶ms, &self.run_exports, + self.package_run_exports.as_ref(), ); Ok(CondaOutputsResult { @@ -260,6 +263,7 @@ fn generate_variant_outputs( index_json: &IndexJson, params: &CondaOutputsParams, run_exports: &BTreeMap, + package_run_exports: Option<&RunExportsJson>, ) -> Vec { // Check if we have variant configurations and dependencies with "*" let variant_keys = find_variant_keys(project_model, params); @@ -272,6 +276,7 @@ fn generate_variant_outputs( params, BTreeMap::new(), run_exports, + package_run_exports, )]; } @@ -295,6 +300,7 @@ fn generate_variant_outputs( params, BTreeMap::new(), run_exports, + package_run_exports, )]; } @@ -304,7 +310,16 @@ fn generate_variant_outputs( // Create an output for each variant combination combinations .into_iter() - .map(|variant| create_output(project_model, index_json, params, variant, run_exports)) + .map(|variant| { + create_output( + project_model, + index_json, + params, + variant, + run_exports, + package_run_exports, + ) + }) .collect() } @@ -469,6 +484,7 @@ fn create_output( params: &CondaOutputsParams, mut variant: BTreeMap, run_exports_config: &BTreeMap, + package_run_exports: Option<&RunExportsJson>, ) -> CondaOutput { let subdir = index_json .subdir @@ -552,7 +568,9 @@ fn create_output( variant, }, ignore_run_exports: Default::default(), - run_exports: Default::default(), + run_exports: package_run_exports + .map(convert_run_exports_json) + .unwrap_or_default(), input_globs: None, } } @@ -668,6 +686,75 @@ fn resolve_run_export_spec( }) } +/// Converts a `RunExportsJson` (from a conda package) to `CondaOutputRunExports`. +fn convert_run_exports_json( + run_exports: &RunExportsJson, +) -> pixi_build_types::procedures::conda_outputs::CondaOutputRunExports { + fn convert_specs(specs: &[String]) -> Vec> { + specs + .iter() + .filter_map(|spec_str| { + let match_spec = rattler_conda_types::MatchSpec::from_str( + spec_str, + rattler_conda_types::ParseStrictness::Lenient, + ) + .ok()?; + + let name = match_spec + .name + .as_ref()? + .as_exact()? + .as_source() + .to_string(); + + Some(NamedSpec { + name: SourcePackageName::from(name), + spec: PackageSpec::Binary(BinaryPackageSpec { + version: match_spec.version.clone(), + ..Default::default() + }), + }) + }) + .collect() + } + + fn convert_constraint_specs(specs: &[String]) -> Vec> { + specs + .iter() + .filter_map(|spec_str| { + let match_spec = rattler_conda_types::MatchSpec::from_str( + spec_str, + rattler_conda_types::ParseStrictness::Lenient, + ) + .ok()?; + + let name = match_spec + .name + .as_ref()? + .as_exact()? + .as_source() + .to_string(); + + Some(NamedSpec { + name: SourcePackageName::from(name), + spec: ConstraintSpec::Binary(BinaryPackageSpec { + version: match_spec.version.clone(), + ..Default::default() + }), + }) + }) + .collect() + } + + pixi_build_types::procedures::conda_outputs::CondaOutputRunExports { + weak: convert_specs(&run_exports.weak), + strong: convert_specs(&run_exports.strong), + noarch: convert_specs(&run_exports.noarch), + weak_constrains: convert_constraint_specs(&run_exports.weak_constrains), + strong_constrains: convert_constraint_specs(&run_exports.strong_constrains), + } +} + /// Returns true if the given [`TargetSelector`] matches the specified /// `platform`. fn matches_target_selector(selector: &TargetSelector, platform: Platform) -> bool { @@ -724,26 +811,31 @@ impl InMemoryBackendInstantiator for PassthroughBackendInstantiator { // Read the package file if it is specified, or create IndexJson for on_the_fly mode let source_dir = params.source_directory.expect("Missing source directory"); - let index_json = match &config.package { + let (index_json, package_run_exports) = match &config.package { Some(path) => { let path = source_dir.join(path); - match rattler_package_streaming::seek::read_package_file(&path) { - Err(err) => { - return Err(Box::new( - BackendError::new(format!( - "failed to read '{}' file: {}", - path.display(), - err - )) - .into(), - )); - } - Ok(index_json) => index_json, - } + let index_json: IndexJson = + match rattler_package_streaming::seek::read_package_file(&path) { + Err(err) => { + return Err(Box::new( + BackendError::new(format!( + "failed to read index.json from '{}': {}", + path.display(), + err + )) + .into(), + )); + } + Ok(index_json) => index_json, + }; + // Also read run_exports.json from the package (optional, may not exist) + let run_exports: Option = + rattler_package_streaming::seek::read_package_file(&path).ok(); + (index_json, run_exports) } None => { // Create IndexJson from project model for on-the-fly package generation - IndexJson { + let index_json = IndexJson { arch: None, build: String::new(), build_number: 0, @@ -770,7 +862,8 @@ impl InMemoryBackendInstantiator for PassthroughBackendInstantiator { .clone() .unwrap_or_else(|| Version::major(0)) .into(), - } + }; + (index_json, None) } }; @@ -780,6 +873,7 @@ impl InMemoryBackendInstantiator for PassthroughBackendInstantiator { source_dir, index_json, run_exports: self.run_exports.clone(), + package_run_exports, }) } diff --git a/crates/pixi_test_utils/src/lib.rs b/crates/pixi_test_utils/src/lib.rs index 8f0ec9efe5..fb9e7f26f2 100644 --- a/crates/pixi_test_utils/src/lib.rs +++ b/crates/pixi_test_utils/src/lib.rs @@ -6,7 +6,9 @@ pub mod git_fixture; pub mod mock_repo_data; pub use git_fixture::GitRepoFixture; -pub use mock_repo_data::{LocalChannel, MockRepoData, Package, PackageBuilder}; +pub use mock_repo_data::{ + LocalChannel, MockRepoData, Package, PackageBuilder, create_conda_package, +}; /// Format a TOML parse error into a string that can be used to generate /// snapshots. From e955462ea76aea830d559acf758ee556642df65a Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Thu, 15 Jan 2026 15:58:06 +0100 Subject: [PATCH 2/3] fix: dont take into account run-exports, they are not stored in the lock-file --- .../pixi_core/src/lock_file/satisfiability/mod.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/crates/pixi_core/src/lock_file/satisfiability/mod.rs b/crates/pixi_core/src/lock_file/satisfiability/mod.rs index e79cd74c81..2c7f3cf9e1 100644 --- a/crates/pixi_core/src/lock_file/satisfiability/mod.rs +++ b/crates/pixi_core/src/lock_file/satisfiability/mod.rs @@ -1371,7 +1371,9 @@ fn package_records_are_equal(a: &PackageRecord, b: &PackageRecord) -> bool { platform: _, purls: a_purls, python_site_packages_path: a_python_site_packages_path, - run_exports: a_run_exports, + // run_exports are not compared because they are used during the solve + // but not stored in the lock-file for source packages. + run_exports: _, sha256: _, size: _, subdir: a_subdir, @@ -1397,7 +1399,9 @@ fn package_records_are_equal(a: &PackageRecord, b: &PackageRecord) -> bool { platform: _, purls: b_purls, python_site_packages_path: b_python_site_packages_path, - run_exports: b_run_exports, + // run_exports are not compared because they are used during the solve + // but not stored in the lock-file for source packages. + run_exports: _, sha256: _, size: _, subdir: b_subdir, @@ -1418,12 +1422,6 @@ fn package_records_are_equal(a: &PackageRecord, b: &PackageRecord) -> bool { && a_noarch == b_noarch && a_purls == b_purls && a_python_site_packages_path == b_python_site_packages_path - && match (a_run_exports, b_run_exports) { - (Some(a_run_exports), Some(b_run_exports)) => a_run_exports == b_run_exports, - (Some(a_run_exports), None) => a_run_exports.is_empty(), - (None, Some(b_run_exports)) => b_run_exports.is_empty(), - (None, None) => true, - } && a_subdir == b_subdir && a_track_features == b_track_features && a_version == b_version From 052f058d7adc6a133885a2aa3f6ecf5846875373 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:07:20 +0100 Subject: [PATCH 3/3] feat: improve unsat error --- .../src/lock_file/satisfiability/mod.rs | 104 ++++++++++++++---- 1 file changed, 84 insertions(+), 20 deletions(-) diff --git a/crates/pixi_core/src/lock_file/satisfiability/mod.rs b/crates/pixi_core/src/lock_file/satisfiability/mod.rs index 2c7f3cf9e1..79de430b98 100644 --- a/crates/pixi_core/src/lock_file/satisfiability/mod.rs +++ b/crates/pixi_core/src/lock_file/satisfiability/mod.rs @@ -430,8 +430,8 @@ pub enum PlatformUnsat { )] PackageBuildSourceMismatch(String, SourceMismatchError), - #[error("the locked metadata of '{0}' package changed (see trace logs for details)")] - SourcePackageMetadataChanged(String), + #[error("the metadata of source package '{0}' changed: {1}")] + SourcePackageMetadataChanged(String, String), #[error("the source location '{0}' changed from '{1}' to '{2}'")] SourceBuildLocationChanged(String, String, String), @@ -1327,12 +1327,13 @@ async fn verify_source_metadata( package_name ); - if !package_records_are_equal( + if let Err(reason) = package_records_are_equal( ¤t_record.package_record, &source_record.package_record, ) { return Err(Box::new(PlatformUnsat::SourcePackageMetadataChanged( package_name.to_string(), + reason, ))); } @@ -1349,8 +1350,9 @@ async fn verify_source_metadata( Ok(()) } -/// Returns true if the package records are considered equal. -fn package_records_are_equal(a: &PackageRecord, b: &PackageRecord) -> bool { +/// Returns `Ok(())` if the package records are considered equal, or an error +/// message describing which field differs. +fn package_records_are_equal(a: &PackageRecord, b: &PackageRecord) -> Result<(), String> { // Use destructuring to ensure we get compiler errors if these types change // significantly. let PackageRecord { @@ -1410,21 +1412,83 @@ fn package_records_are_equal(a: &PackageRecord, b: &PackageRecord) -> bool { version: b_version, } = &b; - a_build == b_build - && a_build_number == b_build_number - && a_constrains == b_constrains - && a_depends == b_depends - && a_extra_depends == b_extra_depends - && a_features == b_features - && a_license == b_license - && a_license_family == b_license_family - && a_name == b_name - && a_noarch == b_noarch - && a_purls == b_purls - && a_python_site_packages_path == b_python_site_packages_path - && a_subdir == b_subdir - && a_track_features == b_track_features - && a_version == b_version + if a_name != b_name { + return Err(format!( + "the package name changed (\"{}\" != \"{}\")", + a_name.as_source(), + b_name.as_source() + )); + } + if a_version != b_version { + return Err(format!( + "the version changed (\"{a_version}\" != \"{b_version}\")" + )); + } + if a_build != b_build { + return Err(format!( + "the build string changed (\"{a_build}\" != \"{b_build}\")" + )); + } + if a_build_number != b_build_number { + return Err(format!( + "the build number changed ({a_build_number} != {b_build_number})" + )); + } + if a_subdir != b_subdir { + return Err(format!( + "the subdir changed (\"{a_subdir}\" != \"{b_subdir}\")" + )); + } + if a_noarch != b_noarch { + return Err(format!( + "the noarch type changed ({a_noarch:?} != {b_noarch:?})" + )); + } + if a_depends != b_depends { + return Err(format!( + "the dependencies changed ({a_depends:?} != {b_depends:?})" + )); + } + if a_constrains != b_constrains { + return Err(format!( + "the constraints changed ({a_constrains:?} != {b_constrains:?})" + )); + } + if a_extra_depends != b_extra_depends { + return Err(format!( + "the extra dependencies changed ({a_extra_depends:?} != {b_extra_depends:?})" + )); + } + if a_features != b_features { + return Err(format!( + "the features changed ({a_features:?} != {b_features:?})" + )); + } + if a_track_features != b_track_features { + return Err(format!( + "the track_features changed ({a_track_features:?} != {b_track_features:?})" + )); + } + if a_license != b_license { + return Err(format!( + "the license changed ({a_license:?} != {b_license:?})" + )); + } + if a_license_family != b_license_family { + return Err(format!( + "the license_family changed ({a_license_family:?} != {b_license_family:?})" + )); + } + if a_purls != b_purls { + return Err(format!("the purls changed ({a_purls:?} != {b_purls:?})")); + } + if a_python_site_packages_path != b_python_site_packages_path { + return Err(format!( + "the python_site_packages_path changed ({a_python_site_packages_path:?} != {b_python_site_packages_path:?})" + )); + } + + Ok(()) } fn format_source_record(r: &SourceRecord) -> String {