Skip to content

Commit 22bf0c6

Browse files
authored
feat: Implement a --use-cache flag (#104)
1 parent 731dab6 commit 22bf0c6

File tree

6 files changed

+125
-8
lines changed

6 files changed

+125
-8
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
name = "pixi-pack"
33
description = "A command line tool to pack and unpack conda environments for easy sharing"
4-
version = "0.3.2"
4+
version = "0.3.3"
55
edition = "2021"
66

77
[features]

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,23 @@ pixi-pack pack --inject local-package-1.0.0-hbefa133_0.conda --manifest-pack pix
165165
This can be particularly useful if you build the project itself and want to include the built package in the environment but still want to use `pixi.lock` from the project.
166166
Before creating the pack, `pixi-pack` will ensure that the injected packages' dependencies and constraints are compatible with the packages in the environment.
167167

168+
### Cache downloaded packages
169+
170+
You can cache downloaded packages to speed up subsequent pack operations by using the `--use-cache` flag:
171+
172+
```bash
173+
pixi-pack pack --use-cache ~/.pixi-pack/cache
174+
```
175+
176+
This will store all downloaded packages in the specified directory and reuse them in future pack operations. The cache follows the same structure as conda channels, organizing packages by platform subdirectories (e.g., linux-64, win-64, etc.).
177+
178+
Using a cache is particularly useful when:
179+
180+
- Creating multiple packs with overlapping dependencies
181+
- Working with large packages that take time to download
182+
- Operating in environments with limited bandwidth
183+
- Running CI/CD pipelines where package caching can significantly improve build times
184+
168185
### Unpacking without `pixi-pack`
169186

170187
If you don't have `pixi-pack` available on your target system, you can still install the environment if you have `conda` or `micromamba` available.

src/main.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ enum Commands {
5454
#[arg(short, long)]
5555
output_file: Option<PathBuf>,
5656

57+
/// Use a cache directory for downloaded packages
58+
#[arg(long)]
59+
use_cache: Option<PathBuf>,
60+
5761
/// Inject an additional conda package into the final prefix
5862
#[arg(short, long, num_args(0..))]
5963
inject: Vec<PathBuf>,
@@ -67,7 +71,6 @@ enum Commands {
6771
#[arg(long, default_value = "false")]
6872
create_executable: bool,
6973
},
70-
7174
/// Unpack a pixi environment
7275
Unpack {
7376
/// Where to unpack the environment.
@@ -126,6 +129,7 @@ async fn main() -> Result<()> {
126129
inject,
127130
ignore_pypi_errors,
128131
create_executable,
132+
use_cache,
129133
} => {
130134
let output_file =
131135
output_file.unwrap_or_else(|| default_output_file(platform, create_executable));
@@ -144,6 +148,7 @@ async fn main() -> Result<()> {
144148
injected_packages: inject,
145149
ignore_pypi_errors,
146150
create_executable,
151+
cache_dir: use_cache,
147152
};
148153
tracing::debug!("Running pack command with options: {:?}", options);
149154
pack(options).await?

src/pack.rs

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ pub struct PackOptions {
3939
pub output_file: PathBuf,
4040
pub manifest_path: PathBuf,
4141
pub metadata: PixiPackMetadata,
42+
pub cache_dir: Option<PathBuf>,
4243
pub injected_packages: Vec<PathBuf>,
4344
pub ignore_pypi_errors: bool,
4445
pub create_executable: bool,
4546
}
46-
4747
fn load_lockfile(manifest_path: &Path) -> Result<LockFile> {
4848
if !manifest_path.exists() {
4949
anyhow::bail!(
@@ -128,7 +128,7 @@ pub async fn pack(options: PackOptions) -> Result<()> {
128128
stream::iter(conda_packages_from_lockfile.iter())
129129
.map(Ok)
130130
.try_for_each_concurrent(50, |package| async {
131-
download_package(&client, package, &channel_dir).await?;
131+
download_package(&client, package, &channel_dir, options.cache_dir.as_deref()).await?;
132132
bar.pb.inc(1);
133133
Ok(())
134134
})
@@ -254,14 +254,29 @@ async fn download_package(
254254
client: &ClientWithMiddleware,
255255
package: &CondaBinaryData,
256256
output_dir: &Path,
257+
cache_dir: Option<&Path>,
257258
) -> Result<()> {
258259
let output_dir = output_dir.join(&package.package_record.subdir);
259260
create_dir_all(&output_dir)
260261
.await
261262
.map_err(|e| anyhow!("could not create download directory: {}", e))?;
262263

263264
let file_name = &package.file_name;
264-
let mut dest = File::create(output_dir.join(file_name)).await?;
265+
let output_path = output_dir.join(file_name);
266+
267+
// Check cache first if enabled
268+
if let Some(cache_dir) = cache_dir {
269+
let cache_path = cache_dir
270+
.join(&package.package_record.subdir)
271+
.join(file_name);
272+
if cache_path.exists() {
273+
tracing::debug!("Using cached package from {}", cache_path.display());
274+
fs::copy(&cache_path, &output_path).await?;
275+
return Ok(());
276+
}
277+
}
278+
279+
let mut dest = File::create(&output_path).await?;
265280

266281
tracing::debug!("Fetching package {}", package.location);
267282
let url = match &package.location {
@@ -281,9 +296,16 @@ async fn download_package(
281296
dest.write_all(&chunk).await?;
282297
}
283298

299+
// Save to cache if enabled
300+
if let Some(cache_dir) = cache_dir {
301+
let cache_subdir = cache_dir.join(&package.package_record.subdir);
302+
create_dir_all(&cache_subdir).await?;
303+
let cache_path = cache_subdir.join(file_name);
304+
fs::copy(&output_path, &cache_path).await?;
305+
}
306+
284307
Ok(())
285308
}
286-
287309
async fn archive_directory(
288310
input_dir: &Path,
289311
archive_target: &Path,

tests/integration_test.rs

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
#![allow(clippy::too_many_arguments)]
22

33
use sha2::{Digest, Sha256};
4+
use std::collections::HashMap;
45
use std::{fs, io};
56
use std::{path::PathBuf, process::Command};
7+
use walkdir::WalkDir;
68

79
use pixi_pack::{
810
unarchive, PackOptions, PixiPackMetadata, UnpackOptions, DEFAULT_PIXI_PACK_VERSION,
@@ -61,6 +63,7 @@ fn options(
6163
injected_packages: vec![],
6264
ignore_pypi_errors,
6365
create_executable,
66+
cache_dir: None,
6467
},
6568
unpack_options: UnpackOptions {
6669
pack_file,
@@ -71,7 +74,6 @@ fn options(
7174
output_dir,
7275
}
7376
}
74-
7577
#[fixture]
7678
fn required_fs_objects() -> Vec<&'static str> {
7779
let mut required_fs_objects = vec!["conda-meta/history", "include", "share"];
@@ -569,3 +571,74 @@ async fn test_manifest_path_dir(#[with(PathBuf::from("examples/simple-python"))]
569571
assert!(pack_result.is_ok(), "{:?}", pack_result);
570572
assert!(pack_file.is_file());
571573
}
574+
#[rstest]
575+
#[tokio::test]
576+
async fn test_package_caching(
577+
#[with(PathBuf::from("examples/simple-python/pixi.toml"))] options: Options,
578+
) {
579+
let temp_cache = tempdir().expect("Couldn't create a temp cache dir");
580+
let cache_dir = temp_cache.path().to_path_buf();
581+
582+
// First pack with cache - should download packages
583+
let mut pack_options = options.pack_options.clone();
584+
pack_options.cache_dir = Some(cache_dir.clone());
585+
let pack_result = pixi_pack::pack(pack_options).await;
586+
assert!(pack_result.is_ok(), "{:?}", pack_result);
587+
588+
// Get files and their modification times after first pack
589+
let mut initial_cache_files = HashMap::new();
590+
for entry in WalkDir::new(&cache_dir) {
591+
let entry = entry.unwrap();
592+
if entry.file_type().is_file() {
593+
let path = entry.path().to_path_buf();
594+
let modified_time = fs::metadata(&path).unwrap().modified().unwrap();
595+
initial_cache_files.insert(path, modified_time);
596+
}
597+
}
598+
assert!(
599+
!initial_cache_files.is_empty(),
600+
"Cache should contain downloaded files"
601+
);
602+
603+
// Calculate first pack's SHA256, reusing test_reproducible_shasum
604+
let first_sha256 = sha256_digest_bytes(&options.pack_options.output_file);
605+
insta::assert_snapshot!(
606+
format!("sha256-{}", options.pack_options.platform),
607+
&first_sha256
608+
);
609+
610+
// Small delay to ensure any new writes would have different timestamps
611+
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
612+
613+
// Second pack with same cache - should use cached packages
614+
let temp_dir2 = tempdir().expect("Couldn't create second temp dir");
615+
let mut pack_options2 = options.pack_options.clone();
616+
pack_options2.cache_dir = Some(cache_dir.clone());
617+
let output_file2 = temp_dir2.path().join("environment.tar");
618+
pack_options2.output_file = output_file2.clone();
619+
620+
let pack_result2 = pixi_pack::pack(pack_options2).await;
621+
assert!(pack_result2.is_ok(), "{:?}", pack_result2);
622+
623+
// Check that cache files weren't modified
624+
for (path, initial_mtime) in initial_cache_files {
625+
let current_mtime = fs::metadata(&path).unwrap().modified().unwrap();
626+
assert_eq!(
627+
initial_mtime,
628+
current_mtime,
629+
"Cache file {} was modified when it should have been reused",
630+
path.display()
631+
);
632+
}
633+
634+
// Verify second pack produces identical output
635+
let second_sha256 = sha256_digest_bytes(&output_file2);
636+
assert_eq!(
637+
first_sha256, second_sha256,
638+
"Pack outputs should be identical when using cache"
639+
);
640+
641+
// Both output files should exist and be valid
642+
assert!(options.pack_options.output_file.exists());
643+
assert!(output_file2.exists());
644+
}

0 commit comments

Comments
 (0)