Repository: prefix-dev/pixi
Pull request #3746: fix: keep index on pypi dependency upgrade
Author: login: remimimimimi
Created: 2025-05-09T17:19:35Z | Merged: 2025-05-20T10:15:14Z

Fixes #3701. This solution is different from proposed. Instead of modifying `Requirement` or one of the `PixiPypiSpec` we merge `Requirement` with the original `PixiPypiSpec`. Thus, we change the minimum amount of code. Currently "merging" is not not implemented for all possible cases, it covers only specific case of #3701.

Linked issue #3701: `pixi upgrade` discards `index` from `pypi-dependencies`
### Checks

- [x] I have checked that this issue has not already been reported.

- [x] I have confirmed this bug exists on the [latest version](https://github.com/prefix-dev/pixi/releases) of pixi, using `pixi --version`.


### Reproducible example

```sh
$ cat <<EOF > pixi.toml
[workspace]
channels = ["conda-forge"]
platforms = ["linux-64"]

[pypi-dependencies]
torch = { version = "==2.7.0", index = "https://download.pytorch.org/whl/cpu" }

[dependencies]
python = "==3.13.3"
EOF

$ pixi upgrade

$ cat pixi.toml
[workspace]
channels = ["conda-forge"]
platforms = ["linux-64"]

[pypi-dependencies]
torch = "==2.7.0"

[dependencies]
python = "==3.13.3"
```

### Issue description

`pixi upgrade` ignores the configured index in pypi dependencies and drops it when rewriting `pixi.toml`. In the example above, pixi inadvertently switches from the CPU to the CUDA version of pytorch. 

### Expected behavior

pixi should check for upgrades in the index configured for this dependency and keep the index setting in the config.
Linked issue comments: user: login: ruben-arts
created_at: 2025-05-02T14:29:47Z
body: This is indeed a bug! Thanks for the reproducer!<br><br>It has to do with the way we interpret the dependencies in the `pixi upgrade` command. I think we should use the full `PixiPypiSpec` instead of just the `Requirement`, as input for the solve.<br><br>Contributions are welcome!
author_association: CONTRIBUTOR
user: login: martenlienen
created_at: 2025-05-04T18:41:15Z
body: I have looked into this a little but it seems to be not so straightforward to me. The problem I see is that `WorkspaceMut.update_dependencies` is also used for `pixi add` and at the same time there is no `PixiPypiSpec` variant that can contain all attributes of a `Requirement`. If `PixiPypiSpec::Version` was extended with `markers` and `origin` attributes, fixing this should be possible. Do you think that is the way to go or am I missing something?
author_association: NONE

Diff:
diff --git a/crates/pixi_manifest/src/manifests/document.rs b/crates/pixi_manifest/src/manifests/document.rs
index a59fc52819..b2dfc944e8 100644
--- a/crates/pixi_manifest/src/manifests/document.rs
+++ b/crates/pixi_manifest/src/manifests/document.rs
@@ -341,10 +341,11 @@ impl ManifestDocument {
     pub fn add_pypi_dependency(
         &mut self,
         requirement: &pep508_rs::Requirement,
+        pixi_requirement: Option<&PixiPypiSpec>,
         platform: Option<Platform>,
         feature_name: &FeatureName,
         editable: Option<bool>,
-        location: &Option<PypiDependencyLocation>,
+        location: Option<&PypiDependencyLocation>,
     ) -> Result<(), TomlError> {
         // Pypi dependencies can be stored in different places in pyproject.toml
         // manifests so we remove any potential dependency of the same name
@@ -369,7 +370,8 @@ impl ManifestDocument {
             || editable.is_some_and(|e| e)
         {
             let mut pypi_requirement =
-                PixiPypiSpec::try_from(requirement.clone()).map_err(Box::new)?;
+                PixiPypiSpec::try_from((requirement.clone(), pixi_requirement.cloned()))
+                    .map_err(Box::new)?;
             if let Some(editable) = editable {
                 pypi_requirement.set_editable(editable);
             }
diff --git a/crates/pixi_manifest/src/manifests/workspace.rs b/crates/pixi_manifest/src/manifests/workspace.rs
index 1d6250223d..55825c5424 100644
--- a/crates/pixi_manifest/src/manifests/workspace.rs
+++ b/crates/pixi_manifest/src/manifests/workspace.rs
@@ -18,7 +18,7 @@ use crate::{
 use indexmap::{Equivalent, IndexMap, IndexSet};
 use itertools::Itertools;
 use miette::{Context, IntoDiagnostic, SourceCode, miette};
-use pixi_pypi_spec::PypiPackageName;
+use pixi_pypi_spec::{PixiPypiSpec, PypiPackageName};
 use pixi_spec::PixiSpec;
 use rattler_conda_types::{ParseStrictness::Strict, Platform, Version, VersionSpec};
 use toml_edit::Value;
@@ -478,12 +478,12 @@ impl WorkspaceManifestMut<'_> {
     /// `ManifestProvenance::save` to persist the changes to disk.
     pub fn add_pep508_dependency(
         &mut self,
-        requirement: &pep508_rs::Requirement,
+        (requirement, pixi_req): (&pep508_rs::Requirement, Option<&PixiPypiSpec>),
         platforms: &[Platform],
         feature_name: &FeatureName,
         editable: Option<bool>,
         overwrite_behavior: DependencyOverwriteBehavior,
-        location: &Option<PypiDependencyLocation>,
+        location: Option<&PypiDependencyLocation>,
     ) -> miette::Result<bool> {
         let mut any_added = false;
         for platform in to_options(platforms) {
@@ -496,6 +496,7 @@ impl WorkspaceManifestMut<'_> {
                 Ok(true) => {
                     self.document.add_pypi_dependency(
                         requirement,
+                        pixi_req,
                         platform,
                         feature_name,
                         editable,
@@ -889,12 +890,12 @@ start = "python -m flask run --port=5050"
         let requirement = pep508_rs::Requirement::from_str("numpy>=3.12").unwrap();
         manifest
             .add_pep508_dependency(
-                &requirement,
+                (&requirement, None),
                 &[],
                 &FeatureName::DEFAULT,
                 None,
                 DependencyOverwriteBehavior::Overwrite,
-                &None,
+                None,
             )
             .unwrap();
 
@@ -916,12 +917,12 @@ start = "python -m flask run --port=5050"
         let requirement = pep508_rs::Requirement::from_str("pytest>=3.12").unwrap();
         manifest
             .add_pep508_dependency(
-                &requirement,
+                (&requirement, None),
                 &[],
                 &FeatureName::from("test"),
                 None,
                 DependencyOverwriteBehavior::Overwrite,
-                &None,
+                None,
             )
             .unwrap();
         assert!(
diff --git a/crates/pixi_pypi_spec/src/pep508.rs b/crates/pixi_pypi_spec/src/pep508.rs
index c454bf347a..8bf8df1f01 100644
--- a/crates/pixi_pypi_spec/src/pep508.rs
+++ b/crates/pixi_pypi_spec/src/pep508.rs
@@ -109,3 +109,26 @@ impl TryFrom<pep508_rs::Requirement> for PixiPypiSpec {
         Ok(converted)
     }
 }
+
+impl TryFrom<(pep508_rs::Requirement, Option<PixiPypiSpec>)> for PixiPypiSpec {
+    type Error = Pep508ToPyPiRequirementError;
+    fn try_from(
+        (req, pixi_req): (pep508_rs::Requirement, Option<PixiPypiSpec>),
+    ) -> Result<Self, Self::Error> {
+        let mut converted = req.try_into()?;
+
+        if let Some(pixi_req) = pixi_req {
+            match (&mut converted, &pixi_req) {
+                (
+                    PixiPypiSpec::Version {
+                        index: conv_index, ..
+                    },
+                    PixiPypiSpec::Version { index, .. },
+                ) => *conv_index = index.clone(),
+                _ => {}
+            }
+        }
+
+        Ok(converted)
+    }
+}
diff --git a/src/cli/add.rs b/src/cli/add.rs
index 6e132a67a4..5f4599409f 100644
--- a/src/cli/add.rs
+++ b/src/cli/add.rs
@@ -157,12 +157,12 @@ pub async fn execute(args: Args) -> miette::Result<()> {
             {
                 Some(vcs_reqs) => vcs_reqs
                     .into_iter()
-                    .map(|(name, req)| (name, (req, None)))
+                    .map(|(name, req)| (name, (req, None, None)))
                     .collect(),
                 None => dependency_config
                     .pypi_deps(workspace.workspace())?
                     .into_iter()
-                    .map(|(name, req)| (name, (req, None)))
+                    .map(|(name, req)| (name, (req, None, None)))
                     .collect(),
             };
 
diff --git a/src/cli/init.rs b/src/cli/init.rs
index 26b976f557..c1fb77ee8c 100644
--- a/src/cli/init.rs
+++ b/src/cli/init.rs
@@ -339,13 +339,13 @@ pub async fn execute(args: Args) -> miette::Result<()> {
         }
         for requirement in pypi_deps {
             workspace.manifest().add_pep508_dependency(
-                &requirement,
+                (&requirement, None),
                 // No platforms required as you can't define them in the yaml
                 &[],
                 &FeatureName::default(),
                 None,
                 DependencyOverwriteBehavior::Overwrite,
-                &None,
+                None,
             )?;
         }
         let workspace = workspace.save().await.into_diagnostic()?;
diff --git a/src/cli/upgrade.rs b/src/cli/upgrade.rs
index d1117f139b..96e21adb5b 100644
--- a/src/cli/upgrade.rs
+++ b/src/cli/upgrade.rs
@@ -142,7 +142,7 @@ pub async fn execute(args: Args) -> miette::Result<()> {
 /// This function processes the dependencies and PyPi dependencies specified in
 /// the feature, filters them based on the provided arguments, and returns the
 /// resulting match specifications and PyPi dependencies.
-fn parse_specs(
+pub fn parse_specs(
     feature: &pixi_manifest::Feature,
     args: &Args,
     workspace: &WorkspaceMut,
@@ -261,17 +261,18 @@ fn parse_specs(
             _ => false,
         })
         // Only upgrade version specs
-        .filter_map(|(name, req)| match req {
+        .filter_map(|(name, req)| match &req {
             PixiPypiSpec::Version { extras, .. } => Some((
                 name.clone(),
                 Requirement {
                     name: name.as_normalized().clone(),
-                    extras,
+                    extras: extras.clone(),
                     // TODO: Add marker support here to avoid overwriting existing markers
                     marker: MarkerTree::default(),
                     origin: None,
                     version_or_url: None,
                 },
+                req,
             )),
             PixiPypiSpec::RawVersion(_) => Some((
                 name.clone(),
@@ -282,16 +283,17 @@ fn parse_specs(
                     origin: None,
                     version_or_url: None,
                 },
+                req,
             )),
             _ => None,
         })
-        .map(|(name, req)| {
+        .map(|(name, req, pixi_req)| {
             let location = workspace.document().pypi_dependency_location(
                 &name,
                 None, // TODO: add support for platforms
                 &args.specs.feature,
             );
-            (name, (req, location))
+            (name, (req, Some(pixi_req), location))
         })
         .collect();
 
diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs
index 0bdf07f73f..bc2b093308 100644
--- a/src/workspace/mod.rs
+++ b/src/workspace/mod.rs
@@ -40,7 +40,7 @@ use pixi_manifest::{
     HasWorkspaceManifest, LoadManifestsError, ManifestProvenance, Manifests, PackageManifest,
     SpecType, WithProvenance, WithWarnings, WorkspaceManifest,
 };
-use pixi_pypi_spec::PypiPackageName;
+use pixi_pypi_spec::{PixiPypiSpec, PypiPackageName};
 use pixi_spec::SourceSpec;
 use pixi_utils::reqwest::build_reqwest_clients;
 use pypi_mapping::{ChannelName, CustomMapping, MappingLocation, MappingSource};
@@ -180,7 +180,11 @@ impl Debug for Workspace {
 
 pub type PypiDeps = indexmap::IndexMap<
     PypiPackageName,
-    (Requirement, Option<pixi_manifest::PypiDependencyLocation>),
+    (
+        Requirement,
+        Option<PixiPypiSpec>,
+        Option<pixi_manifest::PypiDependencyLocation>,
+    ),
 >;
 
 pub type MatchSpecs = indexmap::IndexMap<PackageName, (MatchSpec, SpecType)>;
diff --git a/src/workspace/workspace_mut.rs b/src/workspace/workspace_mut.rs
index cfc9e699e3..93a4e9467c 100644
--- a/src/workspace/workspace_mut.rs
+++ b/src/workspace/workspace_mut.rs
@@ -27,7 +27,7 @@ use pixi_manifest::{
     ManifestDocument, ManifestKind, PypiDependencyLocation, SpecType, TomlError, WorkspaceManifest,
     WorkspaceManifestMut, toml::TomlDocument, utils::WithSourceCode,
 };
-use pixi_pypi_spec::PypiPackageName;
+use pixi_pypi_spec::{PixiPypiSpec, PypiPackageName};
 use pixi_spec::PixiSpec;
 use rattler_conda_types::{NamelessMatchSpec, PackageName, Platform, Version};
 use rattler_lock::LockFile;
@@ -279,18 +279,19 @@ impl WorkspaceMut {
             )?;
         }
 
-        for (name, (spec, location)) in pypi_deps {
+        for (name, (spec, pixi_spec, location)) in pypi_deps {
             let added = self.manifest().add_pep508_dependency(
-                &spec,
+                (&spec, pixi_spec.as_ref()),
                 platforms,
                 feature_name,
                 Some(editable),
                 DependencyOverwriteBehavior::Overwrite,
-                &location,
+                location.as_ref(),
             )?;
             if added {
                 if spec.version_or_url.is_none() {
-                    pypi_specs_to_add_constraints_for.insert(name.clone(), (spec, location));
+                    pypi_specs_to_add_constraints_for
+                        .insert(name.clone(), (spec, pixi_spec, location));
                 }
                 pypi_packages.insert(name.as_normalized().clone());
             }
@@ -516,7 +517,11 @@ impl WorkspaceMut {
         updated_lock_file: &LockFile,
         pypi_specs_to_add_constraints_for: IndexMap<
             PypiPackageName,
-            (Requirement, Option<PypiDependencyLocation>),
+            (
+                Requirement,
+                Option<PixiPypiSpec>,
+                Option<PypiDependencyLocation>,
+            ),
         >,
         affect_environment_and_platforms: Vec<(String, Platform)>,
         feature_name: &FeatureName,
@@ -547,7 +552,7 @@ impl WorkspaceMut {
             .unwrap_or_default();
 
         // Determine the versions of the packages in the lock-file
-        for (name, (req, location)) in pypi_specs_to_add_constraints_for {
+        for (name, (req, pixi_req, location)) in pypi_specs_to_add_constraints_for {
             let version_constraint = pinning_strategy.determine_version_constraint(
                 pypi_records
                     .iter()
@@ -570,13 +575,14 @@ impl WorkspaceMut {
                     version_or_url: Some(VersionSpecifier(version_spec)),
                     ..req
                 };
+
                 self.manifest().add_pep508_dependency(
-                    &req,
+                    (&req, pixi_req.as_ref()),
                     platforms,
                     feature_name,
                     Some(editable),
                     DependencyOverwriteBehavior::Overwrite,
-                    &location,
+                    location.as_ref(),
                 )?;
             }
         }
diff --git a/tests/integration_rust/main.rs b/tests/integration_rust/main.rs
index e36cabe4da..c2098b89ed 100644
--- a/tests/integration_rust/main.rs
+++ b/tests/integration_rust/main.rs
@@ -9,3 +9,4 @@ mod solve_group_tests;
 mod task_tests;
 mod test_activation;
 mod update_tests;
+mod upgrade_tests;
diff --git a/tests/integration_rust/snapshots/integration_rust__upgrade_tests__pypi_dependency_index_preserved_on_upgrade.snap b/tests/integration_rust/snapshots/integration_rust__upgrade_tests__pypi_dependency_index_preserved_on_upgrade.snap
new file mode 100644
index 0000000000..af7eb15a16
--- /dev/null
+++ b/tests/integration_rust/snapshots/integration_rust__upgrade_tests__pypi_dependency_index_preserved_on_upgrade.snap
@@ -0,0 +1,14 @@
+---
+source: tests/integration_rust/upgrade_tests.rs
+expression: "fs_err::read_to_string(file_path).unwrap_or_default()"
+---
+[workspace]
+channels = ["conda-forge"]
+platforms = ["linux-64", "osx-arm64", "win-64"]
+exclude-newer = "2025-05-19"
+
+[pypi-dependencies]
+click = { version = ">=8.2.0, <9", index = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+
+[dependencies]
+python = ">=3.13.3,<3.14"
diff --git a/tests/integration_rust/upgrade_tests.rs b/tests/integration_rust/upgrade_tests.rs
new file mode 100644
index 0000000000..05b4cbd86a
--- /dev/null
+++ b/tests/integration_rust/upgrade_tests.rs
@@ -0,0 +1,74 @@
+use indexmap::IndexMap;
+use insta::assert_snapshot;
+use pixi::Workspace;
+use pixi::cli::upgrade::{Args, parse_specs};
+use std::io::Write;
+use std::path::Path;
+use tempfile::tempdir;
+
+// When the specific template is not in the file or the file does not exist.
+// Make the file and append the template to the file.
+fn create_or_append_file(path: &Path, template: &str) -> std::io::Result<()> {
+    let file = fs_err::read_to_string(path).unwrap_or_default();
+
+    if !file.contains(template) {
+        std::fs::OpenOptions::new()
+            .append(true)
+            .create(true)
+            .open(path)?
+            .write_all(template.as_bytes())?;
+    }
+    Ok(())
+}
+
+// This test requires network connection and takes around 40s to
+// complete on my machine.
+#[cfg_attr(not(feature = "slow_integration_tests"), ignore)]
+#[tokio::test]
+async fn pypi_dependency_index_preserved_on_upgrade() {
+    let dir = tempdir().unwrap();
+    let file_path = dir.path().join("pixi.toml");
+    let file_contents = r#"
+[workspace]
+channels = ["conda-forge"]
+platforms = ["linux-64", "osx-arm64", "win-64"]
+exclude-newer = "2025-05-19"
+
+[pypi-dependencies]
+click = { version = "==8.2.0", index = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+
+[dependencies]
+python = "==3.13.3""#;
+    create_or_append_file(&file_path, file_contents).unwrap();
+
+    let mut args = Args::default();
+    args.workspace_config.manifest_path = Some(file_path.clone());
+
+    let workspace = Workspace::from_path(&file_path).unwrap();
+
+    let workspace_value = workspace.workspace.value.clone();
+    let feature = workspace_value.feature(&args.specs.feature).unwrap();
+
+    let mut workspace = workspace.modify().unwrap();
+
+    let (match_specs, pypi_deps) = parse_specs(feature, &args, &workspace).unwrap();
+
+    let _ = workspace
+        .update_dependencies(
+            match_specs,
+            pypi_deps,
+            IndexMap::default(),
+            &args.prefix_update_config,
+            &args.lock_file_update_config,
+            &args.specs.feature,
+            &[],
+            true,
+            args.dry_run,
+        )
+        .await
+        .unwrap();
+
+    workspace.save().await.unwrap();
+
+    assert_snapshot!(fs_err::read_to_string(file_path).unwrap_or_default());
+}


Reviews:
user: login: tdejager
body: Good work, don't know if we need to cover more cases in the part you marked as `TODO`. Mainly have a single comment on the test, which in and of itself is pretty good :)
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T08:45:47Z
user: login: remimimimimi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-18T21:28:06Z
user: login: remimimimimi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-18T21:31:41Z
user: login: tdejager
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T06:27:30Z
user: login: tdejager
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T10:14:58Z

Review comments:
diff_hunk: @@ -344,3 +346,82 @@ fn ensure_package_exists(package_name: &str, available_packages: &[String]) -> m
     }
     .into())
 }
+
+#[cfg(test)]
+mod tests {
+    use crate::Workspace;
+    use insta::assert_snapshot;
+    use std::io::Write;
+    use std::path::Path;
+    use tempfile::tempdir;
+
+    use super::*;
+
+    // When the specific template is not in the file or the file does not exist.
+    // Make the file and append the template to the file.
+    fn create_or_append_file(path: &Path, template: &str) -> std::io::Result<()> {
+        let file = fs_err::read_to_string(path).unwrap_or_default();
+
+        if !file.contains(template) {
+            std::fs::OpenOptions::new()
+                .append(true)
+                .create(true)
+                .open(path)?
+                .write_all(template.as_bytes())?;
+        }
+        Ok(())
+    }
+
+    // This test requires network connection, takes a lot of time to
+    // complete, and torch version can change over time, so we ignore
+    // it by default.
+    #[ignore]
path: src/cli/upgrade.rs
user: login: tdejager
body: 1. Do you think we could move this thing into integration tests (if possible), mark it as extra-slow. 
2. It might be benificial to take a slimmer dependency than torch:
`https://pypi.tuna.tsinghua.edu.cn/simple/` is an example public registry, if you take something like `click` I guess the same issue should occur as the linked one.
3. This option might also be relevant, to only take versions into account at time of test publish: https://pixi.sh/dev/reference/pixi_manifest/#exclude-newer-optional

wdyt?
created_at: 2025-05-16T08:42:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -344,3 +346,82 @@ fn ensure_package_exists(package_name: &str, available_packages: &[String]) -> m
     }
     .into())
 }
+
+#[cfg(test)]
+mod tests {
+    use crate::Workspace;
+    use insta::assert_snapshot;
+    use std::io::Write;
+    use std::path::Path;
+    use tempfile::tempdir;
+
+    use super::*;
+
+    // When the specific template is not in the file or the file does not exist.
+    // Make the file and append the template to the file.
+    fn create_or_append_file(path: &Path, template: &str) -> std::io::Result<()> {
+        let file = fs_err::read_to_string(path).unwrap_or_default();
+
+        if !file.contains(template) {
+            std::fs::OpenOptions::new()
+                .append(true)
+                .create(true)
+                .open(path)?
+                .write_all(template.as_bytes())?;
+        }
+        Ok(())
+    }
+
+    // This test requires network connection, takes a lot of time to
+    // complete, and torch version can change over time, so we ignore
+    // it by default.
+    #[ignore]
path: src/cli/upgrade.rs
user: login: remimimimimi
body: Thanks for suggestions! I applied all of them. Also, I tweaked types from `&Option<T>` to `Option<&T>`.
created_at: 2025-05-18T21:28:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -344,3 +346,82 @@ fn ensure_package_exists(package_name: &str, available_packages: &[String]) -> m
     }
     .into())
 }
+
+#[cfg(test)]
+mod tests {
+    use crate::Workspace;
+    use insta::assert_snapshot;
+    use std::io::Write;
+    use std::path::Path;
+    use tempfile::tempdir;
+
+    use super::*;
+
+    // When the specific template is not in the file or the file does not exist.
+    // Make the file and append the template to the file.
+    fn create_or_append_file(path: &Path, template: &str) -> std::io::Result<()> {
+        let file = fs_err::read_to_string(path).unwrap_or_default();
+
+        if !file.contains(template) {
+            std::fs::OpenOptions::new()
+                .append(true)
+                .create(true)
+                .open(path)?
+                .write_all(template.as_bytes())?;
+        }
+        Ok(())
+    }
+
+    // This test requires network connection, takes a lot of time to
+    // complete, and torch version can change over time, so we ignore
+    // it by default.
+    #[ignore]
path: src/cli/upgrade.rs
user: login: remimimimimi
body: I was thinking about introducing locally running registry server specifically for this test, but it seems not worth the hassle, so only changed package and index. 
created_at: 2025-05-18T21:31:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -344,3 +346,82 @@ fn ensure_package_exists(package_name: &str, available_packages: &[String]) -> m
     }
     .into())
 }
+
+#[cfg(test)]
+mod tests {
+    use crate::Workspace;
+    use insta::assert_snapshot;
+    use std::io::Write;
+    use std::path::Path;
+    use tempfile::tempdir;
+
+    use super::*;
+
+    // When the specific template is not in the file or the file does not exist.
+    // Make the file and append the template to the file.
+    fn create_or_append_file(path: &Path, template: &str) -> std::io::Result<()> {
+        let file = fs_err::read_to_string(path).unwrap_or_default();
+
+        if !file.contains(template) {
+            std::fs::OpenOptions::new()
+                .append(true)
+                .create(true)
+                .open(path)?
+                .write_all(template.as_bytes())?;
+        }
+        Ok(())
+    }
+
+    // This test requires network connection, takes a lot of time to
+    // complete, and torch version can change over time, so we ignore
+    // it by default.
+    #[ignore]
path: src/cli/upgrade.rs
user: login: tdejager
body: I've been in that position at least 15 times in the last year :), I know the feeling. Would be great to do so at some point, but I am in agreement with what you changed.
created_at: 2025-05-19T06:27:30Z
author_association: CONTRIBUTOR

PR comments:
user: login: tdejager
created_at: 2025-05-19T06:29:08Z
body: @remimimimimi do you think this is ready to be undrafted, not sure that is a word :P Or do you want to tackle the remaining `TODO` comment you added?
author_association: CONTRIBUTOR
user: login: remimimimimi
created_at: 2025-05-19T06:57:52Z
body: > @remimimimimi do you think this is ready to be undrafted, not sure that is a word :P Or do you want to tackle the remaining `TODO` comment you added?

I only wanted to fix CI. Sure, it can be undrafted :)
author_association: CONTRIBUTOR