Skip to content

Commit

Permalink
Allow customizing the project environment path with `UV_PROJECT_ENVIR…
Browse files Browse the repository at this point in the history
…ONMENT`
  • Loading branch information
zanieb committed Aug 29, 2024
1 parent 09e359f commit 55cc043
Show file tree
Hide file tree
Showing 4 changed files with 371 additions and 19 deletions.
25 changes: 24 additions & 1 deletion crates/uv-workspace/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,8 +435,31 @@ impl Workspace {
}

/// The path to the workspace virtual environment.
///
/// Uses `.venv` in the install path directory by default.
///
/// If `UV_PROJECT_ENVIRONMENT` is set, it will take precedence. If a relative path is provided,
/// it is resolved relative to the install path.
pub fn venv(&self) -> PathBuf {
self.install_path.join(".venv")
/// Resolve the `UV_PROJECT_ENVIRONMENT` value, if any.
fn from_environment_variable(workspace: &Workspace) -> Option<PathBuf> {
let value = std::env::var_os("UV_PROJECT_ENVIRONMENT")?;

if value.is_empty() {
return None;
};

let path = PathBuf::from(value);
if path.is_absolute() {
return Some(path);
};

// Resolve the path relative to the install path.
Some(workspace.install_path.join(path))
}

// TODO(zanieb): Warn if `VIRTUAL_ENV` is set and does not match
from_environment_variable(self).unwrap_or_else(|| self.install_path.join(".venv"))
}

/// The members of the workspace.
Expand Down
52 changes: 35 additions & 17 deletions crates/uv/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,31 +165,49 @@ impl TestContext {
self
}

/// Create a new test context with multiple Python versions.
///
/// Does not create a virtual environment by default, but the first Python version
/// can be used to create a virtual environment with [`TestContext::create_venv`].
/// Add extra standard filtering for a given path.
#[must_use]
pub fn with_filtered_path(mut self, path: &Path, name: &str) -> Self {
// Note this is sloppy, ideally we wouldn't push to the front of the `Vec` but we need
// this to come in front of other filters or we can transform the path (e.g., with `[TMP]`)
// before we reach this filter.
for pattern in Self::path_patterns(path)
.into_iter()
.map(|pattern| (pattern, format!("[{name}]/")))
{
self.filters.insert(0, pattern)
}
self
}
/// Discover the path to the XDG state directory. We use this, rather than the OS-specific
/// temporary directory, because on macOS (and Windows on GitHub Actions), they involve
/// symlinks. (On macOS, the temporary directory is, like `/var/...`, which resolves to
/// `/private/var/...`.)
///
/// See [`TestContext::new`] if only a single version is desired.
pub fn new_with_versions(python_versions: &[&str]) -> Self {
// Discover the path to the XDG state directory. We use this, rather than the OS-specific
// temporary directory, because on macOS (and Windows on GitHub Actions), they involve
// symlinks. (On macOS, the temporary directory is, like `/var/...`, which resolves to
// `/private/var/...`.)
//
// It turns out that, at least on macOS, if we pass a symlink as `current_dir`, it gets
// _immediately_ resolved (such that if you call `current_dir` in the running `Command`, it
// returns resolved symlink). This is problematic, as we _don't_ want to resolve symlinks
// for user-provided paths.
let bucket = env::var("UV_INTERNAL__TEST_DIR")
/// It turns out that, at least on macOS, if we pass a symlink as `current_dir`, it gets
/// _immediately_ resolved (such that if you call `current_dir` in the running `Command`, it
/// returns resolved symlink). This is problematic, as we _don't_ want to resolve symlinks
/// for user-provided paths.
pub fn test_bucket_dir() -> PathBuf {
env::var("UV_INTERNAL__TEST_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| {
etcetera::base_strategy::choose_base_strategy()
.expect("Failed to find base strategy")
.data_dir()
.join("uv")
.join("tests")
});
})
}

/// Create a new test context with multiple Python versions.
///
/// Does not create a virtual environment by default, but the first Python version
/// can be used to create a virtual environment with [`TestContext::create_venv`].
///
/// See [`TestContext::new`] if only a single version is desired.
pub fn new_with_versions(python_versions: &[&str]) -> Self {
let bucket = Self::test_bucket_dir();
fs_err::create_dir_all(&bucket).expect("Failed to create test bucket");

let root = tempfile::TempDir::new_in(bucket).expect("Failed to create test root directory");
Expand Down
267 changes: 266 additions & 1 deletion crates/uv/tests/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

use anyhow::Result;
use assert_cmd::prelude::*;
use assert_fs::prelude::*;
use assert_fs::{fixture::ChildPath, prelude::*};
use insta::assert_snapshot;

use common::{uv_snapshot, TestContext};
use predicates::prelude::predicate;
use tempfile::tempdir_in;

mod common;

Expand Down Expand Up @@ -1470,3 +1472,266 @@ fn convert_to_package() -> Result<()> {

Ok(())
}

#[test]
fn sync_custom_environment_path() -> Result<()> {
let mut context = TestContext::new("3.12");

let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["iniconfig"]
"#,
)?;

// Running `uv sync` should create `.venv` by default
uv_snapshot!(context.filters(), context.sync(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
"###);

context
.temp_dir
.child(".venv")
.assert(predicate::path::is_dir());

// Running `uv sync` should create `foo` in the project directory when customized
uv_snapshot!(context.filters(), context.sync().env("UV_PROJECT_ENVIRONMENT", "foo"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using Python 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtualenv at: foo
Resolved 2 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
"###);

context
.temp_dir
.child("foo")
.assert(predicate::path::is_dir());

// We don't delete `.venv`, though we arguably could
context
.temp_dir
.child(".venv")
.assert(predicate::path::is_dir());

// An absolute path can be provided
uv_snapshot!(context.filters(), context.sync().env("UV_PROJECT_ENVIRONMENT", "foobar/.venv"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using Python 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtualenv at: foobar/.venv
Resolved 2 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
"###);

context
.temp_dir
.child("foobar")
.assert(predicate::path::is_dir());

context
.temp_dir
.child("foobar")
.child(".venv")
.assert(predicate::path::is_dir());

// An absolute path can be provided
uv_snapshot!(context.filters(), context.sync().env("UV_PROJECT_ENVIRONMENT", context.temp_dir.join("bar")), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using Python 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtualenv at: bar
Resolved 2 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
"###);

context
.temp_dir
.child("bar")
.assert(predicate::path::is_dir());

// And, it can be outside the project
let tempdir = tempdir_in(TestContext::test_bucket_dir())?;
dbg!(tempdir.path());
context = context.with_filtered_path(tempdir.path(), "OTHER_TEMPDIR");
uv_snapshot!(context.filters(), context.sync().env("UV_PROJECT_ENVIRONMENT", tempdir.path().join(".venv")), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using Python 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtualenv at: [OTHER_TEMPDIR]/.venv
Resolved 2 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
"###);

ChildPath::new(tempdir.path())
.child(".venv")
.assert(predicate::path::is_dir());

Ok(())
}

#[test]
fn sync_workspace_custom_environment_path() -> Result<()> {
let context = TestContext::new("3.12");

let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["iniconfig"]
"#,
)?;

// Create a workspace member
context.init().arg("child").assert().success();

// Running `uv sync` should create `.venv` in the workspace root
uv_snapshot!(context.filters(), context.sync(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
"###);

context
.temp_dir
.child(".venv")
.assert(predicate::path::is_dir());

// Similarly, `uv sync` from the child project uses `.venv` in the workspace root
uv_snapshot!(context.filters(), context.sync().current_dir(context.temp_dir.join("child")), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
Uninstalled 1 package in [TIME]
- iniconfig==2.0.0
"###);

context
.temp_dir
.child(".venv")
.assert(predicate::path::is_dir());

context
.temp_dir
.child("child")
.child(".venv")
.assert(predicate::path::missing());

// Running `uv sync` should create `foo` in the workspace root when customized
uv_snapshot!(context.filters(), context.sync().env("UV_PROJECT_ENVIRONMENT", "foo"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using Python 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtualenv at: foo
Resolved 3 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
"###);

context
.temp_dir
.child("foo")
.assert(predicate::path::is_dir());

// We don't delete `.venv`, though we arguably could
context
.temp_dir
.child(".venv")
.assert(predicate::path::is_dir());

// Similarly, `uv sync` from the child project uses `foo` relative to the workspace root
uv_snapshot!(context.filters(), context.sync().env("UV_PROJECT_ENVIRONMENT", "foo").current_dir(context.temp_dir.join("child")), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
Uninstalled 1 package in [TIME]
- iniconfig==2.0.0
"###);

context
.temp_dir
.child("foo")
.assert(predicate::path::is_dir());

context
.temp_dir
.child("child")
.child("foo")
.assert(predicate::path::missing());

// And, `uv sync --package child` uses `foo` relative to the workspace root
uv_snapshot!(context.filters(), context.sync().arg("--package").arg("child").env("UV_PROJECT_ENVIRONMENT", "foo"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
Audited in [TIME]
"###);

context
.temp_dir
.child("foo")
.assert(predicate::path::is_dir());

context
.temp_dir
.child("child")
.child("foo")
.assert(predicate::path::missing());

Ok(())
}
Loading

0 comments on commit 55cc043

Please sign in to comment.