Skip to content

Commit 66ce6cf

Browse files
authored
feat: switch to compressed mapping (#1335)
1 parent 070d0e4 commit 66ce6cf

File tree

10 files changed

+1998
-913
lines changed

10 files changed

+1998
-913
lines changed

examples/conda_mapping/pixi.lock

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

examples/conda_mapping/pixi.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ python = "~=3.11.0"
1717
scipy = "~=1.11.4"
1818
boltons = "*"
1919
jupyter-ros = { version = "*", channel = "robostack" }
20+
jupyter-amphion = {version = "*", channel = "robostack"}
2021

2122
[pypi-dependencies]
2223
black = { version = "~=23.10", extras = ["jupyter"] }
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{
2-
"jupyter-ros": "my-name-from-mapping"
2+
"jupyter-ros": "my-name-from-mapping",
3+
"jupyter-amphion": null
34
}
Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,40 @@
11
import yaml
22

3+
# This test verify if we generate right purls for our packages
4+
# We use one remote mapping for conda-forge channel
5+
# and one local mapping for robostack channel
36

4-
PACKAGE_NAME_TO_TEST = {"boltons": "my-boltons-name", "jupyter-ros": "my-name-from-mapping"}
57

8+
# For packages that are present in local-mapping
9+
# we verify if source=project-defined-mapping qualifier is present in purl
10+
# so purl should look like this:
11+
# pkg:pypi/my-boltons-name?source=project-defined-mapping
12+
13+
PACKAGE_NAME_TO_TEST = {
14+
"boltons": "my-boltons-name?source=project-defined-mapping",
15+
"jupyter-ros": "my-name-from-mapping?source=project-defined-mapping"
16+
}
17+
18+
19+
20+
# We test if having a null for conda name
21+
# will mark a conda package as not a pypi package
22+
# and will not add any purls for it
23+
# "jupyter-amphion": null
24+
PACKAGE_NAME_SHOULD_BE_NULL = ("jupyter-amphion",)
625

7-
if __name__ == "__main__":
8-
# this will test if we map correctly our packages
9-
# we have one remote mapping for conda-forge
10-
# and one local mapping for robostack
1126

27+
if __name__ == "__main__":
1228
with open("pixi.lock") as pixi_lock:
1329
lock = yaml.safe_load(pixi_lock)
1430

1531
expected_packages = [
1632
package for package in lock["packages"] if package["name"] in PACKAGE_NAME_TO_TEST
1733
]
1834

19-
assert len(expected_packages) == 2
35+
expected_null_packages = [
36+
package for package in lock["packages"] if package["name"] in PACKAGE_NAME_SHOULD_BE_NULL
37+
]
2038

2139
for package in expected_packages:
2240
package_name = package["name"]
@@ -29,3 +47,7 @@
2947
expected_purl = f"pkg:pypi/{PACKAGE_NAME_TO_TEST[package_name]}"
3048

3149
assert purls[0] == expected_purl
50+
51+
52+
for package in expected_null_packages:
53+
assert "purls" not in package

examples/pypi/pixi.lock

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

src/lock_file/package_identifier.rs

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::{project::manifest::python::PyPiPackageName, pypi_mapping};
1+
use crate::project::manifest::python::PyPiPackageName;
22
use pep508_rs::{Requirement, VersionOrUrl};
33
use rattler_conda_types::{PackageUrl, RepoDataRecord};
44
use std::{collections::HashSet, str::FromStr};
@@ -32,51 +32,21 @@ impl PypiPackageIdentifier {
3232
result: &mut Vec<Self>,
3333
) -> Result<(), ConversionError> {
3434
// Check the PURLs for a python package.
35-
let mut has_pypi_purl = false;
3635
for purl in record.package_record.purls.iter() {
37-
if let Some(entry) = Self::try_from_purl(purl, &record.package_record.version.as_str())?
36+
if let Some(entry) =
37+
Self::convert_from_purl(purl, &record.package_record.version.as_str())?
3838
{
3939
result.push(entry);
40-
has_pypi_purl = true;
41-
}
42-
}
43-
44-
// If there is no pypi purl, but the package is a conda-forge package, we just assume that
45-
// the name of the package is equivalent to the name of the python package.
46-
if !has_pypi_purl && pypi_mapping::is_conda_forge_record(record) {
47-
// Convert the conda package names to pypi package names. If the conversion fails we
48-
// just assume that its not a valid python package.
49-
let name = PackageName::from_str(record.package_record.name.as_source()).ok();
50-
let version =
51-
pep440_rs::Version::from_str(&record.package_record.version.as_str()).ok();
52-
if let (Some(name), Some(version)) = (name, version) {
53-
result.push(PypiPackageIdentifier {
54-
name: PyPiPackageName::from_normalized(name),
55-
version,
56-
url: record.url.clone(),
57-
// TODO: We can't really tell which python extras are enabled in a conda package.
58-
extras: Default::default(),
59-
})
6040
}
6141
}
6242

6343
Ok(())
6444
}
6545

66-
// /// Given a list of conda package records, extract the python packages that will be installed
67-
// /// when these conda packages are installed.
68-
// pub fn from_records(records: &[RepoDataRecord]) -> Result<Vec<Self>, ConversionError> {
69-
// let mut result = Vec::new();
70-
// for record in records {
71-
// Self::from_record_into(record, &mut result)?;
72-
// }
73-
// Ok(result)
74-
// }
75-
7646
/// Tries to construct an instance from a generic PURL.
7747
///
7848
/// The `fallback_version` is used if the PURL does not contain a version.
79-
pub fn try_from_purl(
49+
pub fn convert_from_purl(
8050
package_url: &PackageUrl,
8151
fallback_version: &str,
8252
) -> Result<Option<Self>, ConversionError> {

src/pypi_mapping/custom_pypi_mapping.rs

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@ use async_once_cell::OnceCell;
99
use crate::pypi_mapping::MappingLocation;
1010

1111
use super::{
12-
prefix_pypi_name_mapping::{self},
12+
build_pypi_purl_from_package_record, is_conda_forge_record, prefix_pypi_name_mapping,
1313
MappingMap, Reporter,
1414
};
1515

16-
pub async fn fetch_mapping_from_url(
16+
pub async fn fetch_mapping_from_url<T>(
1717
client: &ClientWithMiddleware,
1818
url: &Url,
19-
) -> miette::Result<HashMap<String, String>> {
19+
) -> miette::Result<T>
20+
where
21+
T: serde::de::DeserializeOwned,
22+
{
2023
let response = client
2124
.get(url.clone())
2225
.send()
@@ -34,8 +37,7 @@ pub async fn fetch_mapping_from_url(
3437
));
3538
}
3639

37-
let mapping_by_name: HashMap<String, String> =
38-
response.json().await.into_diagnostic().context(format!(
40+
let mapping_by_name: T = response.json().await.into_diagnostic().context(format!(
3941
"failed to parse pypi name mapping located at {}. Please make sure that it's a valid json",
4042
url
4143
))?;
@@ -46,11 +48,11 @@ pub async fn fetch_mapping_from_url(
4648
pub async fn fetch_custom_mapping(
4749
client: &ClientWithMiddleware,
4850
mapping_url: &MappingMap,
49-
) -> miette::Result<&'static HashMap<String, HashMap<String, String>>> {
50-
static MAPPING: OnceCell<HashMap<String, HashMap<String, String>>> = OnceCell::new();
51+
) -> miette::Result<&'static HashMap<String, HashMap<String, Option<String>>>> {
52+
static MAPPING: OnceCell<HashMap<String, HashMap<String, Option<String>>>> = OnceCell::new();
5153
MAPPING
5254
.get_or_try_init(async {
53-
let mut mapping_url_to_name: HashMap<String, HashMap<String, String>> =
55+
let mut mapping_url_to_name: HashMap<String, HashMap<String, Option<String>>> =
5456
Default::default();
5557

5658
for (name, url) in mapping_url.iter() {
@@ -83,10 +85,12 @@ pub async fn fetch_custom_mapping(
8385
let contents = std::fs::read_to_string(path)
8486
.into_diagnostic()
8587
.context(format!("mapping on {path:?} could not be loaded"))?;
86-
let data: HashMap<String, String> = serde_json::from_str(&contents)
87-
.unwrap_or_else(|_| {
88-
panic!("Failed to parse JSON mapping located at {path:?}")
89-
});
88+
let data: HashMap<String, Option<String>> = serde_json::from_str(&contents)
89+
.into_diagnostic()
90+
.context(format!(
91+
"Failed to parse JSON mapping located at {}",
92+
path.display()
93+
))?;
9094

9195
mapping_url_to_name.insert(name.to_string(), data);
9296
}
@@ -149,7 +153,7 @@ pub async fn amend_pypi_purls(
149153
/// a conda-forge package.
150154
fn amend_pypi_purls_for_record(
151155
record: &mut RepoDataRecord,
152-
custom_mapping: &'static HashMap<String, HashMap<String, String>>,
156+
custom_mapping: &'static HashMap<String, HashMap<String, Option<String>>>,
153157
) -> miette::Result<()> {
154158
// If the package already has a pypi name we can stop here.
155159
if record
@@ -161,27 +165,43 @@ fn amend_pypi_purls_for_record(
161165
return Ok(());
162166
}
163167

164-
// If this package is a conda-forge package or user specified a custom channel mapping
165-
// we can try to guess the pypi name from the conda name
166-
if custom_mapping.contains_key(&record.channel) {
167-
if let Some(mapped_channel) = custom_mapping.get(&record.channel) {
168-
if let Some(mapped_name) =
169-
mapped_channel.get(record.package_record.name.as_normalized())
170-
{
171-
record.package_record.purls.push(
172-
PackageUrl::new(String::from("pypi"), mapped_name)
173-
.expect("valid pypi package url"),
174-
);
168+
let mut not_a_pypi = false;
169+
170+
// we verify if we have package channel and name in user provided mapping
171+
if let Some(mapped_channel) = custom_mapping.get(&record.channel) {
172+
if let Some(mapped_name) = mapped_channel.get(record.package_record.name.as_normalized()) {
173+
// we have a pypi name for it so we record a purl
174+
if let Some(name) = mapped_name {
175+
let purl = PackageUrl::builder(String::from("pypi"), name.to_string())
176+
.with_qualifier("source", "project-defined-mapping")
177+
.expect("valid qualifier");
178+
179+
record
180+
.package_record
181+
.purls
182+
.push(purl.build().expect("valid pypi package url"));
183+
} else {
184+
not_a_pypi = true;
175185
}
176186
}
177187
}
178188

189+
// if we don't have it and it's channel is conda-forge
190+
// we assume that it's the pypi package
191+
if !not_a_pypi && record.package_record.purls.is_empty() && is_conda_forge_record(record) {
192+
// Convert the conda package names to pypi package names. If the conversion fails we
193+
// just assume that its not a valid python package.
194+
if let Some(purl) = build_pypi_purl_from_package_record(&record.package_record) {
195+
record.package_record.purls.push(purl);
196+
}
197+
}
198+
179199
Ok(())
180200
}
181201

182202
pub fn _amend_only_custom_pypi_purls(
183203
conda_packages: &mut [RepoDataRecord],
184-
custom_mapping: &'static HashMap<String, HashMap<String, String>>,
204+
custom_mapping: &'static HashMap<String, HashMap<String, Option<String>>>,
185205
) -> miette::Result<()> {
186206
for record in conda_packages.iter_mut() {
187207
amend_pypi_purls_for_record(record, custom_mapping)?;

src/pypi_mapping/mod.rs

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
use std::{collections::HashMap, path::PathBuf, str::FromStr, sync::Arc};
22

33
use http_cache_reqwest::{CACacheManager, Cache, CacheMode, HttpCache, HttpCacheOptions};
4-
use rattler_conda_types::RepoDataRecord;
4+
use rattler_conda_types::{PackageRecord, PackageUrl, RepoDataRecord};
55
use reqwest_middleware::ClientBuilder;
66
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
77
use url::Url;
88

99
use crate::config::get_cache_dir;
1010

11-
mod custom_pypi_mapping;
11+
pub mod custom_pypi_mapping;
1212
pub mod prefix_pypi_name_mapping;
1313

1414
pub trait Reporter: Send + Sync {
@@ -19,19 +19,34 @@ pub trait Reporter: Send + Sync {
1919

2020
pub type ChannelName = String;
2121

22-
type MappingMap = HashMap<ChannelName, MappingLocation>;
22+
pub type MappingMap = HashMap<ChannelName, MappingLocation>;
2323

24-
#[derive(Debug)]
24+
#[derive(Debug, Clone)]
2525
pub enum MappingLocation {
2626
Path(PathBuf),
2727
Url(Url),
2828
}
2929

30+
/// This enum represents the source of mapping
31+
/// it can be user-defined ( custom )
32+
/// or from prefix.dev ( prefix )
33+
3034
pub enum MappingSource {
3135
Custom { mapping: MappingMap },
3236
Prefix,
3337
}
3438

39+
impl MappingSource {
40+
/// Return the custom `MappingMap`
41+
/// for `MappingSource::Custom`
42+
pub fn custom(&self) -> Option<MappingMap> {
43+
match self {
44+
MappingSource::Custom { mapping } => Some(mapping.clone()),
45+
_ => None,
46+
}
47+
}
48+
}
49+
3550
pub async fn amend_pypi_purls(
3651
client: reqwest::Client,
3752
mapping_source: &MappingSource,
@@ -78,3 +93,18 @@ pub fn is_conda_forge_record(record: &RepoDataRecord) -> bool {
7893
pub fn is_conda_forge_url(url: &Url) -> bool {
7994
url.path().starts_with("/conda-forge")
8095
}
96+
97+
/// Build a purl for a `PackageRecord`
98+
/// it will return a purl in this format
99+
/// `pkg:pypi/aiofiles`
100+
pub fn build_pypi_purl_from_package_record(package_record: &PackageRecord) -> Option<PackageUrl> {
101+
let name = pep508_rs::PackageName::from_str(package_record.name.as_source()).ok();
102+
let version = pep440_rs::Version::from_str(&package_record.version.as_str()).ok();
103+
if let (Some(name), Some(_)) = (name, version) {
104+
let purl = PackageUrl::builder(String::from("pypi"), name.to_string());
105+
let built_purl = purl.build().expect("valid pypi package url");
106+
return Some(built_purl);
107+
}
108+
109+
None
110+
}

0 commit comments

Comments
 (0)