Skip to content

Commit 1234b6d

Browse files
authored
Allow customizing the project environment path with UV_PROJECT_ENVIRONMENT (#6834)
Allows configuration of the (currently hard-coded) path to the virtual environment in projects using the `UV_PROJECT_ENVIRONMENT` environment variable. If empty, we'll ignore it. If a relative path, it will be resolved relative to the workspace root. If an absolute path, we'll use that. This feature targets use in Docker images and CI. The variable is intended to be set once in an isolated system and used for all uv operations. We do not expose a CLI option or configuration file setting — we may pursue those later but I see them as lower priority. I think a system-level environment variable addresses the most pressing use-cases here. This doesn't special-case the system environment. Which means that you can use this to write to the system Python environment. I would generally strongly recommend against doing so. The insightful comment from @edmorley at #5229 (comment) provides some context on why. More generally, `uv sync` will remove packages from the environment by default. This means that if the system environment contains any packages relevant to the operation of the system (that are not dependencies of your project), `uv sync` will break it. I'd only use this in Docker or CI, if anywhere. Virtual environments have lots of benefits, and it's only [one line to "activate" them](https://docs.astral.sh/uv/guides/integration/docker/#using-the-environment). If you are considering using this feature to use Docker bind mounts for developing in containers, I would highly recommend reading our [Docker container development documentation](https://docs.astral.sh/uv/guides/integration/docker/#developing-in-a-container) first. If the solutions there do not work for you, please open an issue describing your use-case and why. We do not read `VIRTUAL_ENV` and do not have plans to at this time. Reading `VIRTUAL_ENV` is high-risk, because users can easily leave an environment active and use the uv project interface today. Reading `VIRTUAL_ENV` would be a breaking change. Additionally, uv is intentionally moving away from the concept of "active environments" and I don't think syncing to an "active" environment is the right behavior while managing projects. I plan to add a warning if `VIRTUAL_ENV` is set, to avoid confusion in this area (see #6864). This does not directly enable centrally managed virtual environments. If you set `UV_PROJECT_ENVIRONMENT` to an absolute path and use it across multiple projects, they will clobber each other's environments. However, you could use this with something like `direnv` to achieve "centrally managed" environments. I intend to build a prototype of this eventually. See #1495 for more details on this use-case. Lots of discussion about this feature in: - astral-sh/rye#371 - astral-sh/rye#1222 - astral-sh/rye#1211 - #5229 - #6669 - #6612 Follow-ups: - #6835 - #6864 - Document this in the project concept documentation (can probably re-use some of this post) Closes #6669 Closes #5229 Closes #6612
1 parent bc7b6f1 commit 1234b6d

File tree

4 files changed

+370
-19
lines changed

4 files changed

+370
-19
lines changed

crates/uv-workspace/src/workspace.rs

+24-1
Original file line numberDiff line numberDiff line change
@@ -435,8 +435,31 @@ impl Workspace {
435435
}
436436

437437
/// The path to the workspace virtual environment.
438+
///
439+
/// Uses `.venv` in the install path directory by default.
440+
///
441+
/// If `UV_PROJECT_ENVIRONMENT` is set, it will take precedence. If a relative path is provided,
442+
/// it is resolved relative to the install path.
438443
pub fn venv(&self) -> PathBuf {
439-
self.install_path.join(".venv")
444+
/// Resolve the `UV_PROJECT_ENVIRONMENT` value, if any.
445+
fn from_environment_variable(workspace: &Workspace) -> Option<PathBuf> {
446+
let value = std::env::var_os("UV_PROJECT_ENVIRONMENT")?;
447+
448+
if value.is_empty() {
449+
return None;
450+
};
451+
452+
let path = PathBuf::from(value);
453+
if path.is_absolute() {
454+
return Some(path);
455+
};
456+
457+
// Resolve the path relative to the install path.
458+
Some(workspace.install_path.join(path))
459+
}
460+
461+
// TODO(zanieb): Warn if `VIRTUAL_ENV` is set and does not match
462+
from_environment_variable(self).unwrap_or_else(|| self.install_path.join(".venv"))
440463
}
441464

442465
/// The members of the workspace.

crates/uv/tests/common/mod.rs

+35-17
Original file line numberDiff line numberDiff line change
@@ -165,31 +165,49 @@ impl TestContext {
165165
self
166166
}
167167

168-
/// Create a new test context with multiple Python versions.
169-
///
170-
/// Does not create a virtual environment by default, but the first Python version
171-
/// can be used to create a virtual environment with [`TestContext::create_venv`].
168+
/// Add extra standard filtering for a given path.
169+
#[must_use]
170+
pub fn with_filtered_path(mut self, path: &Path, name: &str) -> Self {
171+
// Note this is sloppy, ideally we wouldn't push to the front of the `Vec` but we need
172+
// this to come in front of other filters or we can transform the path (e.g., with `[TMP]`)
173+
// before we reach this filter.
174+
for pattern in Self::path_patterns(path)
175+
.into_iter()
176+
.map(|pattern| (pattern, format!("[{name}]/")))
177+
{
178+
self.filters.insert(0, pattern);
179+
}
180+
self
181+
}
182+
/// Discover the path to the XDG state directory. We use this, rather than the OS-specific
183+
/// temporary directory, because on macOS (and Windows on GitHub Actions), they involve
184+
/// symlinks. (On macOS, the temporary directory is, like `/var/...`, which resolves to
185+
/// `/private/var/...`.)
172186
///
173-
/// See [`TestContext::new`] if only a single version is desired.
174-
pub fn new_with_versions(python_versions: &[&str]) -> Self {
175-
// Discover the path to the XDG state directory. We use this, rather than the OS-specific
176-
// temporary directory, because on macOS (and Windows on GitHub Actions), they involve
177-
// symlinks. (On macOS, the temporary directory is, like `/var/...`, which resolves to
178-
// `/private/var/...`.)
179-
//
180-
// It turns out that, at least on macOS, if we pass a symlink as `current_dir`, it gets
181-
// _immediately_ resolved (such that if you call `current_dir` in the running `Command`, it
182-
// returns resolved symlink). This is problematic, as we _don't_ want to resolve symlinks
183-
// for user-provided paths.
184-
let bucket = env::var("UV_INTERNAL__TEST_DIR")
187+
/// It turns out that, at least on macOS, if we pass a symlink as `current_dir`, it gets
188+
/// _immediately_ resolved (such that if you call `current_dir` in the running `Command`, it
189+
/// returns resolved symlink). This is problematic, as we _don't_ want to resolve symlinks
190+
/// for user-provided paths.
191+
pub fn test_bucket_dir() -> PathBuf {
192+
env::var("UV_INTERNAL__TEST_DIR")
185193
.map(PathBuf::from)
186194
.unwrap_or_else(|_| {
187195
etcetera::base_strategy::choose_base_strategy()
188196
.expect("Failed to find base strategy")
189197
.data_dir()
190198
.join("uv")
191199
.join("tests")
192-
});
200+
})
201+
}
202+
203+
/// Create a new test context with multiple Python versions.
204+
///
205+
/// Does not create a virtual environment by default, but the first Python version
206+
/// can be used to create a virtual environment with [`TestContext::create_venv`].
207+
///
208+
/// See [`TestContext::new`] if only a single version is desired.
209+
pub fn new_with_versions(python_versions: &[&str]) -> Self {
210+
let bucket = Self::test_bucket_dir();
193211
fs_err::create_dir_all(&bucket).expect("Failed to create test bucket");
194212

195213
let root = tempfile::TempDir::new_in(bucket).expect("Failed to create test root directory");

crates/uv/tests/sync.rs

+265-1
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
use anyhow::Result;
44
use assert_cmd::prelude::*;
5-
use assert_fs::prelude::*;
5+
use assert_fs::{fixture::ChildPath, prelude::*};
66
use insta::assert_snapshot;
77

88
use common::{uv_snapshot, TestContext};
9+
use predicates::prelude::predicate;
10+
use tempfile::tempdir_in;
911

1012
mod common;
1113

@@ -1483,3 +1485,265 @@ fn convert_to_package() -> Result<()> {
14831485

14841486
Ok(())
14851487
}
1488+
1489+
#[test]
1490+
fn sync_custom_environment_path() -> Result<()> {
1491+
let mut context = TestContext::new("3.12");
1492+
1493+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
1494+
pyproject_toml.write_str(
1495+
r#"
1496+
[project]
1497+
name = "project"
1498+
version = "0.1.0"
1499+
requires-python = ">=3.12"
1500+
dependencies = ["iniconfig"]
1501+
"#,
1502+
)?;
1503+
1504+
// Running `uv sync` should create `.venv` by default
1505+
uv_snapshot!(context.filters(), context.sync(), @r###"
1506+
success: true
1507+
exit_code: 0
1508+
----- stdout -----
1509+
1510+
----- stderr -----
1511+
Resolved 2 packages in [TIME]
1512+
Prepared 1 package in [TIME]
1513+
Installed 1 package in [TIME]
1514+
+ iniconfig==2.0.0
1515+
"###);
1516+
1517+
context
1518+
.temp_dir
1519+
.child(".venv")
1520+
.assert(predicate::path::is_dir());
1521+
1522+
// Running `uv sync` should create `foo` in the project directory when customized
1523+
uv_snapshot!(context.filters(), context.sync().env("UV_PROJECT_ENVIRONMENT", "foo"), @r###"
1524+
success: true
1525+
exit_code: 0
1526+
----- stdout -----
1527+
1528+
----- stderr -----
1529+
Using Python 3.12.[X] interpreter at: [PYTHON-3.12]
1530+
Creating virtualenv at: foo
1531+
Resolved 2 packages in [TIME]
1532+
Prepared 1 package in [TIME]
1533+
Installed 1 package in [TIME]
1534+
+ iniconfig==2.0.0
1535+
"###);
1536+
1537+
context
1538+
.temp_dir
1539+
.child("foo")
1540+
.assert(predicate::path::is_dir());
1541+
1542+
// We don't delete `.venv`, though we arguably could
1543+
context
1544+
.temp_dir
1545+
.child(".venv")
1546+
.assert(predicate::path::is_dir());
1547+
1548+
// An absolute path can be provided
1549+
uv_snapshot!(context.filters(), context.sync().env("UV_PROJECT_ENVIRONMENT", "foobar/.venv"), @r###"
1550+
success: true
1551+
exit_code: 0
1552+
----- stdout -----
1553+
1554+
----- stderr -----
1555+
Using Python 3.12.[X] interpreter at: [PYTHON-3.12]
1556+
Creating virtualenv at: foobar/.venv
1557+
Resolved 2 packages in [TIME]
1558+
Prepared 1 package in [TIME]
1559+
Installed 1 package in [TIME]
1560+
+ iniconfig==2.0.0
1561+
"###);
1562+
1563+
context
1564+
.temp_dir
1565+
.child("foobar")
1566+
.assert(predicate::path::is_dir());
1567+
1568+
context
1569+
.temp_dir
1570+
.child("foobar")
1571+
.child(".venv")
1572+
.assert(predicate::path::is_dir());
1573+
1574+
// An absolute path can be provided
1575+
uv_snapshot!(context.filters(), context.sync().env("UV_PROJECT_ENVIRONMENT", context.temp_dir.join("bar")), @r###"
1576+
success: true
1577+
exit_code: 0
1578+
----- stdout -----
1579+
1580+
----- stderr -----
1581+
Using Python 3.12.[X] interpreter at: [PYTHON-3.12]
1582+
Creating virtualenv at: bar
1583+
Resolved 2 packages in [TIME]
1584+
Prepared 1 package in [TIME]
1585+
Installed 1 package in [TIME]
1586+
+ iniconfig==2.0.0
1587+
"###);
1588+
1589+
context
1590+
.temp_dir
1591+
.child("bar")
1592+
.assert(predicate::path::is_dir());
1593+
1594+
// And, it can be outside the project
1595+
let tempdir = tempdir_in(TestContext::test_bucket_dir())?;
1596+
context = context.with_filtered_path(tempdir.path(), "OTHER_TEMPDIR");
1597+
uv_snapshot!(context.filters(), context.sync().env("UV_PROJECT_ENVIRONMENT", tempdir.path().join(".venv")), @r###"
1598+
success: true
1599+
exit_code: 0
1600+
----- stdout -----
1601+
1602+
----- stderr -----
1603+
Using Python 3.12.[X] interpreter at: [PYTHON-3.12]
1604+
Creating virtualenv at: [OTHER_TEMPDIR]/.venv
1605+
Resolved 2 packages in [TIME]
1606+
Prepared 1 package in [TIME]
1607+
Installed 1 package in [TIME]
1608+
+ iniconfig==2.0.0
1609+
"###);
1610+
1611+
ChildPath::new(tempdir.path())
1612+
.child(".venv")
1613+
.assert(predicate::path::is_dir());
1614+
1615+
Ok(())
1616+
}
1617+
1618+
#[test]
1619+
fn sync_workspace_custom_environment_path() -> Result<()> {
1620+
let context = TestContext::new("3.12");
1621+
1622+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
1623+
pyproject_toml.write_str(
1624+
r#"
1625+
[project]
1626+
name = "project"
1627+
version = "0.1.0"
1628+
requires-python = ">=3.12"
1629+
dependencies = ["iniconfig"]
1630+
"#,
1631+
)?;
1632+
1633+
// Create a workspace member
1634+
context.init().arg("child").assert().success();
1635+
1636+
// Running `uv sync` should create `.venv` in the workspace root
1637+
uv_snapshot!(context.filters(), context.sync(), @r###"
1638+
success: true
1639+
exit_code: 0
1640+
----- stdout -----
1641+
1642+
----- stderr -----
1643+
Resolved 3 packages in [TIME]
1644+
Prepared 1 package in [TIME]
1645+
Installed 1 package in [TIME]
1646+
+ iniconfig==2.0.0
1647+
"###);
1648+
1649+
context
1650+
.temp_dir
1651+
.child(".venv")
1652+
.assert(predicate::path::is_dir());
1653+
1654+
// Similarly, `uv sync` from the child project uses `.venv` in the workspace root
1655+
uv_snapshot!(context.filters(), context.sync().current_dir(context.temp_dir.join("child")), @r###"
1656+
success: true
1657+
exit_code: 0
1658+
----- stdout -----
1659+
1660+
----- stderr -----
1661+
Resolved 3 packages in [TIME]
1662+
Uninstalled 1 package in [TIME]
1663+
- iniconfig==2.0.0
1664+
"###);
1665+
1666+
context
1667+
.temp_dir
1668+
.child(".venv")
1669+
.assert(predicate::path::is_dir());
1670+
1671+
context
1672+
.temp_dir
1673+
.child("child")
1674+
.child(".venv")
1675+
.assert(predicate::path::missing());
1676+
1677+
// Running `uv sync` should create `foo` in the workspace root when customized
1678+
uv_snapshot!(context.filters(), context.sync().env("UV_PROJECT_ENVIRONMENT", "foo"), @r###"
1679+
success: true
1680+
exit_code: 0
1681+
----- stdout -----
1682+
1683+
----- stderr -----
1684+
Using Python 3.12.[X] interpreter at: [PYTHON-3.12]
1685+
Creating virtualenv at: foo
1686+
Resolved 3 packages in [TIME]
1687+
Prepared 1 package in [TIME]
1688+
Installed 1 package in [TIME]
1689+
+ iniconfig==2.0.0
1690+
"###);
1691+
1692+
context
1693+
.temp_dir
1694+
.child("foo")
1695+
.assert(predicate::path::is_dir());
1696+
1697+
// We don't delete `.venv`, though we arguably could
1698+
context
1699+
.temp_dir
1700+
.child(".venv")
1701+
.assert(predicate::path::is_dir());
1702+
1703+
// Similarly, `uv sync` from the child project uses `foo` relative to the workspace root
1704+
uv_snapshot!(context.filters(), context.sync().env("UV_PROJECT_ENVIRONMENT", "foo").current_dir(context.temp_dir.join("child")), @r###"
1705+
success: true
1706+
exit_code: 0
1707+
----- stdout -----
1708+
1709+
----- stderr -----
1710+
Resolved 3 packages in [TIME]
1711+
Uninstalled 1 package in [TIME]
1712+
- iniconfig==2.0.0
1713+
"###);
1714+
1715+
context
1716+
.temp_dir
1717+
.child("foo")
1718+
.assert(predicate::path::is_dir());
1719+
1720+
context
1721+
.temp_dir
1722+
.child("child")
1723+
.child("foo")
1724+
.assert(predicate::path::missing());
1725+
1726+
// And, `uv sync --package child` uses `foo` relative to the workspace root
1727+
uv_snapshot!(context.filters(), context.sync().arg("--package").arg("child").env("UV_PROJECT_ENVIRONMENT", "foo"), @r###"
1728+
success: true
1729+
exit_code: 0
1730+
----- stdout -----
1731+
1732+
----- stderr -----
1733+
Resolved 3 packages in [TIME]
1734+
Audited in [TIME]
1735+
"###);
1736+
1737+
context
1738+
.temp_dir
1739+
.child("foo")
1740+
.assert(predicate::path::is_dir());
1741+
1742+
context
1743+
.temp_dir
1744+
.child("child")
1745+
.child("foo")
1746+
.assert(predicate::path::missing());
1747+
1748+
Ok(())
1749+
}

0 commit comments

Comments
 (0)