Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
de9a60d
feat: Relative exclude-newer configuration
pavelzw Mar 27, 2026
a015739
wip
pavelzw Mar 28, 2026
d62576c
Merge branch 'main' into exclude-newer-duration
pavelzw Mar 28, 2026
c0556d2
WIP
pavelzw Mar 28, 2026
71b862b
wip
pavelzw Mar 28, 2026
dab5a0f
WIP
pavelzw Mar 29, 2026
ce294bd
wip
pavelzw Mar 29, 2026
49de170
wip
pavelzw Mar 29, 2026
898f9c1
wip
pavelzw Mar 29, 2026
4598819
wip
pavelzw Mar 29, 2026
5f00270
wip
pavelzw Mar 29, 2026
8b1cb9b
WIP
pavelzw Mar 31, 2026
abf4f8f
WIP
pavelzw Mar 31, 2026
d21021b
wip
pavelzw Mar 31, 2026
ea9976a
wip
pavelzw Mar 31, 2026
9072f07
WIP
pavelzw Mar 31, 2026
ad308a8
WIP
pavelzw Mar 31, 2026
887d956
WIP
pavelzw Mar 31, 2026
b7e13c8
WIP
pavelzw Mar 31, 2026
25faf95
WIP
pavelzw Apr 1, 2026
a1a90ac
WIP
pavelzw Apr 1, 2026
b1c7f71
integration tests
pavelzw Apr 1, 2026
82bcd2c
allow dates again
pavelzw Apr 1, 2026
449f5ae
only `channel`, no `url` or `path
pavelzw Apr 1, 2026
7e1e87b
wip
pavelzw Apr 1, 2026
69b2346
WIP
pavelzw Apr 1, 2026
20d1ebd
WIP
pavelzw Apr 1, 2026
9edf1de
WIP
pavelzw Apr 1, 2026
a53fa31
WIP
pavelzw Apr 1, 2026
3798bed
WIP
pavelzw Apr 1, 2026
84965c6
WIP
pavelzw Apr 1, 2026
c8c70ac
WIP
pavelzw Apr 1, 2026
ff6cf57
WIP
pavelzw Apr 1, 2026
f078fa9
WIP
pavelzw Apr 1, 2026
0a4bc7f
wip
pavelzw Apr 1, 2026
1aba6c7
WIP
pavelzw Apr 1, 2026
d8f5fae
WIP
pavelzw Apr 1, 2026
8fc9f83
fix
pavelzw Apr 2, 2026
660758d
fix
pavelzw Apr 2, 2026
6d2dfb1
fix
pavelzw Apr 2, 2026
aea4fb5
fix
pavelzw Apr 2, 2026
9c3aaf2
Merge branch 'main' into exclude-newer-duration
pavelzw Apr 2, 2026
2c1d30f
add relative exclude-newer test for pypi
pavelzw Apr 2, 2026
085aa36
wip
pavelzw Apr 7, 2026
3065518
wip
pavelzw Apr 7, 2026
584fe0d
Merge branch 'main' into exclude-newer-duration
pavelzw Apr 7, 2026
fdf6dc7
fix
pavelzw Apr 7, 2026
e0f0008
fix
pavelzw Apr 7, 2026
4da5c69
wip
pavelzw Apr 7, 2026
5ee09d5
fix
pavelzw Apr 7, 2026
c4a9be6
wip
pavelzw Apr 7, 2026
52ad564
wip
pavelzw Apr 7, 2026
009e08e
WIP
pavelzw Apr 7, 2026
1b52297
backend-name and backend-version
pavelzw Apr 7, 2026
3cb8235
Merge branch 'main' into exclude-newer-duration
pavelzw Apr 7, 2026
670c6d5
WIP
pavelzw Apr 7, 2026
c4a65e4
fix
pavelzw Apr 7, 2026
31c0b25
clippy
pavelzw Apr 7, 2026
a9431f8
fix backend identifier
pavelzw Apr 7, 2026
2b25b80
fix
pavelzw Apr 7, 2026
78e88d0
fix
pavelzw Apr 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 147 additions & 124 deletions Cargo.lock

Large diffs are not rendered by default.

36 changes: 18 additions & 18 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -220,36 +220,36 @@ zstd = { version = "0.13.3", default-features = false }
# Rattler crates
coalesced_map = "0.1.2"
file_url = "0.2.7"
rattler = { version = "0.40.0", default-features = false }
rattler_cache = { version = "0.6.14", default-features = false }
rattler_conda_types = { version = "0.44.0", default-features = false, features = [
rattler = { version = "0.40.3", default-features = false }
Comment thread
pavelzw marked this conversation as resolved.
rattler_cache = { version = "0.6.18", default-features = false }
rattler_conda_types = { version = "0.44.3", default-features = false, features = [
"rayon",
] }
rattler_digest = { version = "1.2.2", default-features = false }
rattler_lock = { version = "0.27.0", default-features = false }
rattler_menuinst = { version = "0.2.49", default-features = false }
rattler_networking = { version = "0.26.2", default-features = false, features = [
rattler_digest = { version = "1.2.3", default-features = false }
rattler_lock = { version = "0.27.4", default-features = false }
rattler_menuinst = { version = "0.2.53", default-features = false }
rattler_networking = { version = "0.26.6", default-features = false, features = [
"dirs",
"google-cloud-auth",
] }
rattler_package_streaming = { version = "0.24.2", default-features = false }
rattler_repodata_gateway = { version = "0.27.0", default-features = false }
rattler_shell = { version = "0.26.2", default-features = false }
rattler_solve = { version = "5.0.0", default-features = false }
rattler_upload = { version = "0.5.0", default-features = false, features = [
rattler_package_streaming = { version = "0.24.6", default-features = false }
rattler_repodata_gateway = { version = "0.27.3", default-features = false }
rattler_shell = { version = "0.26.6", default-features = false }
rattler_solve = { version = "5.1.0", default-features = false }
rattler_upload = { version = "0.5.4", default-features = false, features = [
"s3",
] }
rattler_virtual_packages = { version = "2.3.11", default-features = false }
rattler_virtual_packages = { version = "2.3.15", default-features = false }
simple_spawn_blocking = { version = "1.1.0", default-features = false }

# Rattler build crates
rattler_build_core = { version = "0.2.0", default-features = false, features = [
rattler_build_core = { version = "0.2.2", default-features = false, features = [
"s3",
] }
rattler_build_jinja = { version = "0.1.0" }
rattler_build_recipe = { version = "0.1.0" }
rattler_build_types = { version = "0.1.0" }
rattler_build_variant_config = { version = "0.1.0" }
rattler_build_jinja = { version = "0.1.4" }
rattler_build_recipe = { version = "0.1.4" }
rattler_build_types = { version = "0.1.4" }
rattler_build_variant_config = { version = "0.1.4" }

[patch.crates-io]
# This is a temporary patch to get `cargo vendor` to work with the `uv` and pep508_rs` crates.
Expand Down
4 changes: 3 additions & 1 deletion crates/pixi_command_dispatcher/src/solve_conda/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,9 @@ impl SolveCondaEnvironmentSpec {
locked_packages: installed,
virtual_packages: self.virtual_packages,
channel_priority: self.channel_priority,
exclude_newer: self.exclude_newer,
exclude_newer: self
.exclude_newer
.map(rattler_solve::ExcludeNewer::from_datetime),
strategy: self.strategy,
constraints: constrains_match_specs,
..rattler_solve::SolverTask::from_iter(solvable_records)
Expand Down
64 changes: 34 additions & 30 deletions crates/pixi_core/src/lock_file/satisfiability/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,34 +138,45 @@ fn fmt_solve_strategy(strategy: rattler_solve::SolveStrategy) -> &'static str {

#[derive(Debug, Error)]
pub struct ExcludeNewerMismatch {
locked_exclude_newer: Option<chrono::DateTime<chrono::Utc>>,
expected_exclude_newer: Option<chrono::DateTime<chrono::Utc>>,
package: String,
timestamp: chrono::DateTime<chrono::Utc>,
exclude_newer: chrono::DateTime<chrono::Utc>,
}

impl Display for ExcludeNewerMismatch {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match (self.locked_exclude_newer, self.expected_exclude_newer) {
(Some(locked), None) => {
write!(
f,
"the lock-file was solved with exclude-newer set to {locked}, but the environment does not have this option set"
)
}
(None, Some(expected)) => {
write!(
f,
"the lock-file was solved without exclude-newer, but the environment has this option set to {expected}"
)
}
(Some(locked), Some(expected)) if locked != expected => {
write!(
f,
"the lock-file was solved with exclude-newer set to {locked}, but the environment has this option set to {expected}"
)
write!(
f,
"the locked package '{}' has timestamp {}, which is newer than the environment's exclude-newer cutoff {}",
self.package, self.timestamp, self.exclude_newer
)
}
}

fn verify_exclude_newer(
locked_environment: &rattler_lock::Environment<'_>,
exclude_newer: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), ExcludeNewerMismatch> {
let Some(exclude_newer) = exclude_newer else {
return Ok(());
};

for (_, packages) in locked_environment.conda_packages_by_platform() {
for package in packages {
let record = package.record();
if let Some(timestamp) = record.timestamp
&& timestamp > exclude_newer
{
return Err(ExcludeNewerMismatch {
package: record.name.as_source().to_string(),
timestamp: timestamp.into(),
exclude_newer,
});
}
_ => unreachable!("if we get here the values are the same"),
}
}

Ok(())
}

#[derive(Debug, Error)]
Expand Down Expand Up @@ -619,15 +630,8 @@ pub fn verify_environment_satisfiability(
});
}

let locked_exclude_newer = locked_environment.solve_options().exclude_newer;
let expected_exclude_newer = environment.exclude_newer();
if locked_exclude_newer != expected_exclude_newer {
return Err(EnvironmentUnsat::ExcludeNewerMismatch(
ExcludeNewerMismatch {
locked_exclude_newer,
expected_exclude_newer,
},
));
if let Err(err) = verify_exclude_newer(&locked_environment, environment.exclude_newer()) {
return Err(EnvironmentUnsat::ExcludeNewerMismatch(err));
}

Ok(())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ expression: s
---
environment 'default' does not satisfy the requirements of the project
Diagnostic severity: error
Caused by: the lock-file was solved without exclude-newer, but the environment has this option set to 2025-11-04 00:00:00 UTC
Caused by: the locked package 'foobar' has timestamp 2025-11-05 00:00:00 UTC, which is newer than the environment's exclude-newer cutoff 2025-11-04 00:00:00 UTC
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ expression: s
---
environment 'default' does not satisfy the requirements of the project
Diagnostic severity: error
Caused by: the lock-file was solved with exclude-newer set to 2023-10-01 00:00:00 UTC, but the environment has this option set to 2025-11-04 00:00:00 UTC
Caused by: the locked package 'foobar' has timestamp 2025-11-05 00:00:00 UTC, which is newer than the environment's exclude-newer cutoff 2025-11-04 00:00:00 UTC

This file was deleted.

1 change: 1 addition & 0 deletions crates/pixi_manifest/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ version = "0.1.0"
chrono = { workspace = true }
console = { workspace = true }
dunce = { workspace = true }
humantime = { workspace = true }
fancy_display = { workspace = true }
fs-err = { workspace = true }
indexmap = { workspace = true }
Expand Down
143 changes: 130 additions & 13 deletions crates/pixi_manifest/src/exclude_newer.rs
Original file line number Diff line number Diff line change
@@ -1,47 +1,91 @@
use chrono::{DateTime, Days, NaiveDate, NaiveTime, Utc};
use std::str::FromStr;

/// A wrapper around a chrono DateTime that is used to exclude packages after
/// a certain point in time.
/// Specifies how to exclude newer packages from the solve.
///
/// The difference between a normal DateTime and this one is that this one can
/// be parsed from both a RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`)
/// and UTC dates in the same (e.g., `2006-12-02`).
/// Can be either:
/// - An absolute timestamp (RFC 3339 or YYYY-MM-DD date)
/// - A relative duration (e.g., `7d`, `1h`, `30m`, `1h30m`)
///
/// When a duration is specified, it is interpreted as "exclude packages newer
/// than `now - duration`" at solve time.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub struct ExcludeNewer(pub DateTime<Utc>);
pub enum ExcludeNewer {
/// An absolute point in time. Packages newer than this are excluded.
Timestamp(DateTime<Utc>),
/// A relative duration. At solve time, packages newer than `now - duration`
/// are excluded.
Duration(std::time::Duration),
}

impl ExcludeNewer {
/// Resolve to an absolute timestamp.
///
/// For `Timestamp` variants, returns the timestamp directly.
/// For `Duration` variants, computes `now - duration`.
pub fn resolve(&self) -> DateTime<Utc> {
match self {
ExcludeNewer::Timestamp(dt) => *dt,
ExcludeNewer::Duration(dur) => {
let chrono_dur =
chrono::Duration::from_std(*dur).expect("duration is too large to represent");
Utc::now() - chrono_dur
}
}
}

/// Returns `true` if this is a duration-based exclude-newer.
pub fn is_duration(&self) -> bool {
matches!(self, ExcludeNewer::Duration(_))
}
}

impl From<ExcludeNewer> for DateTime<Utc> {
fn from(value: ExcludeNewer) -> Self {
value.0
value.resolve()
}
}

impl FromStr for ExcludeNewer {
type Err = String;

fn from_str(s: &str) -> Result<Self, Self::Err> {
// Try parsing as a duration first (e.g., "7d", "1h30m", "30days")
if let Ok(duration) = humantime::parse_duration(s) {
return Ok(ExcludeNewer::Duration(duration));
}

// Try parsing as a date (YYYY-MM-DD)
let date_err = match NaiveDate::from_str(s) {
Ok(date) => {
// Midnight that day is 00:00:00 the next day
return Ok(Self(
return Ok(ExcludeNewer::Timestamp(
(date + Days::new(1)).and_time(NaiveTime::MIN).and_utc(),
));
}
Err(err) => err,
};

// Try parsing as an RFC 3339 timestamp
let datetime_err = match DateTime::parse_from_rfc3339(s) {
Ok(datetime) => return Ok(Self(datetime.with_timezone(&Utc))),
Ok(datetime) => return Ok(ExcludeNewer::Timestamp(datetime.with_timezone(&Utc))),
Err(err) => err,
};

Err(format!(
"`{s}` is neither a valid date ({date_err}) nor a valid datetime ({datetime_err})"
"`{s}` is neither a valid duration, date ({date_err}), nor datetime ({datetime_err})"
))
}
}

impl std::fmt::Display for ExcludeNewer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
match self {
ExcludeNewer::Timestamp(dt) => dt.fmt(f),
ExcludeNewer::Duration(dur) => {
write!(f, "{}", humantime::format_duration(*dur))
}
}
}
}

Expand All @@ -50,17 +94,90 @@ mod test {
use super::*;

#[test]
fn test_from_str() {
fn test_from_str_timestamp() {
// Specifying just a date is equivalent to specifying the date at midnight of the next day.
assert_eq!(
ExcludeNewer::from_str("2006-12-02").unwrap(),
ExcludeNewer::from_str("2006-12-03T00:00:00Z").unwrap(),
);

// A more readable case that RFC3339 allowed
assert_eq!(
match (
ExcludeNewer::from_str("2006-12-02T00:00:00Z").unwrap(),
ExcludeNewer::from_str("2006-12-02 00:00:00Z").unwrap(),
) {
(ExcludeNewer::Timestamp(a), ExcludeNewer::Timestamp(b)) => assert_eq!(a, b),
_ => panic!("expected timestamps"),
}
}

#[test]
fn test_from_str_duration() {
// Various duration formats supported by humantime
assert_eq!(
ExcludeNewer::from_str("7d").unwrap(),
ExcludeNewer::Duration(std::time::Duration::from_secs(7 * 24 * 60 * 60)),
);
assert_eq!(
ExcludeNewer::from_str("1h").unwrap(),
ExcludeNewer::Duration(std::time::Duration::from_secs(60 * 60)),
);
assert_eq!(
ExcludeNewer::from_str("30m").unwrap(),
ExcludeNewer::Duration(std::time::Duration::from_secs(30 * 60)),
);
assert_eq!(
ExcludeNewer::from_str("1h30m").unwrap(),
ExcludeNewer::Duration(std::time::Duration::from_secs(90 * 60)),
);
assert_eq!(
ExcludeNewer::from_str("7days").unwrap(),
ExcludeNewer::Duration(std::time::Duration::from_secs(7 * 24 * 60 * 60)),
);
}

#[test]
fn test_display_duration() {
let d = ExcludeNewer::Duration(std::time::Duration::from_secs(7 * 24 * 60 * 60));
let display = format!("{d}");
assert!(display.contains("7days"), "got: {display}");
}

#[test]
fn test_display_timestamp() {
let t = ExcludeNewer::from_str("2006-12-02T02:07:43Z").unwrap();
let display = format!("{t}");
assert!(display.contains("2006"), "got: {display}");
}

#[test]
fn test_resolve_timestamp() {
let t = ExcludeNewer::from_str("2006-12-02T02:07:43Z").unwrap();
let resolved = t.resolve();
assert_eq!(
resolved,
DateTime::parse_from_rfc3339("2006-12-02T02:07:43Z")
.unwrap()
.with_timezone(&Utc)
);
}

#[test]
fn test_resolve_duration() {
let before = Utc::now();
let d = ExcludeNewer::Duration(std::time::Duration::from_secs(3600));
let resolved = d.resolve();
let after = Utc::now();

// resolved should be approximately 1 hour ago
let one_hour = chrono::Duration::seconds(3600);
assert!(resolved >= before - one_hour);
assert!(resolved <= after - one_hour + chrono::Duration::seconds(1));
}

#[test]
fn test_is_duration() {
assert!(ExcludeNewer::from_str("7d").unwrap().is_duration());
assert!(!ExcludeNewer::from_str("2006-12-02").unwrap().is_duration());
}
}
10 changes: 9 additions & 1 deletion crates/pixi_manifest/src/features_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,15 @@ pub trait FeaturesExt<'source>: HasWorkspaceManifest<'source> + HasFeaturesIter<
Ok(channel_priority)
}

/// Returns whether packages should be excluded newer than a certain date.
/// Returns the raw exclude-newer configuration.
fn exclude_newer_raw(&self) -> Option<crate::exclude_newer::ExcludeNewer> {
self.workspace_manifest().workspace.exclude_newer
}

/// Returns the resolved exclude-newer timestamp.
///
/// For absolute timestamps, returns the timestamp directly.
/// For durations, resolves to `now - duration`.
fn exclude_newer(&self) -> Option<DateTime<Utc>> {
self.workspace_manifest()
.workspace
Expand Down
Loading