Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions crates/pixi/tests/integration_rust/build_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
132 changes: 113 additions & 19 deletions crates/pixi_build_backend_passthrough/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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<String, RunExportsJson>,
/// Run exports read from the package file (when config.package is specified).
package_run_exports: Option<RunExportsJson>,
}

impl PassthroughBackend {
Expand Down Expand Up @@ -92,6 +94,7 @@ impl InMemoryBackend for PassthroughBackend {
&self.index_json,
&params,
&self.run_exports,
self.package_run_exports.as_ref(),
);

Ok(CondaOutputsResult {
Expand Down Expand Up @@ -260,6 +263,7 @@ fn generate_variant_outputs(
index_json: &IndexJson,
params: &CondaOutputsParams,
run_exports: &BTreeMap<String, RunExportsJson>,
package_run_exports: Option<&RunExportsJson>,
) -> Vec<CondaOutput> {
// Check if we have variant configurations and dependencies with "*"
let variant_keys = find_variant_keys(project_model, params);
Expand All @@ -272,6 +276,7 @@ fn generate_variant_outputs(
params,
BTreeMap::new(),
run_exports,
package_run_exports,
)];
}

Expand All @@ -295,6 +300,7 @@ fn generate_variant_outputs(
params,
BTreeMap::new(),
run_exports,
package_run_exports,
)];
}

Expand All @@ -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()
}

Expand Down Expand Up @@ -469,6 +484,7 @@ fn create_output(
params: &CondaOutputsParams,
mut variant: BTreeMap<String, VariantValue>,
run_exports_config: &BTreeMap<String, RunExportsJson>,
package_run_exports: Option<&RunExportsJson>,
) -> CondaOutput {
let subdir = index_json
.subdir
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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<NamedSpec<PackageSpec>> {
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<NamedSpec<ConstraintSpec>> {
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 {
Expand Down Expand Up @@ -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<RunExportsJson> =
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,
Expand All @@ -770,7 +862,8 @@ impl InMemoryBackendInstantiator for PassthroughBackendInstantiator {
.clone()
.unwrap_or_else(|| Version::major(0))
.into(),
}
};
(index_json, None)
}
};

Expand All @@ -780,6 +873,7 @@ impl InMemoryBackendInstantiator for PassthroughBackendInstantiator {
source_dir,
index_json,
run_exports: self.run_exports.clone(),
package_run_exports,
})
}

Expand Down
Loading
Loading