diff --git a/src/global/common.rs b/src/global/common.rs index 5136548d4..04561d29c 100644 --- a/src/global/common.rs +++ b/src/global/common.rs @@ -1,4 +1,4 @@ -use super::trampoline::{GlobalBin, Trampoline}; +use super::trampoline::{GlobalExecutable, Trampoline}; use super::{EnvironmentName, ExposedName, Mapping}; use crate::prefix::Executable; @@ -22,6 +22,7 @@ use rattler_conda_types::{ use std::collections::HashMap; use std::ffi::OsStr; use std::iter::Peekable; +use std::ops::Not; use std::str::FromStr; use std::{ io::Read, @@ -57,19 +58,18 @@ impl BinDir { /// /// This function reads the directory specified by `self.0` and try to collect all /// file paths into a vector. It returns a `miette::Result` containing the - /// vector of `GlobalBin`or an error if the directory can't be read. - pub(crate) async fn bins(&self) -> miette::Result> { + /// vector of `GlobalExecutable`or an error if the directory can't be read. + pub(crate) async fn executables(&self) -> miette::Result> { let mut files = Vec::new(); let mut entries = tokio_fs::read_dir(&self.0).await.into_diagnostic()?; while let Some(entry) = entries.next_entry().await.into_diagnostic()? { let path = entry.path(); - if path.is_file() && path.is_executable() && Trampoline::is_trampoline(&path).await? { - let trampoline = Trampoline::try_from(path).await?; - files.push(GlobalBin::Trampoline(trampoline)); - } else if path.is_file() && path.is_executable() && !is_binary(&path)? { + if let Ok(trampoline) = Trampoline::try_from(&path).await { + files.push(GlobalExecutable::Trampoline(trampoline)); + } else if path.is_file() && path.is_executable() && is_binary(&path)?.not() { // If the file is not a binary, it's a script - files.push(GlobalBin::Script(path)); + files.push(GlobalExecutable::Script(path)); } } @@ -704,9 +704,9 @@ pub(crate) async fn get_expose_scripts_sync_status( bin_dir: &BinDir, env_dir: &EnvDir, mappings: &IndexSet, -) -> miette::Result<(Vec, IndexSet)> { +) -> miette::Result<(Vec, IndexSet)> { // Get all paths to the binaries from trampolines or scripts in the bin directory. - let locally_exposed = bin_dir.bins().await?; + let locally_exposed = bin_dir.executables().await?; let executable_paths = futures::future::join_all(locally_exposed.iter().map(|global_bin| { let global_bin = global_bin.clone(); let path = global_bin.path().clone(); diff --git a/src/global/install.rs b/src/global/install.rs index 3be391e44..d8897dc1d 100644 --- a/src/global/install.rs +++ b/src/global/install.rs @@ -107,6 +107,7 @@ pub(crate) async fn create_executable_trampolines( original_executable, } in mapped_executables { + tracing::debug!("Create trampoline {}", global_script_path.display()); let exe = prefix.root().join(original_executable); let path = prefix .root() @@ -118,7 +119,14 @@ pub(crate) async fn create_executable_trampolines( })?); let metadata = Configuration::new(exe, path, Some(activation_variables.clone())); - let json_path = Configuration::trampoline_configuration(global_script_path); + let parent_dir = global_script_path.parent().ok_or_else(|| { + miette::miette!( + "{} needs to have a parent directory", + global_script_path.display() + ) + })?; + let exposed_name = Trampoline::name(global_script_path)?; + let json_path = Configuration::path_from_trampoline(parent_dir, &exposed_name); // Check if an old bash script is present and remove it let mut changed = if global_script_path.exists() @@ -463,7 +471,7 @@ mod tests { #[cfg(windows)] #[tokio::test] async fn test_extract_executable_from_script_windows() { - use crate::global::trampoline::GlobalBin; + use crate::global::trampoline::GlobalExecutable; use std::fs; use std::path::Path; let script_without_quote = r#" @@ -475,7 +483,7 @@ mod tests { let tempdir = tempfile::tempdir().unwrap(); let script_path = tempdir.path().join(script_path); fs::write(&script_path, script_without_quote).unwrap(); - let script_global_bin = GlobalBin::Script(script_path); + let script_global_bin = GlobalExecutable::Script(script_path); let executable_path = script_global_bin.executable().await.unwrap(); assert_eq!( executable_path, @@ -502,7 +510,7 @@ mod tests { async fn test_extract_executable_from_script_unix() { use std::{fs, path::Path}; - use crate::global::trampoline::GlobalBin; + use crate::global::trampoline::GlobalExecutable; let script = r#"#!/bin/sh export PATH="/home/user/.pixi/envs/nushell/bin:${PATH}" @@ -513,7 +521,7 @@ export CONDA_PREFIX="/home/user/.pixi/envs/nushell" let tempdir = tempfile::tempdir().unwrap(); let script_path = tempdir.path().join(script_path); fs::write(&script_path, script).unwrap(); - let script_global_bin = GlobalBin::Script(script_path); + let script_global_bin = GlobalExecutable::Script(script_path); let executable_path = script_global_bin.executable().await.unwrap(); assert_eq!( executable_path, diff --git a/src/global/project/mod.rs b/src/global/project/mod.rs index 7666ab542..b97d57f9f 100644 --- a/src/global/project/mod.rs +++ b/src/global/project/mod.rs @@ -1,6 +1,8 @@ +use self::trampoline::{Configuration, ConfigurationParseError, Trampoline}; + use super::common::{get_install_changes, EnvironmentUpdate}; use super::install::find_binary_by_name; -use super::trampoline::GlobalBin; +use super::trampoline::{self, GlobalExecutable}; use super::{BinDir, EnvRoot, StateChange, StateChanges}; use crate::global::common::{ channel_url_to_prioritized_channel, find_package_records, get_expose_scripts_sync_status, @@ -22,6 +24,7 @@ use fs::tokio as tokio_fs; use fs_err as fs; use futures::stream::StreamExt; use indexmap::{IndexMap, IndexSet}; +use is_executable::IsExecutable; use itertools::Itertools; pub(crate) use manifest::{ExposedType, Manifest, Mapping}; use miette::{miette, Context, IntoDiagnostic}; @@ -113,7 +116,7 @@ impl ExposedData { /// reading the associated `conda-meta` directory. /// or it looks into the trampoline manifest to extract the metadata. pub async fn from_exposed_path( - bin: &GlobalBin, + bin: &GlobalExecutable, env_root: &EnvRoot, channel_config: &ChannelConfig, ) -> miette::Result { @@ -286,17 +289,25 @@ impl Project { let env_root = EnvRoot::from_env().await?; if !manifest_path.exists() { + tracing::debug!( + "Global manifest {} doesn't exist yet. Creating a new one.", + manifest_path.display() + ); tokio_fs::create_dir_all(&manifest_dir) .await .into_diagnostic()?; if !env_root.directories().await?.is_empty() { + tracing::debug!( + "Existing installation found. Creating global manifest from that information." + ); return Self::try_from_existing_installation(&manifest_path, env_root, bin_dir) .await .wrap_err_with(|| { "Failed to create global manifest from existing installation" }); } else { + tracing::debug!("Create an empty global manifest."); tokio_fs::File::create(&manifest_path) .await .into_diagnostic()?; @@ -314,7 +325,7 @@ impl Project { let config = Config::load(env_root.path()); let exposed_binaries: Vec = bin_dir - .bins() + .executables() .await? .into_iter() .map(|bin| { @@ -793,7 +804,7 @@ impl Project { return Ok(false); } - // Verify the binaries to be in sync with the environment + tracing::debug!("Verify that the binaries are in sync with the environment"); let (to_remove, to_add) = get_expose_scripts_sync_status(&self.bin_dir, &env_dir, &environment.exposed).await?; if !to_remove.is_empty() || !to_add.is_empty() { @@ -896,9 +907,9 @@ impl Project { // Prune environments that are not listed state_changes |= self.prune_old_environments().await?; - // Remove broken scripts - if let Err(err) = self.remove_broken_bins().await { - tracing::warn!("Couldn't remove broken exposed executables: {err}") + // Remove broken files + if let Err(err) = self.remove_broken_files().await { + tracing::warn!("Couldn't remove broken files: {err}") } for env_name in self.environments().keys() { @@ -916,9 +927,14 @@ impl Project { removed_packages: Option>, ) -> miette::Result { let mut state_changes = StateChanges::new_with_env(env_name.clone()); - if !self.environment_in_sync(env_name).await? { + if self.environment_in_sync(env_name).await? { + tracing::debug!( + "Environment {} specs already up to date with global manifest", + env_name.fancy_display() + ); + } else { tracing::debug!( - "Environment {} specs not up to date with manifest", + "Environment {} specs not up to date with global manifest", env_name.fancy_display() ); let mut environment_update = self.install_environment(env_name).await?; @@ -940,37 +956,36 @@ impl Project { } /// Delete scripts in the bin folder that are broken - pub(crate) async fn remove_broken_bins(&self) -> miette::Result<()> { - for exposed_bin in self.bin_dir.bins().await? { - let executable = exposed_bin.executable().await; - - if executable - .and_then(|path| { - if path.is_file() { - Ok(path) - } else { - miette::bail!("Path is not a file") + pub(crate) async fn remove_broken_files(&self) -> miette::Result<()> { + // Get all the files in the global binary directory + // If there's a trampoline that couldn't be correctly parsed, remove it + let root_path = self.bin_dir.path(); + let mut entries = tokio_fs::read_dir(&root_path).await.into_diagnostic()?; + + while let Some(entry) = entries.next_entry().await.into_diagnostic()? { + let path = entry.path(); + if path.is_file() && path.is_executable() && Trampoline::is_trampoline(&path).await? { + let exposed_name = Trampoline::name(&path)?; + match Configuration::from_root_path(root_path, &exposed_name).await { + Ok(_) => (), + Err(ConfigurationParseError::ReadError(config_path, err)) => { + tracing::warn!("Couldn't read {}: {err}", config_path.display()); + tracing::warn!("Removing the trampoline at {}", path.display()); + tokio_fs::remove_file(path).await.into_diagnostic()?; + } + Err(ConfigurationParseError::ParseError(config_path, err)) => { + tracing::warn!("Couldn't parse {}: {err}", config_path.display()); + tracing::warn!( + "Removing the trampoline at {} and configuration at {}", + path.display(), + config_path.display() + ); + tokio_fs::remove_file(path).await.into_diagnostic()?; + tokio_fs::remove_file(config_path).await.into_diagnostic()?; } - }) - .is_err() - { - tokio_fs::remove_file(exposed_bin.path()) - .await - .into_diagnostic()?; - - if exposed_bin.is_trampoline() { - tokio_fs::remove_file( - exposed_bin - .trampoline() - .expect("we checked it") - .manifest_path(), - ) - .await - .into_diagnostic()?; } } } - Ok(()) } diff --git a/src/global/trampoline.rs b/src/global/trampoline.rs index 8d90e231a..4a8a30f7b 100644 --- a/src/global/trampoline.rs +++ b/src/global/trampoline.rs @@ -127,6 +127,17 @@ pub(crate) async fn extract_executable_from_script(script: &Path) -> miette::Res ) } +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum ConfigurationParseError { + #[error("Failed to read configuration file at {0}")] + #[diagnostic(code(configuration::read_error))] + ReadError(PathBuf, #[source] std::io::Error), + + #[error("Failed to parse configuration file at {0}")] + #[diagnostic(code(configuration::parse_error))] + ParseError(PathBuf, #[source] serde_json::Error), +} + /// Configuration of the original executable. /// This is used by trampoline to set the environment variables /// prepened the path and execute the original executable. @@ -152,49 +163,47 @@ impl Configuration { /// Read existing configuration of trampoline from the root path. pub async fn from_root_path( - root_path: PathBuf, + root_path: &Path, exposed_name: &ExposedName, - ) -> miette::Result { - let manifest_path = root_path - .join(TRAMPOLINE_CONFIGURATION) - .join(exposed_name.to_string() + ".json"); - let manifest_str = tokio_fs::read_to_string(manifest_path) + ) -> Result { + let configuration_path = Self::path_from_trampoline(root_path, exposed_name); + let manifest_str = tokio_fs::read_to_string(&configuration_path) .await - .into_diagnostic()?; - serde_json::from_str(&manifest_str).into_diagnostic() + .map_err(|e| ConfigurationParseError::ReadError(configuration_path.clone(), e))?; + serde_json::from_str(&manifest_str) + .map_err(|e| ConfigurationParseError::ParseError(configuration_path.clone(), e)) } /// Return the configuration file for the trampoline. - pub fn trampoline_configuration(trampoline: &Path) -> PathBuf { - let parent = trampoline.parent().expect("should have a parent"); - parent + pub fn path_from_trampoline(root_path: &Path, exposed_name: &ExposedName) -> PathBuf { + root_path .join(PathBuf::from(TRAMPOLINE_CONFIGURATION)) - .join(Trampoline::name(trampoline) + ".json") + .join(format!("{exposed_name}.json")) } } /// Represents an exposed global executable installed by pixi global. /// This can be either a trampoline or a old script. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum GlobalBin { +pub enum GlobalExecutable { Trampoline(Trampoline), Script(PathBuf), } -impl GlobalBin { +impl GlobalExecutable { /// Returns the path to the original executable. pub async fn executable(&self) -> miette::Result { Ok(match self { - GlobalBin::Trampoline(trampoline) => trampoline.original_exe(), - GlobalBin::Script(script) => extract_executable_from_script(script).await?, + GlobalExecutable::Trampoline(trampoline) => trampoline.original_exe(), + GlobalExecutable::Script(script) => extract_executable_from_script(script).await?, }) } /// Returns exposed name pub fn exposed_name(&self) -> ExposedName { match self { - GlobalBin::Trampoline(trampoline) => trampoline.exposed_name.clone(), - GlobalBin::Script(script) => { + GlobalExecutable::Trampoline(trampoline) => trampoline.exposed_name.clone(), + GlobalExecutable::Script(script) => { ExposedName::from_str(&executable_from_path(script)).unwrap() } } @@ -203,37 +212,29 @@ impl GlobalBin { /// Returns the path to the exposed binary. pub fn path(&self) -> PathBuf { match self { - GlobalBin::Trampoline(trampoline) => trampoline.path(), - GlobalBin::Script(script) => script.clone(), + GlobalExecutable::Trampoline(trampoline) => trampoline.path(), + GlobalExecutable::Script(script) => script.clone(), } } /// Returns if the exposed global binary is trampoline. pub fn is_trampoline(&self) -> bool { - matches!(self, GlobalBin::Trampoline(_)) - } - - /// Returns the inner trampoline. - pub fn trampoline(&self) -> Option<&Trampoline> { - match self { - GlobalBin::Trampoline(trampoline) => Some(trampoline), - _ => None, - } + matches!(self, GlobalExecutable::Trampoline(_)) } /// Removes exposed global executable. /// In case it is a trampoline, it will also remove its manifest. pub async fn remove(&self) -> miette::Result<()> { match self { - GlobalBin::Trampoline(trampoline) => { + GlobalExecutable::Trampoline(trampoline) => { let (trampoline_removed, manifest_removed) = tokio::join!( tokio_fs::remove_file(trampoline.path()), - tokio_fs::remove_file(trampoline.manifest_path()) + tokio_fs::remove_file(trampoline.configuration()) ); trampoline_removed.into_diagnostic()?; manifest_removed.into_diagnostic()?; } - GlobalBin::Script(script) => { + GlobalExecutable::Script(script) => { tokio_fs::remove_file(script).await.into_diagnostic()?; } } @@ -269,8 +270,8 @@ impl Trampoline { } /// Tries to create a trampoline object from the already existing trampoline. - pub async fn try_from(trampoline_path: PathBuf) -> miette::Result { - let exposed_name = ExposedName::from_str(&executable_from_path(&trampoline_path))?; + pub async fn try_from(trampoline_path: &Path) -> miette::Result { + let exposed_name = ExposedName::from_str(&executable_from_path(trampoline_path))?; let parent_path = trampoline_path .parent() .ok_or_else(|| { @@ -281,7 +282,7 @@ impl Trampoline { })? .to_path_buf(); - let metadata = Configuration::from_root_path(parent_path.clone(), &exposed_name).await?; + let metadata = Configuration::from_root_path(&parent_path, &exposed_name).await?; Ok(Trampoline::new(exposed_name, parent_path, metadata)) } @@ -295,8 +296,8 @@ impl Trampoline { self.configuration.exe.clone() } - /// Returns the path to the trampoline manifest - pub fn manifest_path(&self) -> PathBuf { + /// Returns the path to the trampoline configuration + pub fn configuration(&self) -> PathBuf { self.root_path .join(TRAMPOLINE_CONFIGURATION) .join(self.exposed_name.to_string() + ".json") @@ -311,22 +312,30 @@ impl Trampoline { } /// Returns the name of the trampoline - pub fn name(trampoline: &Path) -> String { - let trampoline_name = trampoline.file_name().expect("should have a file name"); + pub fn name(trampoline: &Path) -> miette::Result { + let trampoline_name = trampoline.file_name().ok_or_else(|| { + miette::miette!( + "trampoline needs to have a file name {}", + trampoline.display() + ) + })?; // strip .exe from the file name - if cfg!(windows) { + let exposed_name = if cfg!(windows) { trampoline_name .to_string_lossy() .strip_suffix(".exe") - .expect("should have suffix") + .ok_or_else(|| miette::miette!("Trampoline doesn't have '.exe' suffix"))? .to_string() } else { trampoline_name.to_string_lossy().to_string() - } + }; + + ExposedName::from_str(&exposed_name).into_diagnostic() } pub async fn save(&self) -> miette::Result<()> { - let (trampoline, manifest) = tokio::join!(self.write_trampoline(), self.write_manifest()); + let (trampoline, manifest) = + tokio::join!(self.write_trampoline(), self.write_configuration()); trampoline?; manifest?; Ok(()) @@ -387,19 +396,19 @@ impl Trampoline { Ok(()) } - /// Writes the manifest file of the trampoline - async fn write_manifest(&self) -> miette::Result<()> { - let manifest_string = + /// Writes the configuration file of the trampoline + async fn write_configuration(&self) -> miette::Result<()> { + let configuration_string = serde_json::to_string_pretty(&self.configuration).into_diagnostic()?; tokio_fs::create_dir_all( - Configuration::trampoline_configuration(&self.path()) + self.configuration() .parent() .expect("should have a parent folder"), ) .await .into_diagnostic()?; - tokio_fs::write(self.manifest_path(), manifest_string) + tokio_fs::write(self.configuration(), configuration_string) .await .into_diagnostic()?; diff --git a/src/lock_file/satisfiability.rs b/src/lock_file/satisfiability.rs index da81b22b4..8da5a37e1 100644 --- a/src/lock_file/satisfiability.rs +++ b/src/lock_file/satisfiability.rs @@ -1461,7 +1461,7 @@ mod tests { Ok(()) } - #[rstest::rstest] + #[rstest] #[tokio::test] async fn test_good_satisfiability( #[files("tests/data/satisfiability/*/pixi.toml")] manifest_path: PathBuf, diff --git a/src/project/mod.rs b/src/project/mod.rs index dabbbb970..4804a9514 100644 --- a/src/project/mod.rs +++ b/src/project/mod.rs @@ -883,9 +883,10 @@ impl Project { .flatten() .collect_vec(); - let mut pinning_strategy = self.config().pinning_strategy; let channel_config = self.channel_config(); for (name, (spec_type, spec)) in conda_specs_to_add_constraints_for { + let mut pinning_strategy = self.config().pinning_strategy; + // Edge case: some packages are a special case where we want to pin the minor // version by default. This is done to avoid early user confusion // when the minor version changes and environments magically start breaking. diff --git a/tests/integration_python/global/test_global.py b/tests/integration_python/global/test_global.py index 6679d6ed9..b30fd0bf1 100644 --- a/tests/integration_python/global/test_global.py +++ b/tests/integration_python/global/test_global.py @@ -5,8 +5,6 @@ import tomli_w from ..common import verify_cli_command, ExitCode, exec_extension, bat_extension import platform -import os -import stat MANIFEST_VERSION = 1 @@ -217,35 +215,6 @@ def test_sync_duplicated_expose_error(pixi: Path, tmp_path: Path, dummy_channel_ ) -def test_sync_clean_up_broken_exec(pixi: Path, tmp_path: Path, dummy_channel_1: str) -> None: - env = {"PIXI_HOME": str(tmp_path)} - manifests = tmp_path.joinpath("manifests") - manifests.mkdir() - manifest = manifests.joinpath("pixi-global.toml") - toml = f""" -version = {MANIFEST_VERSION} - -[envs.one] -channels = ["{dummy_channel_1}"] -dependencies = {{ dummy-a = "*" }} -exposed = {{ dummy-1 = "dummy-a" }} - """ - manifest.write_text(toml) - - bin_dir = manifests = tmp_path.joinpath("bin") - bin_dir.mkdir() - broken_exec = bin_dir.joinpath("broken.com") - broken_exec.write_text("Hello world") - if platform.system() != "Windows": - os.chmod(broken_exec, os.stat(broken_exec).st_mode | stat.S_IEXEC) - - verify_cli_command( - [pixi, "global", "sync"], - env=env, - ) - assert not broken_exec.is_file() - - def test_expose_basic(pixi: Path, tmp_path: Path, dummy_channel_1: str) -> None: env = {"PIXI_HOME": str(tmp_path)} manifests = tmp_path.joinpath("manifests") diff --git a/tests/integration_python/global/test_trampoline.py b/tests/integration_python/global/test_trampoline.py index f93ecd07d..2348e50fd 100644 --- a/tests/integration_python/global/test_trampoline.py +++ b/tests/integration_python/global/test_trampoline.py @@ -258,3 +258,34 @@ def test_trampoline_extends_path(pixi: Path, tmp_path: Path, trampoline_path_cha [dummy_trampoline_path], stdout_contains=["/another/test/path", "/test/path"], ) + + +def test_trampoline_removes_trampolines_not_in_manifest( + pixi: Path, tmp_path: Path, trampoline_channel_1: str +) -> None: + env = {"PIXI_HOME": str(tmp_path)} + + dummy_trampoline_original = tmp_path / "bin" / exec_extension("dummy-trampoline") + + verify_cli_command( + [ + pixi, + "global", + "install", + "--channel", + trampoline_channel_1, + "dummy-trampoline", + ], + env=env, + ) + + dummy_trampoline_new = dummy_trampoline_original.rename( + dummy_trampoline_original.parent / exec_extension("dummy-trampoline-new") + ) + + verify_cli_command( + [pixi, "global", "sync"], + env=env, + ) + assert dummy_trampoline_original.is_file() + assert not dummy_trampoline_new.is_file() diff --git a/tests/integration_rust/add_tests.rs b/tests/integration_rust/add_tests.rs index ffca9726b..b4599b5c7 100644 --- a/tests/integration_rust/add_tests.rs +++ b/tests/integration_rust/add_tests.rs @@ -470,9 +470,8 @@ async fn add_sdist_functionality() { .unwrap(); } -#[rstest::rstest] #[tokio::test] -async fn add_unconstrainted_dependency() { +async fn add_unconstrained_dependency() { // Create a channel with a single package let mut package_database = PackageDatabase::default(); package_database.add_package(Package::build("foobar", "1").finish()); @@ -582,3 +581,68 @@ async fn pinning_dependency() { .to_string(); assert_eq!(python_spec, r#""==3.13""#); } + +#[tokio::test] +async fn add_dependency_pinning_strategy() { + // Create a channel with two packages + let mut package_database = PackageDatabase::default(); + package_database.add_package(Package::build("foo", "1").finish()); + package_database.add_package(Package::build("bar", "1").finish()); + package_database.add_package(Package::build("python", "3.13").finish()); + + let local_channel = package_database.into_channel().await.unwrap(); + + // Initialize a new pixi project using the above channel + let pixi = PixiControl::new().unwrap(); + pixi.init().with_channel(local_channel.url()).await.unwrap(); + + // Add the `packages` to the project + pixi.add_multiple(vec!["foo", "python", "bar"]) + .await + .unwrap(); + + let project = pixi.project().unwrap(); + + // Get the specs for the `foo` package + let foo_spec = project + .manifest() + .default_feature() + .dependencies(SpecType::Run, None) + .unwrap_or_default() + .get("foo") + .cloned() + .unwrap() + .to_toml_value() + .to_string(); + assert_eq!(foo_spec, r#"">=1,<2""#); + + // Get the specs for the `python` package + let python_spec = project + .manifest() + .default_feature() + .dependencies(SpecType::Run, None) + .unwrap_or_default() + .get("python") + .cloned() + .unwrap() + .to_toml_value() + .to_string(); + // Testing to see if edge cases are handled correctly + // Python shouldn't be automatically pinned to a major version. + assert_eq!(python_spec, r#"">=3.13,<3.14""#); + + // Get the specs for the `bar` package + let bar_spec = project + .manifest() + .default_feature() + .dependencies(SpecType::Run, None) + .unwrap_or_default() + .get("bar") + .cloned() + .unwrap() + .to_toml_value() + .to_string(); + // Testing to make sure bugfix did not regress + // Package should be automatically pinned to a major version + assert_eq!(bar_spec, r#"">=1,<2""#); +}