mirror of
https://github.com/astral-sh/uv.git
synced 2025-10-31 20:09:09 +00:00
[uv-settings]: Correct behavior for relative find-links paths when run from a subdir (#10827)
## One-liner
Relative find-links configuration to local path from a pyproject.toml or
uv.toml is now relative to the config file
## Summary
### Background
One can configure find-links in a `pyproject.toml` or `uv.toml` file,
which are located from the cli arg, system directory, user directory, or
by traversing parent directories until one is encountered.
This PR addresses the following scenario:
- A project directory which includes a `pyproject.toml` or `uv.toml`
file
- The config file includes a `find-links` option. (eg under `[tool.uv]`
for `pyproject.toml`)
- The `find-links` option is configured to point to a local subdirectory
in the project: `packages/`
- There is a subdirectory called `subdir`, which is the current working
directory
- I run `uv run my_script.py`. This will locate the `pyproject.toml` in
the parent directory
### Current Behavior
- uv tries to use the path `subdir/packages/` to find packages, and
fails.
### New Behavior
- uv tries to use the path `packages/` to find the packages, and
succeeds
- Specifically, any relative local find-links path will resolve to be
relative to the configuration file.
### Why is this behavior change OK?
- I believe no one depends on the behavior that a relative find-links
when running in a subdir will refer to different directories each time
- Thus this change only allows a more common use case which didn't work
previously.
## Test Plan
- I re-created the setup mentioned above:
```
UvTest/
├── packages/
│ ├── colorama-0.4.6-py2.py3-none-any.whl
│ └── tqdm-4.67.1-py3-none-any.whl
├── subdir/
│ └── my_script.py
└── pyproject.toml
```
```toml
# pyproject.toml
[project]
name = "uvtest"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"tqdm>=4.67.1",
]
[tool.uv]
offline = true
no-index = true
find-links = ["packages/"]
```
- With working directory under `subdir`, previously, running `uv sync
--offline` would fail resolving the tdqm package, and after the change
it succeeds.
- Additionally, one can use `uv sync --show-settings` to show the
actually-resolved settings - now having the desired path in
`flat_index.url.path`
## Alternative designs considered
- I considered modifying the `impl Deserialize for IndexUrl` to parse
ahead of time directly with a base directory by having a custom
`Deserializer` with a base dir field, but it seems to contradict the
design of the serde `Deserialize` trait - which should work with all
`Deserializer`s
## Future work
- Support for adjusting all other local-relative paths in `Options`
would be desired, but is out of scope for the current PR.
---------
Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
This commit is contained in:
parent
d6d0593b71
commit
b1e4bc779c
6 changed files with 208 additions and 25 deletions
|
|
@ -1,3 +1,4 @@
|
|||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
|
||||
use thiserror::Error;
|
||||
|
|
@ -166,6 +167,16 @@ impl Index {
|
|||
// Otherwise, extract the credentials from the URL.
|
||||
Credentials::from_url(self.url.url())
|
||||
}
|
||||
|
||||
/// Resolve the index relative to the given root directory.
|
||||
pub fn relative_to(mut self, root_dir: &Path) -> Result<Self, IndexUrlError> {
|
||||
if let IndexUrl::Path(ref url) = self.url {
|
||||
if let Some(given) = url.given() {
|
||||
self.url = IndexUrl::parse(given, Some(root_dir))?;
|
||||
}
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Index {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::borrow::Cow;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::ops::Deref;
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, LazyLock, RwLock};
|
||||
|
||||
|
|
@ -27,6 +28,42 @@ pub enum IndexUrl {
|
|||
Path(VerbatimUrl),
|
||||
}
|
||||
|
||||
impl IndexUrl {
|
||||
/// Parse an [`IndexUrl`] from a string, relative to an optional root directory.
|
||||
///
|
||||
/// If no root directory is provided, relative paths are resolved against the current working
|
||||
/// directory.
|
||||
pub fn parse(path: &str, root_dir: Option<&Path>) -> Result<Self, IndexUrlError> {
|
||||
let url = match split_scheme(path) {
|
||||
Some((scheme, ..)) => {
|
||||
match Scheme::parse(scheme) {
|
||||
Some(_) => {
|
||||
// Ex) `https://pypi.org/simple`
|
||||
VerbatimUrl::parse_url(path)?
|
||||
}
|
||||
None => {
|
||||
// Ex) `C:\Users\user\index`
|
||||
if let Some(root_dir) = root_dir {
|
||||
VerbatimUrl::from_path(path, root_dir)?
|
||||
} else {
|
||||
VerbatimUrl::from_absolute_path(std::path::absolute(path)?)?
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Ex) `/Users/user/index`
|
||||
if let Some(root_dir) = root_dir {
|
||||
VerbatimUrl::from_path(path, root_dir)?
|
||||
} else {
|
||||
VerbatimUrl::from_absolute_path(std::path::absolute(path)?)?
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(Self::from(url.with_given(path)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "schemars")]
|
||||
impl schemars::JsonSchema for IndexUrl {
|
||||
fn schema_name() -> String {
|
||||
|
|
@ -114,25 +151,7 @@ impl FromStr for IndexUrl {
|
|||
type Err = IndexUrlError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let url = match split_scheme(s) {
|
||||
Some((scheme, ..)) => {
|
||||
match Scheme::parse(scheme) {
|
||||
Some(_) => {
|
||||
// Ex) `https://pypi.org/simple`
|
||||
VerbatimUrl::parse_url(s)?
|
||||
}
|
||||
None => {
|
||||
// Ex) `C:\Users\user\index`
|
||||
VerbatimUrl::from_absolute_path(std::path::absolute(s)?)?
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Ex) `/Users/user/index`
|
||||
VerbatimUrl::from_absolute_path(std::path::absolute(s)?)?
|
||||
}
|
||||
};
|
||||
Ok(Self::from(url.with_given(s)))
|
||||
Self::parse(s, None)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
//! flags set.
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{Index, IndexUrl};
|
||||
|
||||
|
|
@ -11,6 +12,12 @@ macro_rules! impl_index {
|
|||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct $name(Index);
|
||||
|
||||
impl $name {
|
||||
pub fn relative_to(self, root_dir: &Path) -> Result<Self, crate::IndexUrlError> {
|
||||
Ok(Self(self.0.relative_to(root_dir)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<$name> for Index {
|
||||
fn from(value: $name) -> Self {
|
||||
value.0
|
||||
|
|
|
|||
|
|
@ -108,8 +108,9 @@ impl FilesystemOptions {
|
|||
let path = dir.join("uv.toml");
|
||||
match fs_err::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
let options: Options = toml::from_str(&content)
|
||||
.map_err(|err| Error::UvToml(path.clone(), Box::new(err)))?;
|
||||
let options = toml::from_str::<Options>(&content)
|
||||
.map_err(|err| Error::UvToml(path.clone(), Box::new(err)))?
|
||||
.relative_to(&std::path::absolute(dir)?)?;
|
||||
|
||||
// If the directory also contains a `[tool.uv]` table in a `pyproject.toml` file,
|
||||
// warn.
|
||||
|
|
@ -155,6 +156,8 @@ impl FilesystemOptions {
|
|||
return Ok(None);
|
||||
};
|
||||
|
||||
let options = options.relative_to(&std::path::absolute(dir)?)?;
|
||||
|
||||
tracing::debug!("Found workspace configuration at `{}`", path.display());
|
||||
return Ok(Some(Self(options)));
|
||||
}
|
||||
|
|
@ -252,8 +255,13 @@ fn system_config_file() -> Option<PathBuf> {
|
|||
/// Load [`Options`] from a `uv.toml` file.
|
||||
fn read_file(path: &Path) -> Result<Options, Error> {
|
||||
let content = fs_err::read_to_string(path)?;
|
||||
let options: Options =
|
||||
toml::from_str(&content).map_err(|err| Error::UvToml(path.to_path_buf(), Box::new(err)))?;
|
||||
let options = toml::from_str::<Options>(&content)
|
||||
.map_err(|err| Error::UvToml(path.to_path_buf(), Box::new(err)))?;
|
||||
let options = if let Some(parent) = std::path::absolute(path)?.parent() {
|
||||
options.relative_to(parent)?
|
||||
} else {
|
||||
options
|
||||
};
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
|
|
@ -294,6 +302,9 @@ pub enum Error {
|
|||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Index(#[from] uv_distribution_types::IndexUrlError),
|
||||
|
||||
#[error("Failed to parse: `{}`", _0.user_display())]
|
||||
PyprojectToml(PathBuf, #[source] Box<toml::de::Error>),
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use std::{fmt::Debug, num::NonZeroUsize, path::PathBuf};
|
||||
use std::{fmt::Debug, num::NonZeroUsize, path::Path, path::PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
|
@ -9,7 +9,7 @@ use uv_configuration::{
|
|||
TargetTriple, TrustedHost, TrustedPublishing,
|
||||
};
|
||||
use uv_distribution_types::{
|
||||
Index, IndexUrl, PipExtraIndex, PipFindLinks, PipIndex, StaticMetadata,
|
||||
Index, IndexUrl, IndexUrlError, PipExtraIndex, PipFindLinks, PipIndex, StaticMetadata,
|
||||
};
|
||||
use uv_install_wheel::linker::LinkMode;
|
||||
use uv_macros::{CombineOptions, OptionsMetadata};
|
||||
|
|
@ -144,6 +144,15 @@ impl Options {
|
|||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the [`Options`] relative to the given root directory.
|
||||
pub fn relative_to(self, root_dir: &Path) -> Result<Self, IndexUrlError> {
|
||||
Ok(Self {
|
||||
top_level: self.top_level.relative_to(root_dir)?,
|
||||
pip: self.pip.map(|pip| pip.relative_to(root_dir)).transpose()?,
|
||||
..self
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Global settings, relevant to all invocations.
|
||||
|
|
@ -723,6 +732,46 @@ pub struct ResolverInstallerOptions {
|
|||
pub no_binary_package: Option<Vec<PackageName>>,
|
||||
}
|
||||
|
||||
impl ResolverInstallerOptions {
|
||||
/// Resolve the [`ResolverInstallerOptions`] relative to the given root directory.
|
||||
pub fn relative_to(self, root_dir: &Path) -> Result<Self, IndexUrlError> {
|
||||
Ok(Self {
|
||||
index: self
|
||||
.index
|
||||
.map(|index| {
|
||||
index
|
||||
.into_iter()
|
||||
.map(|index| index.relative_to(root_dir))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.transpose()?,
|
||||
index_url: self
|
||||
.index_url
|
||||
.map(|index_url| index_url.relative_to(root_dir))
|
||||
.transpose()?,
|
||||
extra_index_url: self
|
||||
.extra_index_url
|
||||
.map(|extra_index_url| {
|
||||
extra_index_url
|
||||
.into_iter()
|
||||
.map(|extra_index_url| extra_index_url.relative_to(root_dir))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.transpose()?,
|
||||
find_links: self
|
||||
.find_links
|
||||
.map(|find_links| {
|
||||
find_links
|
||||
.into_iter()
|
||||
.map(|find_link| find_link.relative_to(root_dir))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.transpose()?,
|
||||
..self
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared settings, relevant to all operations that might create managed python installations.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, CombineOptions, OptionsMetadata)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
|
|
@ -1480,6 +1529,46 @@ pub struct PipOptions {
|
|||
pub reinstall_package: Option<Vec<PackageName>>,
|
||||
}
|
||||
|
||||
impl PipOptions {
|
||||
/// Resolve the [`PipOptions`] relative to the given root directory.
|
||||
pub fn relative_to(self, root_dir: &Path) -> Result<Self, IndexUrlError> {
|
||||
Ok(Self {
|
||||
index: self
|
||||
.index
|
||||
.map(|index| {
|
||||
index
|
||||
.into_iter()
|
||||
.map(|index| index.relative_to(root_dir))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.transpose()?,
|
||||
index_url: self
|
||||
.index_url
|
||||
.map(|index_url| index_url.relative_to(root_dir))
|
||||
.transpose()?,
|
||||
extra_index_url: self
|
||||
.extra_index_url
|
||||
.map(|extra_index_url| {
|
||||
extra_index_url
|
||||
.into_iter()
|
||||
.map(|extra_index_url| extra_index_url.relative_to(root_dir))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.transpose()?,
|
||||
find_links: self
|
||||
.find_links
|
||||
.map(|find_links| {
|
||||
find_links
|
||||
.into_iter()
|
||||
.map(|find_link| find_link.relative_to(root_dir))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.transpose()?,
|
||||
..self
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ResolverInstallerOptions> for ResolverOptions {
|
||||
fn from(value: ResolverInstallerOptions) -> Self {
|
||||
Self {
|
||||
|
|
|
|||
|
|
@ -6115,3 +6115,49 @@ fn path_hash_mismatch() -> Result<()> {
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_links_relative_in_config_works_from_subdir() -> Result<()> {
|
||||
let context = TestContext::new("3.12");
|
||||
|
||||
let pyproject_toml = context.temp_dir.child("pyproject.toml");
|
||||
pyproject_toml.write_str(indoc! {r#"
|
||||
[project]
|
||||
name = "subdir_test"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["ok==1.0.0"]
|
||||
|
||||
[tool.uv]
|
||||
find-links = ["packages/"]
|
||||
"#})?;
|
||||
|
||||
// Create packages/ subdirectory and copy our "offline" tqdm wheel there
|
||||
let packages = context.temp_dir.child("packages");
|
||||
packages.create_dir_all()?;
|
||||
|
||||
let wheel_src = context
|
||||
.workspace_root
|
||||
.join("scripts/links/ok-1.0.0-py3-none-any.whl");
|
||||
let wheel_dst = packages.child("ok-1.0.0-py3-none-any.whl");
|
||||
fs_err::copy(&wheel_src, &wheel_dst)?;
|
||||
|
||||
// Create a separate subdir, which will become our working directory
|
||||
let subdir = context.temp_dir.child("subdir");
|
||||
subdir.create_dir_all()?;
|
||||
|
||||
// Run `uv sync --offline` from subdir. We expect it to find the local wheel in ../packages/.
|
||||
uv_snapshot!(context.filters(), context.sync().current_dir(&subdir).arg("--offline"), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
Resolved 2 packages in [TIME]
|
||||
Prepared 1 package in [TIME]
|
||||
Installed 1 package in [TIME]
|
||||
+ ok==1.0.0
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue