diff --git a/Cargo.lock b/Cargo.lock index 74f1ad7840..33d42efd10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4717,6 +4717,7 @@ dependencies = [ "uv-installer", "uv-pep508", "uv-python", + "zip 2.4.2", ] [[package]] diff --git a/crates/pixi/Cargo.toml b/crates/pixi/Cargo.toml index 583697e362..f6be8ac598 100644 --- a/crates/pixi/Cargo.toml +++ b/crates/pixi/Cargo.toml @@ -78,6 +78,7 @@ uv-configuration = { workspace = true } uv-installer = { workspace = true } uv-pep508 = { workspace = true } uv-python = { workspace = true } +zip = { workspace = true } [[test]] name = "integration_rust" diff --git a/tests/integration_rust/common/mod.rs b/tests/integration_rust/common/mod.rs index d95a4265c4..831c9373a1 100644 --- a/tests/integration_rust/common/mod.rs +++ b/tests/integration_rust/common/mod.rs @@ -4,6 +4,7 @@ pub mod builders; pub mod client; pub mod logging; pub mod package_database; +pub mod pypi_index; use std::{ ffi::OsString, diff --git a/tests/integration_rust/common/package_database.rs b/tests/integration_rust/common/package_database.rs index 82d0937ead..eb3b9be3ea 100644 --- a/tests/integration_rust/common/package_database.rs +++ b/tests/integration_rust/common/package_database.rs @@ -8,7 +8,7 @@ use chrono::{DateTime, Utc}; use itertools::Itertools; use miette::IntoDiagnostic; use rattler_conda_types::{ - ChannelInfo, PackageName, PackageRecord, Platform, RepoData, VersionWithSource, + ChannelInfo, PackageName, PackageRecord, PackageUrl, Platform, RepoData, VersionWithSource, package::ArchiveType, }; use std::{collections::HashSet, path::Path}; @@ -146,6 +146,7 @@ pub struct PackageBuilder { timestamp: Option>, md5: Option, sha256: Option, + purls: Option>, } impl Package { @@ -162,6 +163,7 @@ impl Package { timestamp: None, sha256: None, md5: None, + purls: None, } } @@ -208,6 +210,25 @@ impl PackageBuilder { self } + /// Attach a PyPI purl for this conda package so Pixi treats it as a Python package. + /// The version used will be the conda record version (fallback if purl has none). + pub fn with_pypi_purl(mut self, pypi_name: impl AsRef) -> Self { + let purl = PackageUrl::builder(String::from("pypi"), pypi_name.as_ref().to_string()) + .build() + .expect("valid pypi package url"); + match &mut self.purls { + Some(v) => { + v.insert(purl); + } + None => { + let mut s = std::collections::BTreeSet::new(); + s.insert(purl); + self.purls = Some(s); + } + } + self + } + /// Sets the timestamp of the package. pub fn with_timestamp(mut self, timestamp: DateTime) -> Self { self.timestamp = Some(timestamp); @@ -272,7 +293,7 @@ impl PackageBuilder { timestamp: self.timestamp, track_features: vec![], version: self.version, - purls: None, + purls: self.purls, run_exports: None, python_site_packages_path: None, experimental_extra_depends: Default::default(), diff --git a/tests/integration_rust/common/pypi_index.rs b/tests/integration_rust/common/pypi_index.rs new file mode 100644 index 0000000000..a8eec2ec28 --- /dev/null +++ b/tests/integration_rust/common/pypi_index.rs @@ -0,0 +1,355 @@ +//! Utilities to generate local PyPI indexes for tests. +//! - Flat (find-links) directory of wheels +//! - Simple (PEP 503) index with index.html pages + +#![allow(dead_code)] + +use std::borrow::Cow; +use std::fmt::Write as _; +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use fs_err as fs; +use miette::IntoDiagnostic; +use tempfile::TempDir; +use url::Url; +use zip::ZipWriter; +use zip::write::SimpleFileOptions; + +/// A wheel tag triple: (python tag, abi tag, platform tag). +/// Defaults to `py3-none-any`. +#[derive(Clone, Debug)] +pub struct WheelTag { + pub py: String, + pub abi: String, + pub plat: String, +} + +impl Default for WheelTag { + fn default() -> Self { + Self { + py: "py3".to_string(), + abi: "none".to_string(), + plat: "any".to_string(), + } + } +} + +/// Description of a fake PyPI package to be emitted as a wheel. +#[derive(Clone, Debug)] +pub struct PyPIPackage { + pub name: String, + pub version: String, + pub tag: WheelTag, + pub requires_dist: Vec, + pub requires_python: Option, + pub summary: Option, + pub timestamp: Option>, // Not embedded, but kept for parity/extension +} + +impl PyPIPackage { + /// Start building a package (defaults to `py3-none-any`). + pub fn new(name: impl Into, version: impl Into) -> Self { + Self { + name: name.into(), + version: version.into(), + tag: WheelTag::default(), + requires_dist: vec![], + requires_python: None, + summary: None, + timestamp: None, + } + } + + pub fn with_tag( + mut self, + py: impl Into, + abi: impl Into, + plat: impl Into, + ) -> Self { + self.tag = WheelTag { + py: py.into(), + abi: abi.into(), + plat: plat.into(), + }; + self + } + + pub fn with_requires_dist(mut self, reqs: impl IntoIterator>) -> Self { + self.requires_dist = reqs.into_iter().map(|s| s.into()).collect(); + self + } + + pub fn with_requires_python(mut self, spec: impl Into) -> Self { + self.requires_python = Some(spec.into()); + self + } + + pub fn with_summary(mut self, summary: impl Into) -> Self { + self.summary = Some(summary.into()); + self + } +} + +/// A collection of packages that can be materialized as either flat or simple indexes. +#[derive(Default)] +pub struct Database { + packages: Vec, +} + +impl Database { + pub fn new() -> Self { + Self { packages: vec![] } + } + + pub fn add(&mut self, pkg: PyPIPackage) { + self.packages.push(pkg); + } + + pub fn with(mut self, pkg: PyPIPackage) -> Self { + self.add(pkg); + self + } + + /// Writes all packages as wheels to a temporary directory and returns the flat index handle. + pub fn into_flat_index(self) -> miette::Result { + let dir = TempDir::new().into_diagnostic()?; + for pkg in &self.packages { + write_wheel(dir.path(), pkg)?; + } + Ok(FlatIndex { dir, _db: self }) + } + + /// Writes packages into a simple (PEP 503) index layout under a temp dir. + pub fn into_simple_index(self) -> miette::Result { + let dir = TempDir::new().into_diagnostic()?; + let index_root = dir.path().join("index"); + fs::create_dir_all(&index_root).into_diagnostic()?; + + // Group wheels by normalized project name + use std::collections::BTreeMap; + let mut projects: BTreeMap> = BTreeMap::new(); + + for pkg in &self.packages { + let project = normalize_simple_name(&pkg.name); + let project_dir = index_root.join(&project); + fs::create_dir_all(&project_dir).into_diagnostic()?; + // write wheel inside project dir + let wheel_path = write_wheel(&project_dir, pkg)?; + projects.entry(project).or_default().push( + wheel_path + .file_name() + .unwrap() + .to_string_lossy() + .to_string(), + ); + } + + // Write per-project index.html files + const INDEX_TMPL: &str = + "\n\n\n%LINKS%\n\n"; + for (project, files) in &projects { + let mut links = String::new(); + for fname in files { + let _ = writeln!(links, "{}", fname, fname); + } + let html = INDEX_TMPL.replace("%LINKS%", &links); + fs::write(index_root.join(project).join("index.html"), html).into_diagnostic()?; + } + + // Write root index.html linking to projects + let mut proj_links = String::new(); + for project in projects.keys() { + let _ = writeln!(proj_links, "{p}", p = project); + } + let root_html = INDEX_TMPL.replace("%LINKS%", &proj_links); + fs::write(index_root.join("index.html"), root_html).into_diagnostic()?; + + Ok(SimpleIndex { + dir, + index_root, + _db: self, + }) + } +} + +/// A local flat index (find-links) represented by a directory of wheel files. +pub struct FlatIndex { + dir: TempDir, + _db: Database, +} + +impl FlatIndex { + /// Path to the directory containing wheels. + pub fn path(&self) -> &Path { + self.dir.path() + } + + /// A `file://` URL pointing to the directory. + pub fn url(&self) -> Url { + Url::from_directory_path(self.dir.path()).expect("absolute path") + } +} + +/// Normalize a project name following PEP 503 (simple repository API). +/// Lowercase and replace runs of `[-_.]` with `-`. +fn normalize_simple_name(name: &str) -> String { + let lower = name.to_ascii_lowercase(); + let mut out = String::with_capacity(lower.len()); + let mut last_dash = false; + for ch in lower.chars() { + let is_sep = ch == '-' || ch == '_' || ch == '.'; + if is_sep { + if !last_dash { + out.push('-'); + last_dash = true; + } + } else { + out.push(ch); + last_dash = false; + } + } + out +} + +/// A local simple (PEP 503) index. +pub struct SimpleIndex { + dir: TempDir, + index_root: PathBuf, + _db: Database, +} + +impl SimpleIndex { + /// Path to the `index` root directory. + pub fn index_path(&self) -> &Path { + &self.index_root + } + + /// file:// URL pointing to the `index` root directory. + pub fn index_url(&self) -> Url { + Url::from_directory_path(&self.index_root).expect("absolute path") + } +} + +/// Create a normalized distribution name for filenames (PEP 427): replace '-' with '_'. +fn normalize_dist_name(name: &str) -> Cow<'_, str> { + if name.contains('-') { + Cow::Owned(name.replace('-', "_")) + } else { + Cow::Borrowed(name) + } +} + +/// Construct a wheel filename: `{name}-{version}-{py}-{abi}-{plat}.whl`. +fn wheel_filename(pkg: &PyPIPackage) -> String { + format!( + "{}-{}-{}-{}-{}.whl", + normalize_dist_name(&pkg.name), + &pkg.version, + pkg.tag.py, + pkg.tag.abi, + pkg.tag.plat + ) +} + +/// dist-info directory name: `{name}-{version}.dist-info` (normalized name). +fn dist_info_dir(pkg: &PyPIPackage) -> String { + format!( + "{}-{}.dist-info", + normalize_dist_name(&pkg.name), + &pkg.version + ) +} + +/// Build METADATA content. +fn build_metadata(pkg: &PyPIPackage) -> String { + let mut s = String::new(); + s.push_str("Metadata-Version: 2.1\n"); + s.push_str(&format!("Name: {}\n", pkg.name)); + s.push_str(&format!("Version: {}\n", pkg.version)); + if let Some(summary) = &pkg.summary { + s.push_str(&format!("Summary: {}\n", summary)); + } + if let Some(rp) = &pkg.requires_python { + s.push_str(&format!("Requires-Python: {}\n", rp)); + } + for req in &pkg.requires_dist { + s.push_str(&format!("Requires-Dist: {}\n", req)); + } + s +} + +/// Build WHEEL content. +fn build_wheel_file(pkg: &PyPIPackage) -> String { + let mut s = String::new(); + s.push_str("Wheel-Version: 1.0\n"); + s.push_str("Generator: pixi-tests\n"); + s.push_str("Root-Is-Purelib: true\n"); + s.push_str(&format!( + "Tag: {}-{}-{}\n", + pkg.tag.py, pkg.tag.abi, pkg.tag.plat + )); + s +} + +/// Write a minimal Python module file content for the package. +fn build_module(pkg: &PyPIPackage) -> (String, Vec) { + let module_dir = normalize_dist_name(&pkg.name).to_string(); + let path = format!("{}/__init__.py", module_dir); + let bytes = b"# generated by pixi tests\n__version__ = \"".to_vec(); + let mut content = bytes; + content.extend_from_slice(pkg.version.as_bytes()); + content.extend_from_slice(b"\"\n"); + (path, content) +} + +/// Write a wheel to `out_dir` for the package. +fn write_wheel(out_dir: &Path, pkg: &PyPIPackage) -> miette::Result { + let wheel_name = wheel_filename(pkg); + let wheel_path = out_dir.join(&wheel_name); + + let file = std::fs::File::create(&wheel_path).into_diagnostic()?; + let mut zip = ZipWriter::new(file); + let options = SimpleFileOptions::default(); + + let dist_info = dist_info_dir(pkg); + + // Prepare files to include so we can compute RECORD entries. + let mut entries: Vec<(String, Vec)> = Vec::new(); + + // Module file + let (module_path, module_bytes) = build_module(pkg); + entries.push((module_path, module_bytes)); + + // METADATA + let metadata_path = format!("{}/METADATA", dist_info); + entries.push((metadata_path.clone(), build_metadata(pkg).into_bytes())); + + // WHEEL + let wheel_file_path = format!("{}/WHEEL", dist_info); + entries.push((wheel_file_path.clone(), build_wheel_file(pkg).into_bytes())); + + // Write all prepared entries to the zip + for (name, bytes) in &entries { + zip.start_file(name, options).into_diagnostic()?; + use std::io::Write as _; + zip.write_all(bytes).into_diagnostic()?; + } + + // Build RECORD content. Omit hashes and sizes (allowed by PEP 376) for simplicity. + let mut record = String::new(); + for (name, _bytes) in &entries { + let _ = writeln!(record, "{name},,"); + } + // RECORD line itself + let record_path = format!("{}/RECORD", dist_info); + let _ = writeln!(record, "{record_path},,"); + + // Write RECORD + zip.start_file(&record_path, options).into_diagnostic()?; + zip.write_all(record.as_bytes()).into_diagnostic()?; + + zip.finish().into_diagnostic()?; + Ok(wheel_path) +} diff --git a/tests/integration_rust/pypi_tests.rs b/tests/integration_rust/pypi_tests.rs index b5485bc5f7..eee0fc9660 100644 --- a/tests/integration_rust/pypi_tests.rs +++ b/tests/integration_rust/pypi_tests.rs @@ -1,9 +1,9 @@ -use std::{io::Write, path::Path}; +use std::io::Write; use rattler_conda_types::Platform; use typed_path::Utf8TypedPath; -use url::Url; +use crate::common::pypi_index::{Database as PyPIDatabase, PyPIPackage}; use crate::common::{LockFileExt, PixiControl}; use crate::setup_tracing; use std::fs::File; @@ -13,11 +13,18 @@ use std::fs::File; async fn test_flat_links_based_index_returns_path() { setup_tracing(); - let pypi_indexes = Path::new(env!("CARGO_WORKSPACE_DIR")).join("tests/data/pypi-indexes"); + // Build a local flat (find-links) index with a single wheel: foo==1.0.0 + let index = PyPIDatabase::new() + .with(PyPIPackage::new("foo", "1.0.0")) + .into_flat_index() + .expect("failed to create local flat index"); + + let find_links_path = index.path().display().to_string().replace('\\', "/"); + let pixi = PixiControl::from_manifest(&format!( r#" [project] - name = "pypi-extra-index-url" + name = "pypi-flat-find-links" platforms = ["{platform}"] channels = ["https://prefix.dev/conda-forge"] @@ -28,24 +35,23 @@ async fn test_flat_links_based_index_returns_path() { foo = "*" [pypi-options] - find-links = [{{ path = "{pypi_indexes}/multiple-indexes-a/flat"}}]"#, + find-links = [{{ path = "{find_links_path}"}}] + "#, platform = Platform::current(), - pypi_indexes = pypi_indexes.display().to_string().replace("\\", "/"), + find_links_path = find_links_path, )); let lock_file = pixi.unwrap().update_lock_file().await.unwrap(); - // This assertion is specifically to test that if we have a url-based *local* index - // we will get a path back to the index and the corresponding file + // Expect the locked URL to be a local path pointing at our generated wheel. + // Our wheel builder uses the tag py3-none-any by default. assert_eq!( lock_file .get_pypi_package_url("default", Platform::current(), "foo") .unwrap() .as_path() .unwrap(), - Utf8TypedPath::from(&*pypi_indexes.as_os_str().to_string_lossy()) - .join("multiple-indexes-a") - .join("flat") - .join("foo-1.0.0-py2.py3-none-any.whl") + Utf8TypedPath::from(&*index.path().as_os_str().to_string_lossy()) + .join("foo-1.0.0-py3-none-any.whl") ); } @@ -54,8 +60,11 @@ async fn test_flat_links_based_index_returns_path() { async fn test_file_based_index_returns_path() { setup_tracing(); - let pypi_indexes = Path::new(env!("CARGO_WORKSPACE_DIR")).join("tests/data/pypi-indexes"); - let pypi_indexes_url = Url::from_directory_path(pypi_indexes.clone()).unwrap(); + let simple = PyPIDatabase::new() + .with(PyPIPackage::new("foo", "1.0.0")) + .into_simple_index() + .expect("failed to create simple index"); + let pixi = PixiControl::from_manifest(&format!( r#" [project] @@ -71,24 +80,22 @@ async fn test_file_based_index_returns_path() { [pypi-options] extra-index-urls = [ - "{pypi_indexes}multiple-indexes-a/index" + "{index_url}" ]"#, platform = Platform::current(), - pypi_indexes = pypi_indexes_url, + index_url = simple.index_url(), )); let lock_file = pixi.unwrap().update_lock_file().await.unwrap(); - // This assertion is specifically to test that if we have a url-based *local* index - // we will get a path back to the index and the corresponding file assert_eq!( lock_file .get_pypi_package_url("default", Platform::current(), "foo") .unwrap() .as_path() .unwrap(), - Utf8TypedPath::from(&*pypi_indexes.as_os_str().to_string_lossy()) - .join("multiple-indexes-a/index/foo") - .join("foo-1.0.0-py2.py3-none-any.whl") + Utf8TypedPath::from(&*simple.index_path().as_os_str().to_string_lossy()) + .join("foo") + .join("foo-1.0.0-py3-none-any.whl") ); } @@ -97,8 +104,18 @@ async fn test_file_based_index_returns_path() { async fn test_index_strategy() { setup_tracing(); - let pypi_indexes = Path::new(env!("CARGO_WORKSPACE_DIR")).join("tests/data/pypi-indexes"); - let pypi_indexes_url = Url::from_directory_path(pypi_indexes.clone()).unwrap(); + let idx_a = PyPIDatabase::new() + .with(PyPIPackage::new("foo", "1.0.0")) + .into_simple_index() + .unwrap(); + let idx_b = PyPIDatabase::new() + .with(PyPIPackage::new("foo", "2.0.0")) + .into_simple_index() + .unwrap(); + let idx_c = PyPIDatabase::new() + .with(PyPIPackage::new("foo", "3.0.0")) + .into_simple_index() + .unwrap(); let pixi = PixiControl::from_manifest(&format!( r#" @@ -115,9 +132,9 @@ async fn test_index_strategy() { [pypi-options] extra-index-urls = [ - "{pypi_indexes}multiple-indexes-a/index", - "{pypi_indexes}multiple-indexes-b/index", - "{pypi_indexes}multiple-indexes-c/index", + "{idx_a}", + "{idx_b}", + "{idx_c}", ] [feature.first-index.pypi-options] @@ -142,7 +159,9 @@ async fn test_index_strategy() { unsafe-best-match = ["unsafe-best-match"] "#, platform = Platform::current(), - pypi_indexes = pypi_indexes_url, + idx_a = idx_a.index_url(), + idx_b = idx_b.index_url(), + idx_c = idx_c.index_url(), )); let lock_file = pixi.unwrap().update_lock_file().await.unwrap(); @@ -180,8 +199,10 @@ async fn test_index_strategy() { async fn test_pinning_index() { setup_tracing(); - let pypi_indexes = Path::new(env!("CARGO_WORKSPACE_DIR")).join("tests/data/pypi-indexes"); - let pypi_indexes_url = Url::from_directory_path(pypi_indexes.clone()).unwrap(); + let idx = PyPIDatabase::new() + .with(PyPIPackage::new("foo", "1.0.0")) + .into_simple_index() + .unwrap(); let pixi = PixiControl::from_manifest(&format!( r#" @@ -194,11 +215,11 @@ async fn test_pinning_index() { python = "~=3.12.0" [pypi-dependencies] - foo = {{ version = "*", index = "{pypi_indexes}multiple-indexes-a/index" }} + foo = {{ version = "*", index = "{idx_url}" }} "#, platform = Platform::current(), - pypi_indexes = pypi_indexes_url, + idx_url = idx.index_url(), )); let lock_file = pixi.unwrap().update_lock_file().await.unwrap(); @@ -209,9 +230,9 @@ async fn test_pinning_index() { .unwrap() .as_path() .unwrap(), - Utf8TypedPath::from(&*pypi_indexes.as_os_str().to_string_lossy()) - .join("multiple-indexes-a/index/foo") - .join("foo-1.0.0-py2.py3-none-any.whl") + Utf8TypedPath::from(&*idx.index_path().as_os_str().to_string_lossy()) + .join("foo") + .join("foo-1.0.0-py3-none-any.whl") ); } @@ -308,8 +329,11 @@ async fn test_allow_insecure_host() { async fn test_indexes_are_passed_when_solving_build_pypi_dependencies() { setup_tracing(); - let pypi_indexes = Path::new(env!("CARGO_WORKSPACE_DIR")).join("tests/data/pypi-indexes"); - let pypi_indexes_url = Url::from_directory_path(pypi_indexes.clone()).unwrap(); + // Provide a local simple index containing `foo` used in build-system requires. + let simple = PyPIDatabase::new() + .with(PyPIPackage::new("foo", "1.0.0")) + .into_simple_index() + .expect("failed to create simple index"); let pixi = PixiControl::from_pyproject_manifest(&format!( r#" @@ -342,14 +366,14 @@ async fn test_indexes_are_passed_when_solving_build_pypi_dependencies() { hatchling = "*" [tool.pixi.pypi-options] - index-url = "{pypi_indexes}multiple-indexes-a/index" + index-url = "{index_url}" no-build-isolation = ["pypi-build-index"] [tool.pixi.pypi-dependencies] pypi-build-index = {{ path = ".", editable = true }} "#, platform = Platform::current(), - pypi_indexes = pypi_indexes_url, + index_url = simple.index_url(), )) .unwrap(); @@ -363,11 +387,7 @@ async fn test_indexes_are_passed_when_solving_build_pypi_dependencies() { // verify that the pypi-build-index can be installed when solved the build dependencies pixi.install().await.unwrap(); - let mut local_pypi_index = pypi_indexes - .join("multiple-indexes-a") - .join("index") - .display() - .to_string(); + let mut local_pypi_index = simple.index_path().display().to_string(); let mut lock_file_index = lock_file .default_environment() @@ -391,6 +411,13 @@ async fn test_indexes_are_passed_when_solving_build_pypi_dependencies() { } // verify that + // Normalize possible trailing slash differences + if !local_pypi_index.ends_with('/') { + local_pypi_index.push('/'); + } + if !lock_file_index.ends_with('/') { + lock_file_index.push('/'); + } assert_eq!(local_pypi_index, lock_file_index,); } @@ -406,7 +433,11 @@ async fn test_cross_platform_resolve_with_no_build() { Platform::OsxArm64 }; - let pypi_indexes = Path::new(env!("CARGO_WORKSPACE_DIR")).join("tests/data/pypi-indexes"); + // Use a local flat index for foo==1.0.0 + let flat = PyPIDatabase::new() + .with(PyPIPackage::new("foo", "1.0.0")) + .into_flat_index() + .expect("failed to create flat index"); let pixi = PixiControl::from_manifest(&format!( r#" [project] @@ -422,9 +453,9 @@ async fn test_cross_platform_resolve_with_no_build() { [pypi-options] no-build = true - find-links = [{{ path = "{pypi_indexes}/multiple-indexes-a/flat"}}]"#, + find-links = [{{ path = "{find_links}"}}]"#, platform = resolve_platform, - pypi_indexes = pypi_indexes.display().to_string().replace("\\", "/"), + find_links = flat.path().display().to_string().replace("\\", "/"), )); let lock_file = pixi.unwrap().update_lock_file().await.unwrap(); @@ -434,10 +465,8 @@ async fn test_cross_platform_resolve_with_no_build() { .unwrap() .as_path() .unwrap(), - Utf8TypedPath::from(&*pypi_indexes.as_os_str().to_string_lossy()) - .join("multiple-indexes-a") - .join("flat") - .join("foo-1.0.0-py2.py3-none-any.whl") + Utf8TypedPath::from(&*flat.path().as_os_str().to_string_lossy()) + .join("foo-1.0.0-py3-none-any.whl") ); } @@ -450,28 +479,63 @@ async fn test_cross_platform_resolve_with_no_build() { async fn test_pinned_help_message() { setup_tracing(); - let pixi = PixiControl::from_manifest( + // Construct a minimal local conda channel with python and pandas==1.0.0 + use crate::common::package_database::{Package, PackageDatabase}; + use rattler_conda_types::Platform; + + let mut conda_db = PackageDatabase::default(); + // Python runtime + conda_db.add_package( + Package::build("python", "3.12.0") + .with_subdir(Platform::current()) + .finish(), + ); + // pandas 1.0.0 (marked as PyPI package via purl) + conda_db.add_package( + Package::build("pandas", "1.0.0") + .with_subdir(Platform::current()) + .with_dependency("python >=3.12") + .with_pypi_purl("pandas") + .finish(), + ); + let conda_channel = conda_db.into_channel().await.unwrap(); + + // Build a simple PyPI index with package `a` that requires pandas>=2.0.0 + let pypi_index = PyPIDatabase::new() + .with(PyPIPackage::new("a", "1.0.0").with_requires_dist(["pandas>=2.0.0"])) + .into_simple_index() + .unwrap(); + + // Use only our local channel and local simple index + let pixi = PixiControl::from_manifest(&format!( r#" [workspace] - channels = ["https://prefix.dev/conda-forge"] - name = "deleteme" - platforms = ["linux-64"] + channels = ["{channel}"] + name = "local-pinned-help" + platforms = ["{platform}"] version = "0.1.0" [dependencies] python = "3.12.*" - pandas = "2.3.2" + pandas = "==1.0.0" [pypi-dependencies] - databricks-sql-connector = ">=4.0.0" + a = "*" + + [pypi-options] + extra-index-urls = ["{idx}"] "#, - ); - // First, it should fail + channel = conda_channel.url(), + platform = Platform::current(), + idx = pypi_index.index_url(), + )); + + // Expect failure let result = pixi.unwrap().update_lock_file().await; - let err = result.err().unwrap(); - // Second, it should contain a help message + let err = result.expect_err("expected a resolution error"); + // Should contain pinned help message for pandas==1.0.0 assert_eq!( format!("{}", err.help().unwrap()), - "The following PyPI packages have been pinned by the conda solve, and this version may be causing a conflict:\npandas==2.3.2" + "The following PyPI packages have been pinned by the conda solve, and this version may be causing a conflict:\npandas==1.0.0" ); }