Skip to content

Commit e41ec12

Browse files
authored
Option to resolve at a fixed timestamp with pip-compile --exclude-newer YYYY-MM-DD (#434)
This works by filtering out files with a more recent upload time, so if the index you use does not provide upload times, the results might be inaccurate. pypi provides upload times for all files. This is, the field is non-nullable in the warehouse schema, but the simple API PEP does not know this field. If you have only pypi dependencies, this means deterministic, reproducible(!) resolution. We could try doing the same for git repos but it doesn't seem worth the effort, i'd recommend pinning commits since git histories are arbitrarily malleable and also if you care about reproducibility and such you such not use git dependencies but a custom index. Timestamps are given either as RFC 3339 timestamps such as `2006-12-02T02:07:43Z` or as UTC dates in the same format such as `2006-12-02`. Dates are interpreted as including this day, i.e. until midnight UTC that day. Date only is required to make this ergonomic and midnight seems like an ergonomic choice. In action for `pandas`: ```console $ target/debug/puffin pip-compile --exclude-newer 2023-11-16 target/pandas.in Resolved 6 packages in 679ms # This file was autogenerated by Puffin v0.0.1 via the following command: # target/debug/puffin pip-compile --exclude-newer 2023-11-16 target/pandas.in numpy==1.26.2 # via pandas pandas==2.1.3 python-dateutil==2.8.2 # via pandas pytz==2023.3.post1 # via pandas six==1.16.0 # via python-dateutil tzdata==2023.3 # via pandas $ target/debug/puffin pip-compile --exclude-newer 2022-11-16 target/pandas.in Resolved 5 packages in 655ms # This file was autogenerated by Puffin v0.0.1 via the following command: # target/debug/puffin pip-compile --exclude-newer 2022-11-16 target/pandas.in numpy==1.23.4 # via pandas pandas==1.5.1 python-dateutil==2.8.2 # via pandas pytz==2022.6 # via pandas six==1.16.0 # via python-dateutil $ target/debug/puffin pip-compile --exclude-newer 2021-11-16 target/pandas.in Resolved 5 packages in 594ms # This file was autogenerated by Puffin v0.0.1 via the following command: # target/debug/puffin pip-compile --exclude-newer 2021-11-16 target/pandas.in numpy==1.21.4 # via pandas pandas==1.3.4 python-dateutil==2.8.2 # via pandas pytz==2021.3 # via pandas six==1.16.0 # via python-dateutil ```
1 parent 0d455eb commit e41ec12

File tree

14 files changed

+218
-38
lines changed

14 files changed

+218
-38
lines changed

Cargo.lock

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ camino = { version = "1.1.6", features = ["serde1"] }
2424
clap = { version = "4.4.7" }
2525
colored = { version = "2.0.4" }
2626
configparser = { version = "3.0.2" }
27+
chrono = { version = "0.4.31" }
2728
csv = { version = "1.3.0" }
2829
data-encoding = { version = "2.4.0" }
2930
directories = { version = "5.0.1" }

crates/puffin-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ anstream = { workspace = true }
3636
anyhow = { workspace = true }
3737
bitflags = { workspace = true }
3838
cacache = { workspace = true }
39+
chrono = { workspace = true }
3940
clap = { workspace = true, features = ["derive"] }
4041
colored = { workspace = true }
4142
directories = { workspace = true }

crates/puffin-cli/src/commands/pip_compile.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::str::FromStr;
77
use std::{env, fs};
88

99
use anyhow::{anyhow, Result};
10+
use chrono::{DateTime, Utc};
1011
use colored::Colorize;
1112
use itertools::Itertools;
1213
use tracing::debug;
@@ -42,6 +43,7 @@ pub(crate) async fn pip_compile(
4243
index_urls: Option<IndexUrls>,
4344
no_build: bool,
4445
python_version: Option<PythonVersion>,
46+
exclude_newer: Option<DateTime<Utc>>,
4547
cache: &Path,
4648
mut printer: Printer,
4749
) -> Result<ExitStatus> {
@@ -102,6 +104,7 @@ pub(crate) async fn pip_compile(
102104
resolution_mode,
103105
prerelease_mode,
104106
project,
107+
exclude_newer,
105108
);
106109

107110
// Detect the current Python interpreter.

crates/puffin-cli/src/main.rs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
use std::borrow::Cow;
22
use std::path::{Path, PathBuf};
33
use std::process::ExitCode;
4+
use std::str::FromStr;
45

56
use anyhow::Result;
7+
use chrono::{DateTime, Days, NaiveDate, NaiveTime, Utc};
68
use clap::{Args, Parser, Subcommand};
79
use colored::Colorize;
810
use directories::ProjectDirs;
@@ -85,6 +87,24 @@ enum Commands {
8587
Remove(RemoveArgs),
8688
}
8789

90+
/// Clap parser for the union of date and datetime
91+
fn date_or_datetime(input: &str) -> Result<DateTime<Utc>, String> {
92+
let date_err = match NaiveDate::from_str(input) {
93+
Ok(date) => {
94+
// Midnight that day is 00:00:00 the next day
95+
return Ok((date + Days::new(1)).and_time(NaiveTime::MIN).and_utc());
96+
}
97+
Err(err) => err,
98+
};
99+
let datetime_err = match DateTime::parse_from_rfc3339(input) {
100+
Ok(datetime) => return Ok(datetime.with_timezone(&Utc)),
101+
Err(err) => err,
102+
};
103+
Err(format!(
104+
"Neither a valid date ({date_err}) not a valid datetime ({datetime_err})"
105+
))
106+
}
107+
88108
#[derive(Args)]
89109
#[allow(clippy::struct_excessive_bools)]
90110
struct PipCompileArgs {
@@ -130,14 +150,28 @@ struct PipCompileArgs {
130150
#[clap(long)]
131151
upgrade: bool,
132152

133-
/// Don't build source distributions. This means resolving will not run arbitrary code. The
134-
/// cached wheels of already built source distributions will be reused.
153+
/// Don't build source distributions.
154+
///
155+
/// This means resolving will not run arbitrary code. The cached wheels of already built source
156+
/// distributions will be reused.
135157
#[clap(long)]
136158
no_build: bool,
137159

138160
/// The minimum Python version that should be supported.
139161
#[arg(long, short, value_enum)]
140162
python_version: Option<PythonVersion>,
163+
164+
/// Try to resolve at a past time.
165+
///
166+
/// This works by filtering out files with a more recent upload time, so if the index you use
167+
/// does not provide upload times, the results might be inaccurate. pypi provides upload times
168+
/// for all files.
169+
///
170+
/// Timestamps are given either as RFC 3339 timestamps such as `2006-12-02T02:07:43Z` or as
171+
/// UTC dates in the same format such as `2006-12-02`. Dates are interpreted as including this
172+
/// day, i.e. until midnight UTC that day.
173+
#[arg(long, value_parser = date_or_datetime)]
174+
exclude_newer: Option<DateTime<Utc>>,
141175
}
142176

143177
#[derive(Args)]
@@ -272,6 +306,7 @@ async fn inner() -> Result<ExitStatus> {
272306
index_urls,
273307
args.no_build,
274308
args.python_version,
309+
args.exclude_newer,
275310
&cache_dir,
276311
printer,
277312
)

crates/puffin-cli/tests/pip_compile.rs

Lines changed: 116 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,31 @@ fn make_venv_py312(temp_dir: &TempDir, cache_dir: &TempDir) -> PathBuf {
3131
venv.to_path_buf()
3232
}
3333

34+
/// Resolve a specific version of Django from a `requirements.in` file.
35+
#[test]
36+
fn compile_requirements_in() -> Result<()> {
37+
let temp_dir = TempDir::new()?;
38+
let cache_dir = TempDir::new()?;
39+
let venv = make_venv_py312(&temp_dir, &cache_dir);
40+
41+
let requirements_in = temp_dir.child("requirements.in");
42+
requirements_in.write_str("django==5.0b1")?;
43+
44+
insta::with_settings!({
45+
filters => INSTA_FILTERS.to_vec()
46+
}, {
47+
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
48+
.arg("pip-compile")
49+
.arg("requirements.in")
50+
.arg("--cache-dir")
51+
.arg(cache_dir.path())
52+
.env("VIRTUAL_ENV", venv.as_os_str())
53+
.current_dir(&temp_dir));
54+
});
55+
56+
Ok(())
57+
}
58+
3459
#[test]
3560
fn missing_requirements_in() -> Result<()> {
3661
let temp_dir = TempDir::new()?;
@@ -68,31 +93,6 @@ fn missing_venv() -> Result<()> {
6893
Ok(())
6994
}
7095

71-
/// Resolve a specific version of Django from a `requirements.in` file.
72-
#[test]
73-
fn compile_requirements_in() -> Result<()> {
74-
let temp_dir = TempDir::new()?;
75-
let cache_dir = TempDir::new()?;
76-
let venv = make_venv_py312(&temp_dir, &cache_dir);
77-
78-
let requirements_in = temp_dir.child("requirements.in");
79-
requirements_in.write_str("django==5.0b1")?;
80-
81-
insta::with_settings!({
82-
filters => INSTA_FILTERS.to_vec()
83-
}, {
84-
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
85-
.arg("pip-compile")
86-
.arg("requirements.in")
87-
.arg("--cache-dir")
88-
.arg(cache_dir.path())
89-
.env("VIRTUAL_ENV", venv.as_os_str())
90-
.current_dir(&temp_dir));
91-
});
92-
93-
Ok(())
94-
}
95-
9696
/// Resolve a specific version of Django from a `pyproject.toml` file.
9797
#[test]
9898
fn compile_pyproject_toml() -> Result<()> {
@@ -1206,11 +1206,7 @@ dependencies = ["django==5.0b1", "django==5.0a1"]
12061206
)?;
12071207

12081208
insta::with_settings!({
1209-
filters => vec![
1210-
(r"\d(ms|s)", "[TIME]"),
1211-
(r"# .* pip-compile", "# [BIN_PATH] pip-compile"),
1212-
(r"--cache-dir .*", "--cache-dir [CACHE_DIR]"),
1213-
]
1209+
filters => INSTA_FILTERS.to_vec()
12141210
}, {
12151211
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
12161212
.arg("pip-compile")
@@ -1244,11 +1240,7 @@ dependencies = ["django==300.1.4"]
12441240
)?;
12451241

12461242
insta::with_settings!({
1247-
filters => vec![
1248-
(r"\d(ms|s)", "[TIME]"),
1249-
(r"# .* pip-compile", "# [BIN_PATH] pip-compile"),
1250-
(r"--cache-dir .*", "--cache-dir [CACHE_DIR]"),
1251-
]
1243+
filters => INSTA_FILTERS.to_vec()
12521244
}, {
12531245
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
12541246
.arg("pip-compile")
@@ -1261,3 +1253,92 @@ dependencies = ["django==300.1.4"]
12611253

12621254
Ok(())
12631255
}
1256+
1257+
/// Resolve at a specific time in the past
1258+
#[test]
1259+
fn compile_exclude_newer() -> Result<()> {
1260+
let temp_dir = TempDir::new()?;
1261+
let cache_dir = TempDir::new()?;
1262+
let venv = make_venv_py312(&temp_dir, &cache_dir);
1263+
1264+
let requirements_in = temp_dir.child("requirements.in");
1265+
requirements_in.write_str("tqdm")?;
1266+
1267+
insta::with_settings!({
1268+
filters => INSTA_FILTERS.to_vec()
1269+
}, {
1270+
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
1271+
.arg("pip-compile")
1272+
.arg("requirements.in")
1273+
.arg("--exclude-newer")
1274+
// 4.64.0: 2022-04-04T01:48:46.194635Z1
1275+
// 4.64.1: 2022-09-03T11:10:27.148080Z
1276+
.arg("2022-04-04T12:00:00Z")
1277+
.arg("--cache-dir")
1278+
.arg(cache_dir.path())
1279+
.env("VIRTUAL_ENV", venv.as_os_str())
1280+
.current_dir(&temp_dir), @r###"
1281+
success: true
1282+
exit_code: 0
1283+
----- stdout -----
1284+
# This file was autogenerated by Puffin v0.0.1 via the following command:
1285+
# puffin pip-compile requirements.in --exclude-newer 2022-04-04T12:00:00Z --cache-dir [CACHE_DIR]
1286+
tqdm==4.64.0
1287+
1288+
----- stderr -----
1289+
Resolved 1 package in [TIME]
1290+
"###);
1291+
});
1292+
1293+
insta::with_settings!({
1294+
filters => INSTA_FILTERS.to_vec()
1295+
}, {
1296+
// Use a date as input instead.
1297+
// We interpret a date as including this day
1298+
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
1299+
.arg("pip-compile")
1300+
.arg("requirements.in")
1301+
.arg("--exclude-newer")
1302+
.arg("2022-04-04")
1303+
.arg("--cache-dir")
1304+
.arg(cache_dir.path())
1305+
.env("VIRTUAL_ENV", venv.as_os_str())
1306+
.current_dir(&temp_dir), @r###"
1307+
success: true
1308+
exit_code: 0
1309+
----- stdout -----
1310+
# This file was autogenerated by Puffin v0.0.1 via the following command:
1311+
# puffin pip-compile requirements.in --exclude-newer 2022-04-04 --cache-dir [CACHE_DIR]
1312+
tqdm==4.64.0
1313+
1314+
----- stderr -----
1315+
Resolved 1 package in [TIME]
1316+
"###);
1317+
});
1318+
1319+
insta::with_settings!({
1320+
filters => INSTA_FILTERS.to_vec()
1321+
}, {
1322+
// Check the error message for invalid datetime
1323+
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
1324+
.arg("pip-compile")
1325+
.arg("requirements.in")
1326+
.arg("--exclude-newer")
1327+
.arg("2022-04-04+02:00")
1328+
.arg("--cache-dir")
1329+
.arg(cache_dir.path())
1330+
.env("VIRTUAL_ENV", venv.as_os_str())
1331+
.current_dir(&temp_dir), @r###"
1332+
success: false
1333+
exit_code: 2
1334+
----- stdout -----
1335+
1336+
----- stderr -----
1337+
error: invalid value '2022-04-04+02:00' for '--exclude-newer <EXCLUDE_NEWER>': Neither a valid date (trailing input) not a valid datetime (input contains invalid characters)
1338+
1339+
For more information, try '--help'.
1340+
"###);
1341+
});
1342+
1343+
Ok(())
1344+
}

crates/puffin-dispatch/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,16 @@ impl BuildContext for BuildDispatch {
7979
self.interpreter_info.simple_version(),
8080
)?;
8181
let resolver = Resolver::new(
82+
// TODO(konstin): Split settings (for all resolutions) and inputs (only for this
83+
// resolution) and attach the former to Self.
8284
Manifest::new(
8385
requirements.to_vec(),
8486
Vec::default(),
8587
Vec::default(),
8688
ResolutionMode::default(),
8789
PreReleaseMode::default(),
8890
None, // TODO(zanieb): We may want to provide a project name here
91+
None,
8992
),
9093
self.interpreter_info.markers(),
9194
&tags,

crates/puffin-resolver/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pypi-types = { path = "../pypi-types" }
2929
anyhow = { workspace = true }
3030
bitflags = { workspace = true }
3131
clap = { workspace = true, features = ["derive"], optional = true }
32+
chrono = { workspace = true }
3233
colored = { workspace = true }
3334
fs-err = { workspace = true, features = ["tokio"] }
3435
futures = { workspace = true }

crates/puffin-resolver/src/manifest.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use chrono::{DateTime, Utc};
12
use pep508_rs::Requirement;
23
use puffin_normalize::PackageName;
34

@@ -13,6 +14,7 @@ pub struct Manifest {
1314
pub(crate) resolution_mode: ResolutionMode,
1415
pub(crate) prerelease_mode: PreReleaseMode,
1516
pub(crate) project: Option<PackageName>,
17+
pub(crate) exclude_newer: Option<DateTime<Utc>>,
1618
}
1719

1820
impl Manifest {
@@ -23,6 +25,7 @@ impl Manifest {
2325
resolution_mode: ResolutionMode,
2426
prerelease_mode: PreReleaseMode,
2527
project: Option<PackageName>,
28+
exclude_newer: Option<DateTime<Utc>>,
2629
) -> Self {
2730
Self {
2831
requirements,
@@ -31,6 +34,7 @@ impl Manifest {
3134
resolution_mode,
3235
prerelease_mode,
3336
project,
37+
exclude_newer,
3438
}
3539
}
3640
}

0 commit comments

Comments
 (0)