Only respect preferences across the same indexes (#9302)
Some checks are pending
CI / build binary | linux (push) Blocked by required conditions
CI / build binary | macos aarch64 (push) Blocked by required conditions
CI / build binary | macos x86_64 (push) Blocked by required conditions
CI / build binary | windows (push) Blocked by required conditions
CI / cargo build (msrv) (push) Blocked by required conditions
CI / build binary | freebsd (push) Blocked by required conditions
CI / check system | amazonlinux (push) Blocked by required conditions
CI / ecosystem test | prefecthq/prefect (push) Blocked by required conditions
CI / ecosystem test | pallets/flask (push) Blocked by required conditions
CI / integration test | conda on ubuntu (push) Blocked by required conditions
CI / integration test | free-threaded on linux (push) Blocked by required conditions
CI / integration test | free-threaded on windows (push) Blocked by required conditions
CI / integration test | pypy on ubuntu (push) Blocked by required conditions
CI / integration test | pypy on windows (push) Blocked by required conditions
CI / integration test | graalpy on ubuntu (push) Blocked by required conditions
CI / integration test | graalpy on windows (push) Blocked by required conditions
CI / integration test | github actions (push) Blocked by required conditions
CI / integration test | determine publish changes (push) Blocked by required conditions
CI / integration test | uv publish (push) Blocked by required conditions
CI / check cache | ubuntu (push) Blocked by required conditions
CI / check cache | macos aarch64 (push) Blocked by required conditions
CI / check system | python on debian (push) Blocked by required conditions
CI / check system | python on fedora (push) Blocked by required conditions
CI / check system | python on ubuntu (push) Blocked by required conditions
CI / check system | python on opensuse (push) Blocked by required conditions
CI / check system | python on rocky linux 8 (push) Blocked by required conditions
CI / Determine changes (push) Waiting to run
CI / check system | python on rocky linux 9 (push) Blocked by required conditions
CI / check system | pypy on ubuntu (push) Blocked by required conditions
CI / check system | pyston (push) Blocked by required conditions
CI / check system | alpine (push) Blocked by required conditions
CI / check system | python on macos aarch64 (push) Blocked by required conditions
CI / check system | homebrew python on macos aarch64 (push) Blocked by required conditions
CI / check system | python on macos x86_64 (push) Blocked by required conditions
CI / check system | python3.10 on windows (push) Blocked by required conditions
CI / check system | python3.10 on windows x86 (push) Blocked by required conditions
CI / check system | python3.13 on windows (push) Blocked by required conditions
CI / check system | python3.12 via chocolatey (push) Blocked by required conditions
CI / check system | python3.9 via pyenv (push) Blocked by required conditions
CI / lint (push) Waiting to run
CI / cargo clippy | ubuntu (push) Blocked by required conditions
CI / check system | python3.13 (push) Blocked by required conditions
CI / check system | conda3.11 on linux (push) Blocked by required conditions
CI / check system | conda3.8 on linux (push) Blocked by required conditions
CI / cargo clippy | windows (push) Blocked by required conditions
CI / cargo dev generate-all (push) Blocked by required conditions
CI / cargo shear (push) Waiting to run
CI / check system | conda3.11 on macos (push) Blocked by required conditions
CI / check system | conda3.8 on macos (push) Blocked by required conditions
CI / check system | conda3.11 on windows (push) Blocked by required conditions
CI / check system | conda3.8 on windows (push) Blocked by required conditions
CI / cargo test | ubuntu (push) Blocked by required conditions
CI / cargo test | macos (push) Blocked by required conditions
CI / cargo test | windows (push) Blocked by required conditions
CI / check windows trampoline | aarch64 (push) Blocked by required conditions
CI / check windows trampoline | i686 (push) Blocked by required conditions
CI / check windows trampoline | x86_64 (push) Blocked by required conditions
CI / test windows trampoline | i686 (push) Blocked by required conditions
CI / test windows trampoline | x86_64 (push) Blocked by required conditions
CI / typos (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / check system | embedded python3.10 on windows (push) Blocked by required conditions
CI / benchmarks (push) Blocked by required conditions

## Summary

The issue here is fairly complex. Consider the following:

```toml
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12.0"
dependencies = []

[project.optional-dependencies]
cpu = [
  "torch>=2.5.1",
  "torchvision>=0.20.1",
]
cu124 = [
  "torch>=2.5.1",
  "torchvision>=0.20.1",
]

[tool.uv]
conflicts = [
  [
    { extra = "cpu" },
    { extra = "cu124" },
  ],
]

[tool.uv.sources]
torch = [
  { index = "pytorch-cpu", extra = "cpu", marker = "platform_system != 'Darwin'" },
]
torchvision = [
  { index = "pytorch-cpu", extra = "cpu", marker = "platform_system != 'Darwin'" },
]

[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
```

When solving this project, we first pick a PyTorch version from PyPI, to
solve the `cu124` extra, selecting `2.5.1`.

Later, we try to solve the `cpu` extra. In solving that extra, we look
at the PyTorch CPU index. Ideally, we'd select `2.5.1+cpu`... But
`2.5.1` is already a preference. So we choose that.

Now, we only respect preferences for explicit indexes if they came from
the same index.

Closes https://github.com/astral-sh/uv/issues/9295.
This commit is contained in:
Charlie Marsh 2024-11-20 22:26:43 -05:00 committed by GitHub
parent c6482dd038
commit 5e48819dbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 129 additions and 64 deletions

View file

@ -7,7 +7,7 @@ use uv_configuration::Upgrade;
use uv_fs::CWD;
use uv_git::ResolvedRepositoryReference;
use uv_requirements_txt::RequirementsTxt;
use uv_resolver::{Lock, Preference, PreferenceError};
use uv_resolver::{Lock, LockError, Preference, PreferenceError};
#[derive(Debug, Default)]
pub struct LockedRequirements {
@ -63,7 +63,11 @@ pub async fn read_requirements_txt(
}
/// Load the preferred requirements from an existing lockfile, applying the upgrade strategy.
pub fn read_lock_requirements(lock: &Lock, upgrade: &Upgrade) -> LockedRequirements {
pub fn read_lock_requirements(
lock: &Lock,
install_path: &Path,
upgrade: &Upgrade,
) -> Result<LockedRequirements, LockError> {
let mut preferences = Vec::new();
let mut git = Vec::new();
@ -74,7 +78,7 @@ pub fn read_lock_requirements(lock: &Lock, upgrade: &Upgrade) -> LockedRequireme
}
// Map each entry in the lockfile to a preference.
preferences.push(Preference::from_lock(package));
preferences.push(Preference::from_lock(package, install_path)?);
// Map each entry in the lockfile to a Git SHA.
if let Some(git_ref) = package.as_git_ref() {
@ -82,5 +86,5 @@ pub fn read_lock_requirements(lock: &Lock, upgrade: &Upgrade) -> LockedRequireme
}
}
LockedRequirements { preferences, git }
Ok(LockedRequirements { preferences, git })
}

View file

@ -4,7 +4,7 @@ use std::fmt::{Display, Formatter};
use tracing::{debug, trace};
use uv_configuration::IndexStrategy;
use uv_distribution_types::{CompatibleDist, IncompatibleDist, IncompatibleSource};
use uv_distribution_types::{CompatibleDist, IncompatibleDist, IncompatibleSource, IndexUrl};
use uv_distribution_types::{DistributionMetadata, IncompatibleWheel, Name, PrioritizedDist};
use uv_normalize::PackageName;
use uv_pep440::Version;
@ -80,11 +80,12 @@ impl CandidateSelector {
preferences: &'a Preferences,
installed_packages: &'a InstalledPackages,
exclusions: &'a Exclusions,
index: Option<&'a IndexUrl>,
env: &ResolverEnvironment,
) -> Option<Candidate<'a>> {
let is_excluded = exclusions.contains(package_name);
// Check for a preference from a lockfile or a previous fork that satisfies the range and
// Check for a preference from a lockfile or a previous fork that satisfies the range and
// is allowed.
if let Some(preferred) = self.get_preferred(
package_name,
@ -93,6 +94,7 @@ impl CandidateSelector {
preferences,
installed_packages,
is_excluded,
index,
env,
) {
trace!("Using preference {} {}", preferred.name, preferred.version);
@ -131,23 +133,39 @@ impl CandidateSelector {
preferences: &'a Preferences,
installed_packages: &'a InstalledPackages,
is_excluded: bool,
index: Option<&'a IndexUrl>,
env: &ResolverEnvironment,
) -> Option<Candidate> {
// In the branches, we "sort" the preferences by marker-matching through an iterator that
// first has the matching half and then the mismatching half.
let preferences_match = preferences.get(package_name).filter(|(marker, _version)| {
// `.unwrap_or(true)` because the universal marker is considered matching.
marker
.map(|marker| env.included_by_marker(marker))
.unwrap_or(true)
});
let preferences_mismatch = preferences.get(package_name).filter(|(marker, _version)| {
marker
.map(|marker| !env.included_by_marker(marker))
.unwrap_or(false)
});
let preferences_match =
preferences
.get(package_name)
.filter(|(marker, _index, _version)| {
// `.unwrap_or(true)` because the universal marker is considered matching.
marker
.map(|marker| env.included_by_marker(marker))
.unwrap_or(true)
});
let preferences_mismatch =
preferences
.get(package_name)
.filter(|(marker, _index, _version)| {
marker
.map(|marker| !env.included_by_marker(marker))
.unwrap_or(false)
});
let preferences = preferences_match.chain(preferences_mismatch).filter_map(
|(marker, source, version)| {
// If the package is mapped to an explicit index, only consider preferences that
// match the index.
index
.map_or(true, |index| source == Some(index))
.then_some((marker, version))
},
);
self.get_preferred_from_iter(
preferences_match.chain(preferences_mismatch),
preferences,
package_name,
range,
version_maps,

View file

@ -1,16 +1,17 @@
use std::path::Path;
use std::str::FromStr;
use rustc_hash::FxHashMap;
use tracing::trace;
use uv_distribution_types::{InstalledDist, InstalledMetadata, InstalledVersion, Name};
use uv_distribution_types::{IndexUrl, InstalledDist, InstalledMetadata, InstalledVersion, Name};
use uv_normalize::PackageName;
use uv_pep440::{Operator, Version};
use uv_pep508::{MarkerTree, VersionOrUrl};
use uv_pypi_types::{HashDigest, HashError};
use uv_requirements_txt::{RequirementEntry, RequirementsTxtRequirement};
use crate::ResolverEnvironment;
use crate::{LockError, ResolverEnvironment};
#[derive(thiserror::Error, Debug)]
pub enum PreferenceError {
@ -25,6 +26,8 @@ pub struct Preference {
version: Version,
/// The markers on the requirement itself (those after the semicolon).
marker: MarkerTree,
/// The index URL of the package, if any.
index: Option<IndexUrl>,
/// If coming from a package with diverging versions, the markers of the forks this preference
/// is part of, otherwise `None`.
fork_markers: Vec<MarkerTree>,
@ -60,6 +63,7 @@ impl Preference {
marker: requirement.marker,
// requirements.txt doesn't have fork annotations.
fork_markers: vec![],
index: None,
hashes: entry
.hashes
.iter()
@ -79,6 +83,7 @@ impl Preference {
name: dist.name().clone(),
version: version.clone(),
marker: MarkerTree::TRUE,
index: None,
// Installed distributions don't have fork annotations.
fork_markers: vec![],
hashes: Vec::new(),
@ -86,14 +91,18 @@ impl Preference {
}
/// Create a [`Preference`] from a locked distribution.
pub fn from_lock(package: &crate::lock::Package) -> Self {
Self {
pub fn from_lock(
package: &crate::lock::Package,
install_path: &Path,
) -> Result<Self, LockError> {
Ok(Self {
name: package.id.name.clone(),
version: package.id.version.clone(),
marker: MarkerTree::TRUE,
index: package.index(install_path)?,
fork_markers: package.fork_markers().to_vec(),
hashes: Vec::new(),
}
})
}
/// Return the [`PackageName`] of the package for this [`Preference`].
@ -107,6 +116,13 @@ impl Preference {
}
}
#[derive(Debug, Clone)]
struct Entry {
marker: Option<MarkerTree>,
index: Option<IndexUrl>,
pin: Pin,
}
/// A set of pinned packages that should be preserved during resolution, if possible.
///
/// The marker is the marker of the fork that resolved to the pin, if any.
@ -114,15 +130,15 @@ impl Preference {
/// Preferences should be prioritized first by whether their marker matches and then by the order
/// they are stored, so that a lockfile has higher precedence than sibling forks.
#[derive(Debug, Clone, Default)]
pub struct Preferences(FxHashMap<PackageName, Vec<(Option<MarkerTree>, Pin)>>);
pub struct Preferences(FxHashMap<PackageName, Vec<Entry>>);
impl Preferences {
/// Create a map of pinned packages from an iterator of [`Preference`] entries.
///
/// The provided [`ResolverEnvironment`] will be used to filter the preferences
/// to an applicable subset.
pub fn from_iter<PreferenceIterator: IntoIterator<Item = Preference>>(
preferences: PreferenceIterator,
pub fn from_iter(
preferences: impl IntoIterator<Item = Preference>,
env: &ResolverEnvironment,
) -> Self {
let mut slf = Self::default();
@ -152,6 +168,7 @@ impl Preferences {
if preference.fork_markers.is_empty() {
slf.insert(
preference.name,
preference.index,
None,
Pin {
version: preference.version,
@ -162,6 +179,7 @@ impl Preferences {
for fork_marker in preference.fork_markers {
slf.insert(
preference.name.clone(),
preference.index.clone(),
Some(fork_marker),
Pin {
version: preference.version.clone(),
@ -179,13 +197,15 @@ impl Preferences {
pub(crate) fn insert(
&mut self,
package_name: PackageName,
index: Option<IndexUrl>,
markers: Option<MarkerTree>,
pin: impl Into<Pin>,
) {
self.0
.entry(package_name)
.or_default()
.push((markers, pin.into()));
self.0.entry(package_name).or_default().push(Entry {
marker: markers,
index,
pin: pin.into(),
});
}
/// Returns an iterator over the preferences.
@ -194,15 +214,19 @@ impl Preferences {
) -> impl Iterator<
Item = (
&PackageName,
impl Iterator<Item = (Option<&MarkerTree>, &Version)>,
impl Iterator<Item = (Option<&MarkerTree>, Option<&IndexUrl>, &Version)>,
),
> {
self.0.iter().map(|(name, preferences)| {
(
name,
preferences
.iter()
.map(|(markers, pin)| (markers.as_ref(), pin.version())),
preferences.iter().map(|entry| {
(
entry.marker.as_ref(),
entry.index.as_ref(),
entry.pin.version(),
)
}),
)
})
}
@ -211,12 +235,14 @@ impl Preferences {
pub(crate) fn get(
&self,
package_name: &PackageName,
) -> impl Iterator<Item = (Option<&MarkerTree>, &Version)> {
self.0
.get(package_name)
.into_iter()
.flatten()
.map(|(markers, pin)| (markers.as_ref(), pin.version()))
) -> impl Iterator<Item = (Option<&MarkerTree>, Option<&IndexUrl>, &Version)> {
self.0.get(package_name).into_iter().flatten().map(|entry| {
(
entry.marker.as_ref(),
entry.index.as_ref(),
entry.pin.version(),
)
})
}
/// Return the hashes for a package, if the version matches that of the pin.
@ -229,8 +255,8 @@ impl Preferences {
.get(package_name)
.into_iter()
.flatten()
.find(|(_markers, pin)| pin.version() == version)
.map(|(_markers, pin)| pin.hashes())
.find(|entry| entry.pin.version() == version)
.map(|entry| entry.pin.hashes())
}
}

View file

@ -381,6 +381,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
for (package, version) in &resolution.nodes {
preferences.insert(
package.name.clone(),
package.index.clone(),
resolution.env.try_markers().cloned(),
version.clone(),
);
@ -669,14 +670,15 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
diverging_packages: &'a [PackageName],
) -> impl Iterator<Item = Result<ForkState, ResolveError>> + 'a {
debug!(
"Splitting resolution on {}=={} over {} into {} resolution with separate markers",
"Splitting resolution on {}=={} over {} into {} resolution{} with separate markers",
current_state.next,
version,
diverging_packages
.iter()
.map(ToString::to_string)
.join(", "),
forks.len()
forks.len(),
if forks.len() == 1 { "" } else { "s" }
);
assert!(forks.len() >= 2);
// This is a somewhat tortured technique to ensure
@ -1075,6 +1077,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
preferences,
&self.installed_packages,
&self.exclusions,
index,
env,
) else {
// Short circuit: we couldn't find _any_ versions for a package.
@ -1934,6 +1937,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
&self.preferences,
&self.installed_packages,
&self.exclusions,
None,
&env,
) else {
return Ok(None);

View file

@ -45,7 +45,7 @@ impl AllowedYanks {
allowed_yanks
.entry(name.clone())
.or_default()
.extend(preferences.map(|(_markers, version)| version.clone()));
.extend(preferences.map(|(.., version)| version.clone()));
}
Self(Arc::new(allowed_yanks))

View file

@ -563,7 +563,8 @@ async fn do_lock(
// If an existing lockfile exists, build up a set of preferences.
let LockedRequirements { preferences, git } = versions_lock
.map(|lock| read_lock_requirements(lock, upgrade))
.map(|lock| read_lock_requirements(lock, workspace.install_path(), upgrade))
.transpose()?
.unwrap_or_default();
// Populate the Git resolver.

View file

@ -935,8 +935,8 @@ pub(crate) async fn resolve_names(
pub(crate) struct EnvironmentSpecification<'lock> {
/// The requirements to include in the environment.
requirements: RequirementsSpecification,
/// The lockfile from which to extract preferences.
lock: Option<&'lock Lock>,
/// The lockfile from which to extract preferences, along with the install path.
lock: Option<(&'lock Lock, &'lock Path)>,
}
impl From<RequirementsSpecification> for EnvironmentSpecification<'_> {
@ -950,7 +950,7 @@ impl From<RequirementsSpecification> for EnvironmentSpecification<'_> {
impl<'lock> EnvironmentSpecification<'lock> {
#[must_use]
pub(crate) fn with_lock(self, lock: Option<&'lock Lock>) -> Self {
pub(crate) fn with_lock(self, lock: Option<(&'lock Lock, &'lock Path)>) -> Self {
Self { lock, ..self }
}
}
@ -1057,7 +1057,8 @@ pub(crate) async fn resolve_environment<'a>(
// If an existing lockfile exists, build up a set of preferences.
let LockedRequirements { preferences, git } = spec
.lock
.map(|lock| read_lock_requirements(lock, &upgrade))
.map(|(lock, install_path)| read_lock_requirements(lock, install_path, &upgrade))
.transpose()?
.unwrap_or_default();
// Populate the Git resolver.

View file

@ -372,7 +372,7 @@ pub(crate) async fn run(
};
// The lockfile used for the base environment.
let mut lock: Option<Lock> = None;
let mut lock: Option<(Lock, PathBuf)> = None;
// Discover and sync the base environment.
let temp_dir;
@ -609,7 +609,8 @@ pub(crate) async fn run(
lock = project::lock::read(project.workspace())
.await
.ok()
.flatten();
.flatten()
.map(|lock| (lock, project.workspace().install_path().to_owned()));
}
} else {
// Validate that any referenced dependency groups are defined in the workspace.
@ -749,7 +750,10 @@ pub(crate) async fn run(
Err(err) => return Err(err.into()),
}
lock = Some(result.into_lock());
lock = Some((
result.into_lock(),
project.workspace().install_path().to_owned(),
));
}
venv.into_interpreter()
@ -861,7 +865,10 @@ pub(crate) async fn run(
debug!("Syncing ephemeral requirements");
let result = CachedEnvironment::get_or_create(
EnvironmentSpecification::from(spec).with_lock(lock.as_ref()),
EnvironmentSpecification::from(spec).with_lock(
lock.as_ref()
.map(|(lock, install_path)| (lock, install_path.as_ref())),
),
base_interpreter.clone(),
&settings,
&state,

View file

@ -6458,7 +6458,11 @@ fn add_index() -> Result<()> {
----- stderr -----
Resolved 4 packages in [TIME]
Audited 3 packages in [TIME]
Prepared 1 package in [TIME]
Uninstalled 1 package in [TIME]
Installed 1 package in [TIME]
- jinja2==3.1.3
+ jinja2==3.1.4
"###);
let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?;
@ -6517,14 +6521,14 @@ fn add_index() -> Result<()> {
[[package]]
name = "jinja2"
version = "3.1.3"
version = "3.1.4"
source = { registry = "https://test.pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://test-files.pythonhosted.org/packages/3e/f0/69ae37cced6b277dc0419dbb1c6e4fb259e5e319a1a971061a2776316bec/Jinja2-3.1.3.tar.gz", hash = "sha256:27fb536952e578492fa66d8681d8967d8bdf1eb36368b1f842b53251c9f0bfe1", size = 268254 }
sdist = { url = "https://test-files.pythonhosted.org/packages/ed/55/39036716d19cab0747a5020fc7e907f362fbf48c984b14e62127f7e68e5d/jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", size = 240245 }
wheels = [
{ url = "https://test-files.pythonhosted.org/packages/47/dc/9d1c0f1ddbedb1e67f7d00e91819b5a9157056ad83bfa64c12ecef8a4f4e/Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:ddd11470e8a1dc4c30e3146400f0130fed7d85886c5f8082f309355b4b0c1128", size = 133236 },
{ url = "https://test-files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 },
]
[[package]]
@ -6633,14 +6637,14 @@ fn add_index() -> Result<()> {
[[package]]
name = "jinja2"
version = "3.1.3"
version = "3.1.4"
source = { registry = "https://test.pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://test-files.pythonhosted.org/packages/3e/f0/69ae37cced6b277dc0419dbb1c6e4fb259e5e319a1a971061a2776316bec/Jinja2-3.1.3.tar.gz", hash = "sha256:27fb536952e578492fa66d8681d8967d8bdf1eb36368b1f842b53251c9f0bfe1", size = 268254 }
sdist = { url = "https://test-files.pythonhosted.org/packages/ed/55/39036716d19cab0747a5020fc7e907f362fbf48c984b14e62127f7e68e5d/jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", size = 240245 }
wheels = [
{ url = "https://test-files.pythonhosted.org/packages/47/dc/9d1c0f1ddbedb1e67f7d00e91819b5a9157056ad83bfa64c12ecef8a4f4e/Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:ddd11470e8a1dc4c30e3146400f0130fed7d85886c5f8082f309355b4b0c1128", size = 133236 },
{ url = "https://test-files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 },
]
[[package]]
@ -6758,14 +6762,14 @@ fn add_index() -> Result<()> {
[[package]]
name = "jinja2"
version = "3.1.3"
version = "3.1.4"
source = { registry = "https://test.pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://test-files.pythonhosted.org/packages/3e/f0/69ae37cced6b277dc0419dbb1c6e4fb259e5e319a1a971061a2776316bec/Jinja2-3.1.3.tar.gz", hash = "sha256:27fb536952e578492fa66d8681d8967d8bdf1eb36368b1f842b53251c9f0bfe1", size = 268254 }
sdist = { url = "https://test-files.pythonhosted.org/packages/ed/55/39036716d19cab0747a5020fc7e907f362fbf48c984b14e62127f7e68e5d/jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", size = 240245 }
wheels = [
{ url = "https://test-files.pythonhosted.org/packages/47/dc/9d1c0f1ddbedb1e67f7d00e91819b5a9157056ad83bfa64c12ecef8a4f4e/Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:ddd11470e8a1dc4c30e3146400f0130fed7d85886c5f8082f309355b4b0c1128", size = 133236 },
{ url = "https://test-files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 },
]
[[package]]