Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions crates/uv-distribution-types/src/index.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::path::Path;
use std::str::FromStr;

use thiserror::Error;
Expand Down Expand Up @@ -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 {
Expand Down
57 changes: 38 additions & 19 deletions crates/uv-distribution-types/src/index_url.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
}

Expand Down
7 changes: 7 additions & 0 deletions crates/uv-distribution-types/src/pip_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! flags set.

use serde::{Deserialize, Deserializer, Serialize};
use std::path::Path;

use crate::{Index, IndexUrl};

Expand All @@ -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
Expand Down
19 changes: 15 additions & 4 deletions crates/uv-settings/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)));
}
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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>),

Expand Down
93 changes: 91 additions & 2 deletions crates/uv-settings/src/settings.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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};
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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 {
Expand Down
46 changes: 46 additions & 0 deletions crates/uv/tests/it/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6226,3 +6226,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(())
}