Skip to content

Commit

Permalink
Support private locks and environments for workspace members (astral-…
Browse files Browse the repository at this point in the history
  • Loading branch information
idlsoft committed Oct 15, 2024
1 parent 169f729 commit c8ba783
Show file tree
Hide file tree
Showing 12 changed files with 287 additions and 54 deletions.
162 changes: 108 additions & 54 deletions crates/uv-workspace/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ use uv_static::EnvVars;
use uv_warnings::{warn_user, warn_user_once};

use crate::pyproject::{
Project, PyProjectToml, PyprojectTomlError, Source, Sources, ToolUvSources, ToolUvWorkspace,
Project, PyProjectToml, PyprojectTomlError, Source, Sources, ToolUv, ToolUvSources,
ToolUvWorkspace,
};

#[derive(thiserror::Error, Debug)]
Expand Down Expand Up @@ -570,6 +571,10 @@ impl Workspace {
pyproject_toml: workspace_pyproject_toml,
})
}

fn shared_lock_members(&self) -> impl Iterator<Item = &WorkspaceMember> + '_ {
self.packages().values().filter(|p| !p.private_lock)
}
}

/// A project in a workspace.
Expand All @@ -584,7 +589,6 @@ pub struct WorkspaceMember {
/// The `pyproject.toml` of the project, found at `<root>/pyproject.toml`.
pyproject_toml: PyProjectToml,
/// does this member require a private lock
#[allow(dead_code)]
private_lock: bool,
}

Expand Down Expand Up @@ -1281,10 +1285,21 @@ impl VirtualProject {
}

pub fn packages_to_resolve(&self) -> impl Iterator<Item = &PackageName> + '_ {
self.workspace().packages().keys()
if let Some(project) = self.private_project() {
Either::Left(std::iter::once(&project.project.name))
} else {
Either::Right(
self.workspace()
.shared_lock_members()
.map(|p| &p.project.name),
)
}
}

pub fn packages_to_lock(&self) -> Vec<PackageName> {
if self.private_project().is_some() {
return vec![];
}
let mut members = self.packages_to_resolve().cloned().collect::<Vec<_>>();
members.sort();

Expand All @@ -1300,7 +1315,12 @@ impl VirtualProject {

/// Returns the set of requirements that include all packages in the workspace.
pub fn members_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
self.workspace().packages.values().filter_map(|member| {
let packages = if let Some(member) = self.private_project() {
Either::Left(std::iter::once(member))
} else {
Either::Right(self.workspace().shared_lock_members())
};
packages.filter_map(|member| {
let url = VerbatimUrl::from_absolute_path(&member.root)
.expect("path is valid URL")
.with_given(member.root.to_string_lossy());
Expand Down Expand Up @@ -1330,27 +1350,7 @@ impl VirtualProject {

/// Returns the set of overrides for the workspace.
pub fn overrides(&self) -> Vec<Requirement> {
let Some(overrides) = self
.workspace()
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.override_dependencies.as_ref())
else {
return vec![];
};

overrides
.iter()
.map(|requirement| {
Requirement::from(
requirement
.clone()
.with_origin(RequirementOrigin::Workspace),
)
})
.collect()
self.uv_dependencies(|uv| uv.override_dependencies.as_ref())
}

/// Returns the set of supported environments for the workspace.
Expand All @@ -1365,27 +1365,7 @@ impl VirtualProject {

/// Returns the set of constraints for the workspace.
pub fn constraints(&self) -> Vec<Requirement> {
let Some(constraints) = self
.workspace()
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.constraint_dependencies.as_ref())
else {
return vec![];
};

constraints
.iter()
.map(|requirement| {
Requirement::from(
requirement
.clone()
.with_origin(RequirementOrigin::Workspace),
)
})
.collect()
self.uv_dependencies(|uv| uv.constraint_dependencies.as_ref())
}

/// The path to the workspace virtual environment.
Expand Down Expand Up @@ -1456,8 +1436,14 @@ impl VirtualProject {
}

// Determine the default value
let project_env = from_project_environment_variable(self.workspace())
.unwrap_or_else(|| self.workspace().install_path.join(".venv"));
let project_env =
from_project_environment_variable(self.workspace()).unwrap_or_else(|| {
if let Some(private_project) = self.private_project() {
private_project.root.join(".venv")
} else {
self.workspace().install_path().join(".venv")
}
});

// Warn if it conflicts with `VIRTUAL_ENV`
if let Some(from_virtual_env) = from_virtual_env_variable() {
Expand All @@ -1475,7 +1461,67 @@ impl VirtualProject {

/// The path to the workspace lockfile
pub fn lockfile(&self) -> PathBuf {
self.workspace().install_path().join("uv.lock")
if let Some(private_project) = self.private_project() {
private_project.root.join("uv.lock")
} else {
self.workspace().install_path().join("uv.lock")
}
}

fn private_project(&self) -> Option<&WorkspaceMember> {
if let VirtualProject::Project(project) = self {
if let Some(project) = &self.workspace().packages.get(project.project_name()) {
if project.private_lock {
return Some(project);
}
}
}
None
}

/// Gets dependencies defined in `[tool.uv]`
/// Tries private project first, then defaults to workspace.
fn uv_dependencies(
&self,
func: fn(&ToolUv) -> Option<&Vec<uv_pep508::Requirement<VerbatimParsedUrl>>>,
) -> Vec<Requirement> {
let dependencies_and_origin = if let Some(private_project) = self.private_project() {
private_project
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(func)
.map(|dependencies| {
(
dependencies,
RequirementOrigin::Project(
private_project.root.clone(),
private_project.project.name.clone(),
),
)
})
} else {
None
}
.or(self
.workspace()
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(func)
.map(|dependencies| (dependencies, RequirementOrigin::Workspace)));

if let Some((dependencies, origin)) = dependencies_and_origin {
return dependencies
.iter()
.map(|requirement| {
Requirement::from(requirement.clone().with_origin(origin.clone()))
})
.collect();
}
vec![]
}

/// Determine a name for the environment, in order of preference:
Expand All @@ -1484,14 +1530,20 @@ impl VirtualProject {
/// 2) The name of the directory at the root of the workspace
/// 3) No prompt
pub fn venv_name(&self) -> Option<String> {
self.workspace()
.pyproject_toml()
let (pyproject_toml, project_path) = if let Some(private_project) = self.private_project() {
(&private_project.pyproject_toml, private_project.root())
} else {
(
self.workspace().pyproject_toml(),
self.workspace().install_path(),
)
};
pyproject_toml
.project
.as_ref()
.map(|p| p.name.to_string())
.or_else(|| {
self.workspace()
.install_path()
project_path
.file_name()
.map(|f| f.to_string_lossy().to_string())
})
Expand Down Expand Up @@ -1528,7 +1580,9 @@ impl<'env> InstallTarget<'env> {
pub fn packages(&self) -> impl Iterator<Item = &PackageName> {
match self {
Self::Project(project) => Either::Left(std::iter::once(project.project_name())),
Self::NonProject(workspace) => Either::Right(workspace.packages().keys()),
Self::NonProject(workspace) => {
Either::Right(workspace.shared_lock_members().map(|p| &p.project.name))
}
Self::FrozenMember(_, package_name) => Either::Left(std::iter::once(*package_name)),
}
}
Expand Down
64 changes: 64 additions & 0 deletions crates/uv/tests/it/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1721,3 +1721,67 @@ fn test_path_hopping() -> Result<()> {

Ok(())
}

#[test]
fn test_shared_libraries() {
let context = TestContext::new("3.12").with_filtered_counts();
let work_dir = context.temp_dir.join("shared-lib-ws");
copy_dir_ignore(workspaces_dir().join("shared-libs"), &work_dir)
.expect("Could not copy to temp dir");

uv_snapshot!(context.filters(), context.sync().env_remove("UV_EXCLUDE_NEWER")
.current_dir(&work_dir), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtual environment at: .venv
Resolved [N] packages in [TIME]
Prepared [N] packages in [TIME]
Installed [N] packages in [TIME]
+ shared-corelib==1.0.0 (from file://[TEMP_DIR]/shared-lib-ws/shared_corelib)
+ shared-lib==1.0.0 (from file://[TEMP_DIR]/shared-lib-ws/shared_lib)
+ six==1.16.0
+ tqdm==4.66.2
"###);

for (app_name, numpy_version) in [("app1", "2.0.0"), ("app2", "1.26.4")] {
let app_dir = work_dir.join(app_name);

let mut filters = context.filters();

filters.push((app_name, "[app_name]"));
filters.push((numpy_version, "[numpy_version]"));

insta::allow_duplicates! {
uv_snapshot!(filters, context.run().env_remove("UV_EXCLUDE_NEWER")
.arg(format!("{app_name}/app.py"))
.current_dir(&app_dir), @r###"
success: true
exit_code: 0
----- stdout -----
shared-lib is loaded from [TEMP_DIR]/shared-lib-ws/shared_lib/shared_lib/__init__.py
six 1.16.0
shared-corelib is loaded from [TEMP_DIR]/shared-lib-ws/shared_corelib/shared_corelib/__init__.py
tqdm 4.66.2
numpy [numpy_version]
----- stderr -----
warning: `VIRTUAL_ENV=[VENV]/` does not match the project environment path `.venv` and will be ignored
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtual environment at: .venv
Resolved [N] packages in [TIME]
Prepared [N] packages in [TIME]
Installed [N] packages in [TIME]
+ [app_name]==1.0.0 (from file://[TEMP_DIR]/shared-lib-ws/[app_name])
+ numpy==[numpy_version]
+ shared-corelib==1.0.0 (from file://[TEMP_DIR]/shared-lib-ws/shared_corelib)
+ shared-lib==1.0.0 (from file://[TEMP_DIR]/shared-lib-ws/shared_lib)
+ six==1.16.0
+ tqdm==4.66.2
"###);
}
}
}
15 changes: 15 additions & 0 deletions scripts/workspaces/shared-libs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
This is a simple workspace with 2 applications `app1` and `app2` and 2 libraries.

The applications have conflicting dependencies:

- `app1` depends on `numpy == 2.0.0`
- `app2` depends on `numpy == 1.26.4`

Both `app1` and `app2` depend on another workspace member `shared_lib`, which in turn depends on
`shared_corelib`.

The workspace will create 3 `uv.lock` and `.venv`:

- in the root, containing `shared_lib` and `shared_corelib`
- in `app1`, with `app1`, `shared_lib`, `shared_corelib`
- in `app2`, with `app2`, `shared_lib`, `shared_corelib`
9 changes: 9 additions & 0 deletions scripts/workspaces/shared-libs/app1/app1/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from shared_lib import hello_shared
import numpy as np

def main():
hello_shared()
print(f"numpy {np.__version__}")

if __name__ == "__main__":
main()
18 changes: 18 additions & 0 deletions scripts/workspaces/shared-libs/app1/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[project]
name = "app1"
version = "1.0.0"
requires-python = ">=3.12"
dependencies=[
"shared_lib",
"numpy == 2.0.0",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["app1"]

[tool.uv.sources]
shared_lib = { workspace = true }
9 changes: 9 additions & 0 deletions scripts/workspaces/shared-libs/app2/app2/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from shared_lib import hello_shared
import numpy as np

def main():
hello_shared()
print(f"numpy {np.__version__}")

if __name__ == "__main__":
main()
18 changes: 18 additions & 0 deletions scripts/workspaces/shared-libs/app2/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[project]
name = "app2"
version = "1.0.0"
requires-python = ">=3.12"
dependencies=[
"shared_lib",
"numpy == 1.26.4",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["app1"]

[tool.uv.sources]
shared_lib = { workspace = true }
3 changes: 3 additions & 0 deletions scripts/workspaces/shared-libs/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[tool.uv.workspace]
members = ["shared_*"]
private-members = ["app*"]
Loading

0 comments on commit c8ba783

Please sign in to comment.