Skip to content

Commit 1650d6a

Browse files
nichmortdejagerruben-arts
authored
feat: add pypi index (prefix-dev#2416)
Co-authored-by: Tim de Jager <tim@prefix.dev> Co-authored-by: Tim de Jager <tdejager89@gmail.com> Co-authored-by: Ruben Arts <ruben@prefix.dev>
1 parent 3bc5876 commit 1650d6a

File tree

11 files changed

+156
-12
lines changed

11 files changed

+156
-12
lines changed

crates/pixi_manifest/src/pypi/pypi_requirement.rs

+45-8
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ pub enum PyPiRequirement {
8585
version: VersionOrStar,
8686
#[serde(default)]
8787
extras: Vec<ExtraName>,
88+
#[serde(default)]
89+
index: Option<Url>,
8890
},
8991
RawVersion(VersionOrStar),
9092
}
@@ -138,6 +140,10 @@ struct RawPyPiRequirement {
138140

139141
// Git and Url only
140142
pub subdirectory: Option<String>,
143+
144+
// Pinned index
145+
#[serde(default)]
146+
pub index: Option<Url>,
141147
}
142148

143149
impl<'de> Deserialize<'de> for PyPiRequirement {
@@ -186,18 +192,24 @@ impl<'de> Deserialize<'de> for PyPiRequirement {
186192
)));
187193
}
188194

189-
let req = match (raw_req.url, raw_req.path, raw_req.git, raw_req.extras) {
190-
(Some(url), None, None, extras) => PyPiRequirement::Url {
195+
let req = match (
196+
raw_req.url,
197+
raw_req.path,
198+
raw_req.git,
199+
raw_req.extras,
200+
raw_req.index,
201+
) {
202+
(Some(url), None, None, extras, None) => PyPiRequirement::Url {
191203
url,
192204
extras,
193205
subdirectory: raw_req.subdirectory,
194206
},
195-
(None, Some(path), None, extras) => PyPiRequirement::Path {
207+
(None, Some(path), None, extras, None) => PyPiRequirement::Path {
196208
path,
197209
editable: raw_req.editable,
198210
extras,
199211
},
200-
(None, None, Some(git), extras) => PyPiRequirement::Git {
212+
(None, None, Some(git), extras, None) => PyPiRequirement::Git {
201213
url: ParsedGitUrl {
202214
git,
203215
branch: raw_req.branch,
@@ -207,13 +219,15 @@ impl<'de> Deserialize<'de> for PyPiRequirement {
207219
},
208220
extras,
209221
},
210-
(None, None, None, extras) => PyPiRequirement::Version {
222+
(None, None, None, extras, index) => PyPiRequirement::Version {
211223
version: raw_req.version.unwrap_or(VersionOrStar::Star),
212224
extras,
225+
index,
213226
},
214-
(_, _, _, extras) if !extras.is_empty() => PyPiRequirement::Version {
227+
(_, _, _, extras, index) if !extras.is_empty() => PyPiRequirement::Version {
215228
version: raw_req.version.unwrap_or(VersionOrStar::Star),
216229
extras,
230+
index,
217231
},
218232
_ => {
219233
return Err(serde_untagged::de::Error::custom(
@@ -278,17 +292,35 @@ impl From<PyPiRequirement> for toml_edit::Value {
278292
}
279293
}
280294

295+
fn insert_index(table: &mut toml_edit::InlineTable, index: &Option<Url>) {
296+
if let Some(index) = index {
297+
table.insert(
298+
"index",
299+
toml_edit::Value::String(toml_edit::Formatted::new(index.to_string())),
300+
);
301+
}
302+
}
303+
281304
match &val {
282-
PyPiRequirement::Version { version, extras } if extras.is_empty() => {
305+
PyPiRequirement::Version {
306+
version,
307+
extras,
308+
index,
309+
} if extras.is_empty() && index.is_none() => {
283310
toml_edit::Value::from(version.to_string())
284311
}
285-
PyPiRequirement::Version { version, extras } => {
312+
PyPiRequirement::Version {
313+
version,
314+
extras,
315+
index,
316+
} => {
286317
let mut table = toml_edit::Table::new().into_inline_table();
287318
table.insert(
288319
"version",
289320
toml_edit::Value::String(toml_edit::Formatted::new(version.to_string())),
290321
);
291322
insert_extras(&mut table, extras);
323+
insert_index(&mut table, index);
292324
toml_edit::Value::InlineTable(table.to_owned())
293325
}
294326
PyPiRequirement::Git {
@@ -423,6 +455,7 @@ impl TryFrom<pep508_rs::Requirement> for PyPiRequirement {
423455
pep508_rs::VersionOrUrl::VersionSpecifier(v) => PyPiRequirement::Version {
424456
version: v.into(),
425457
extras: req.extras,
458+
index: None,
426459
},
427460
pep508_rs::VersionOrUrl::Url(u) => {
428461
let url = u.to_url();
@@ -494,6 +527,7 @@ impl TryFrom<pep508_rs::Requirement> for PyPiRequirement {
494527
PyPiRequirement::Version {
495528
version: VersionOrStar::Star,
496529
extras: req.extras,
530+
index: None,
497531
}
498532
} else {
499533
PyPiRequirement::RawVersion(VersionOrStar::Star)
@@ -616,6 +650,7 @@ mod tests {
616650
&PyPiRequirement::Version {
617651
version: ">=3.12".parse().unwrap(),
618652
extras: vec![ExtraName::from_str("bar").unwrap()],
653+
index: None,
619654
}
620655
);
621656

@@ -636,6 +671,7 @@ mod tests {
636671
ExtraName::from_str("bar").unwrap(),
637672
ExtraName::from_str("foo").unwrap(),
638673
],
674+
index: None,
639675
}
640676
);
641677
}
@@ -659,6 +695,7 @@ mod tests {
659695
ExtraName::from_str("feature1").unwrap(),
660696
ExtraName::from_str("feature2").unwrap()
661697
],
698+
index: None,
662699
}
663700
);
664701
}

crates/pixi_manifest/src/pypi/snapshots/pixi_manifest__pypi__pypi_requirement__tests__deserialize_failing.snap

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ expression: snapshot
55
- input:
66
ver: 1.2.3
77
result:
8-
error: "ERROR: unknown field `ver`, expected one of `version`, `extras`, `path`, `editable`, `git`, `branch`, `tag`, `rev`, `url`, `subdirectory`"
8+
error: "ERROR: unknown field `ver`, expected one of `version`, `extras`, `path`, `editable`, `git`, `branch`, `tag`, `rev`, `url`, `subdirectory`, `index`"
99
- input:
1010
path: foobar
1111
version: "==1.2.3"

crates/pixi_manifest/src/pypi/snapshots/pixi_manifest__pypi__pypi_requirement__tests__deserialize_succeeding.snap

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ expression: snapshot
99
result:
1010
version: "==1.2.3"
1111
extras: []
12+
index: ~
1213
- input: "*"
1314
result: "*"
1415
- input:

crates/pixi_uv_conversions/src/requirements.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,11 @@ pub fn as_uv_req(
8282
) -> Result<uv_pypi_types::Requirement, AsPep508Error> {
8383
let name = PackageName::new(name.to_owned())?;
8484
let source = match req {
85-
PyPiRequirement::Version { version, .. } => {
85+
PyPiRequirement::Version { version, index, .. } => {
8686
// TODO: implement index later
8787
RequirementSource::Registry {
8888
specifier: to_version_specificers(version)?,
89-
index: None,
89+
index: index.clone(),
9090
}
9191
}
9292
PyPiRequirement::Git {

docs/reference/project_configuration.md

+13
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,19 @@ ruff = "~=1.0.0"
469469
pytest = {version = "*", extras = ["dev"]}
470470
```
471471

472+
##### `index`
473+
474+
The index parameter allows you to specify the URL of a custom package index for the installation of a specific package.
475+
This feature is useful when you want to ensure that a package is retrieved from a particular source, rather than from the default index.
476+
477+
For example, to use some other than the official Python Package Index (PyPI) at https://pypi.org/simple, you can use the `index` parameter:
478+
479+
```toml
480+
torch = { version = "*", index = "https://download.pytorch.org/whl/cu118" }
481+
```
482+
483+
This is useful for PyTorch specifically, as the registries are pinned to different CUDA versions.
484+
472485
##### `git`
473486

474487
A git repository to install from.

schema/examples/valid/full.toml

+1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ requests = { version = ">= 2.8.1, ==2.8.*", extras = [
4949
"security",
5050
"tests",
5151
] } # Using the map allows the user to add `extras`
52+
test-pinning-index = { version = "*", index = "https://example.com/test" }
5253
testpypi = "*"
5354
testpypi1 = "*"
5455

schema/model.py

+4
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,10 @@ class PyPIVersion(_PyPIRequirement):
248248
None,
249249
description="The version of the package in [PEP 440](https://www.python.org/dev/peps/pep-0440/) format",
250250
)
251+
index: NonEmptyStr | None = Field(
252+
None,
253+
description="The index to fetch the package from",
254+
)
251255

252256

253257
PyPIRequirement = (

schema/schema.json

+6
Original file line numberDiff line numberDiff line change
@@ -1081,6 +1081,12 @@
10811081
"minLength": 1
10821082
}
10831083
},
1084+
"index": {
1085+
"title": "Index",
1086+
"description": "The index to fetch the package from",
1087+
"type": "string",
1088+
"minLength": 1
1089+
},
10841090
"version": {
10851091
"title": "Version",
10861092
"description": "The version of the package in [PEP 440](https://www.python.org/dev/peps/pep-0440/) format",

src/cli/project/export/conda_environment.rs

+3-1
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,9 @@ fn format_pip_dependency(name: &PyPiPackageName, requirement: &PyPiRequirement)
106106

107107
url_string
108108
}
109-
PyPiRequirement::Version { version, extras } => {
109+
PyPiRequirement::Version {
110+
version, extras, ..
111+
} => {
110112
format!(
111113
"{name}{extras}{version}",
112114
name = name.as_normalized(),

tests/integration_rust/add_tests.rs

+1
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,7 @@ async fn add_pypi_extra_functionality() {
440440
PyPiRequirement::Version {
441441
version: VersionOrStar::from_str("==24.8.0").unwrap(),
442442
extras: vec![pep508_rs::ExtraName::from_str("cli").unwrap()],
443+
index: None
443444
}
444445
);
445446
}

tests/integration_rust/pypi_tests.rs

+79
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,82 @@ async fn test_index_strategy() {
163163
Some("3.0.0".into())
164164
);
165165
}
166+
167+
#[tokio::test]
168+
#[cfg_attr(not(feature = "slow_integration_tests"), ignore)]
169+
/// This test checks if we can pin a package from a PyPI index, by explicitly specifying the index.
170+
async fn test_pinning_index() {
171+
let pypi_indexes = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/pypi-indexes");
172+
let pypi_indexes_url = Url::from_directory_path(pypi_indexes.clone()).unwrap();
173+
174+
let pixi = PixiControl::from_manifest(&format!(
175+
r#"
176+
[project]
177+
name = "pypi-pinning-index"
178+
platforms = ["{platform}"]
179+
channels = ["conda-forge"]
180+
181+
[dependencies]
182+
python = "~=3.12.0"
183+
184+
[pypi-dependencies]
185+
foo = {{ version = "*", index = "{pypi_indexes}multiple-indexes-a/index" }}
186+
187+
"#,
188+
platform = Platform::current(),
189+
pypi_indexes = pypi_indexes_url,
190+
));
191+
192+
let lock_file = pixi.unwrap().update_lock_file().await.unwrap();
193+
194+
assert_eq!(
195+
lock_file
196+
.get_pypi_package_url("default", Platform::current(), "foo")
197+
.unwrap()
198+
.as_path()
199+
.unwrap(),
200+
pypi_indexes
201+
.join("multiple-indexes-a/index/foo")
202+
.join("foo-1.0.0-py2.py3-none-any.whl")
203+
);
204+
}
205+
206+
#[tokio::test]
207+
#[cfg_attr(not(feature = "slow_integration_tests"), ignore)]
208+
/// This test checks if we can receive torch correctly from the whl/cu124 index.
209+
async fn pin_torch() {
210+
// Do some platform magic, as the index does not contain wheels for each platform.
211+
let platform = Platform::current();
212+
let platforms = match platform {
213+
Platform::Linux64 => "\"linux-64\"".to_string(),
214+
_ => format!("\"{platform}\", \"linux-64\"", platform = platform),
215+
};
216+
217+
let pixi = PixiControl::from_manifest(&format!(
218+
r#"
219+
[project]
220+
name = "pypi-pinning-index"
221+
platforms = [{platforms}]
222+
channels = ["conda-forge"]
223+
224+
[dependencies]
225+
python = "~=3.12.0"
226+
227+
[target.linux-64.pypi-dependencies]
228+
torch = {{ version = "*", index = "https://download.pytorch.org/whl/cu124" }}
229+
"#,
230+
platforms = platforms,
231+
));
232+
233+
let lock_file = pixi.unwrap().update_lock_file().await.unwrap();
234+
// So the check is as follows:
235+
// 1. The PyPI index is the main index-url, so normally torch would be taken from there.
236+
// 2. We manually check if it is taken from the whl/cu124 index instead.
237+
assert!(lock_file
238+
.get_pypi_package_url("default", Platform::Linux64, "torch")
239+
.unwrap()
240+
.as_url()
241+
.unwrap()
242+
.path()
243+
.contains("/whl/cu124"));
244+
}

0 commit comments

Comments
 (0)