Skip to content
13 changes: 13 additions & 0 deletions crates/uv-workspace/src/pyproject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@ impl PyProjectToml {
.is_some_and(|project| project.version.is_none())
}

/// Returns `true` if the key is set dynamically.
pub fn is_key_dynamic(&self, key: &str) -> bool {
self.project.as_ref().is_some_and(|project| {
project
.dynamic
.as_ref()
.is_some_and(|dynamic| dynamic.iter().any(|field| field == key))
})
}

/// Returns whether the project manifest contains any script table.
pub fn has_scripts(&self) -> bool {
if let Some(ref project) = self.project {
Expand Down Expand Up @@ -221,6 +231,8 @@ pub struct Project {
pub dependencies: Option<Vec<String>>,
/// The optional dependencies of the project.
pub optional_dependencies: Option<BTreeMap<ExtraName, Vec<String>>>,
/// The dynamic attributes of the project.
pub dynamic: Option<Vec<String>>,

/// Used to determine whether a `gui-scripts` section is present.
#[serde(default, skip_serializing)]
Expand Down Expand Up @@ -266,6 +278,7 @@ impl TryFrom<ProjectWire> for Project {
requires_python: value.requires_python,
dependencies: value.dependencies,
optional_dependencies: value.optional_dependencies,
dynamic: value.dynamic,
gui_scripts: value.gui_scripts,
scripts: value.scripts,
})
Expand Down
109 changes: 83 additions & 26 deletions crates/uv-workspace/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use rustc_hash::FxHashSet;
use tracing::{debug, trace, warn};
use uv_distribution_types::Index;
use uv_fs::{Simplified, CWD};
use uv_normalize::{GroupName, PackageName, DEV_DEPENDENCIES};
use uv_normalize::{ExtraName, GroupName, PackageName, DEV_DEPENDENCIES};
use uv_pep440::VersionSpecifiers;
use uv_pep508::{MarkerTree, VerbatimUrl};
use uv_pypi_types::{
Expand Down Expand Up @@ -526,6 +526,38 @@ impl Workspace {
.collect()
}

/// Returns the set of all non-dynamic extras defined in the workspace.
pub fn extras(&self) -> BTreeSet<&ExtraName> {
self.pyproject_toml
.project
.as_ref()
.and_then(|project| project.optional_dependencies.as_ref())
.iter()
.flat_map(|extras| extras.keys())
.chain(
self.packages
.values()
.filter_map(|member| {
member
.pyproject_toml
.project
.as_ref()
.map_or_else(|| None, |project| project.optional_dependencies.as_ref())
})
.flat_map(|extras| extras.keys()),
)
.collect()
}

/// Whether at least one project in the workspace sets the key dynamically.
pub fn uses_dynamic_key(&self, key: &str) -> bool {
self.pyproject_toml.is_key_dynamic(key)
|| self
.packages
.values()
.any(|member| member.pyproject_toml.is_key_dynamic(key))
}

/// The path to the workspace root, the directory containing the top level `pyproject.toml` with
/// the `uv.tool.workspace`, or the `pyproject.toml` in an implicit single workspace project.
pub fn install_path(&self) -> &PathBuf {
Expand Down Expand Up @@ -1573,7 +1605,8 @@ mod tests {
"dependencies": [
"anyio>=4.3.0,<5"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
}
Expand All @@ -1588,7 +1621,8 @@ mod tests {
"dependencies": [
"anyio>=4.3.0,<5"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"tool": null,
"dependency-groups": null
Expand Down Expand Up @@ -1626,7 +1660,8 @@ mod tests {
"dependencies": [
"anyio>=4.3.0,<5"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
}
Expand All @@ -1641,7 +1676,8 @@ mod tests {
"dependencies": [
"anyio>=4.3.0,<5"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"tool": null,
"dependency-groups": null
Expand Down Expand Up @@ -1679,7 +1715,8 @@ mod tests {
"bird-feeder",
"tqdm>=4,<5"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
},
Expand All @@ -1693,7 +1730,8 @@ mod tests {
"anyio>=4.3.0,<5",
"seeds"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
},
Expand All @@ -1706,7 +1744,8 @@ mod tests {
"dependencies": [
"idna==3.6"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
}
Expand All @@ -1730,7 +1769,8 @@ mod tests {
"bird-feeder",
"tqdm>=4,<5"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"tool": {
"uv": {
Expand Down Expand Up @@ -1796,7 +1836,8 @@ mod tests {
"bird-feeder",
"tqdm>=4,<5"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
},
Expand All @@ -1810,7 +1851,8 @@ mod tests {
"anyio>=4.3.0,<5",
"seeds"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
},
Expand All @@ -1823,7 +1865,8 @@ mod tests {
"dependencies": [
"idna==3.6"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
}
Expand Down Expand Up @@ -1886,7 +1929,8 @@ mod tests {
"dependencies": [
"tqdm>=4,<5"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
}
Expand All @@ -1901,7 +1945,8 @@ mod tests {
"dependencies": [
"tqdm>=4,<5"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"tool": null,
"dependency-groups": null
Expand Down Expand Up @@ -2006,7 +2051,8 @@ mod tests {
"dependencies": [
"tqdm>=4,<5"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
},
Expand All @@ -2019,7 +2065,8 @@ mod tests {
"dependencies": [
"idna==3.6"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
}
Expand All @@ -2034,7 +2081,8 @@ mod tests {
"dependencies": [
"tqdm>=4,<5"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"tool": {
"uv": {
Expand Down Expand Up @@ -2109,7 +2157,8 @@ mod tests {
"dependencies": [
"tqdm>=4,<5"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
},
Expand All @@ -2122,7 +2171,8 @@ mod tests {
"dependencies": [
"idna==3.6"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
}
Expand All @@ -2137,7 +2187,8 @@ mod tests {
"dependencies": [
"tqdm>=4,<5"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"tool": {
"uv": {
Expand Down Expand Up @@ -2213,7 +2264,8 @@ mod tests {
"dependencies": [
"tqdm>=4,<5"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
},
Expand All @@ -2226,7 +2278,8 @@ mod tests {
"dependencies": [
"anyio>=4.3.0,<5"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
},
Expand All @@ -2239,7 +2292,8 @@ mod tests {
"dependencies": [
"idna==3.6"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
}
Expand All @@ -2254,7 +2308,8 @@ mod tests {
"dependencies": [
"tqdm>=4,<5"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"tool": {
"uv": {
Expand Down Expand Up @@ -2330,7 +2385,8 @@ mod tests {
"dependencies": [
"tqdm>=4,<5"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
}
Expand All @@ -2345,7 +2401,8 @@ mod tests {
"dependencies": [
"tqdm>=4,<5"
],
"optional-dependencies": null
"optional-dependencies": null,
"dynamic": null
},
"tool": {
"uv": {
Expand Down
17 changes: 9 additions & 8 deletions crates/uv/src/commands/project/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ use crate::commands::project::install_target::InstallTarget;
use crate::commands::project::lock::{do_safe_lock, LockMode};
use crate::commands::project::lock_target::LockTarget;
use crate::commands::project::{
default_dependency_groups, detect_conflicts, DependencyGroupsTarget, ProjectError,
ProjectInterpreter, ScriptInterpreter,
default_dependency_groups, detect_conflicts, ProjectError, ProjectInterpreter,
ScriptInterpreter, SpecificationTarget,
};
use crate::commands::{diagnostics, ExitStatus, OutputWriter};
use crate::printer::Printer;
Expand Down Expand Up @@ -108,22 +108,23 @@ pub(crate) async fn export(
ExportTarget::Project(project)
};

// Validate that any referenced dependency groups are defined in the workspace.
// Validate that any referenced dependency groups and extras are defined in the workspace.
if !frozen {
let target = match &target {
ExportTarget::Project(VirtualProject::Project(project)) => {
if all_packages {
DependencyGroupsTarget::Workspace(project.workspace())
SpecificationTarget::Workspace(project.workspace())
} else {
DependencyGroupsTarget::Project(project)
SpecificationTarget::Project(project)
}
}
ExportTarget::Project(VirtualProject::NonProject(workspace)) => {
DependencyGroupsTarget::Workspace(workspace)
SpecificationTarget::Workspace(workspace)
}
ExportTarget::Script(_) => DependencyGroupsTarget::Script,
ExportTarget::Script(_) => SpecificationTarget::Script,
};
target.validate(&dev)?;
target.validate_dependency_groups(&dev)?;
target.validate_extras(&extras)?;
}

// Determine the default groups to include.
Expand Down
Loading
Loading