From 7f3ab0449a7b375e73ea163fee73ebd615b990c9 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Thu, 18 Jun 2026 15:35:52 -0400 Subject: [PATCH] Add `--script` to `uv check` and `uv metadata` (#19860) Like other `--script` commands this tells uv to ignore the current workspace and only handle the PEP 723 script at the given path, with its own lock and venv. --- crates/uv-cli/src/lib.rs | 30 ++ .../uv-resolver/src/lock/export/metadata.rs | 192 +++++++++-- crates/uv-resolver/src/lock/export/mod.rs | 23 +- crates/uv-resolver/src/lock/mod.rs | 23 +- crates/uv/src/commands/project/check.rs | 246 +++++++++++--- crates/uv/src/commands/project/check/ty.rs | 11 + crates/uv/src/commands/project/mod.rs | 10 +- crates/uv/src/commands/workspace/metadata.rs | 193 +++++++---- .../src/commands/workspace/module_owners.rs | 32 +- crates/uv/src/lib.rs | 39 +++ crates/uv/src/settings.rs | 10 +- crates/uv/tests/project/check.rs | 94 +++++ .../uv/tests/workspace/workspace_metadata.rs | 321 +++++++++++++++++- docs/reference/internals/metadata.md | 55 ++- 14 files changed, 1107 insertions(+), 172 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 1489fbb4f0..a84927a7cb 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -5237,6 +5237,29 @@ pub struct FormatArgs { #[derive(Args)] pub struct CheckArgs { + /// Run checks for the specified PEP 723 Python script, rather than the current project. + /// + /// If provided, uv will use the dependencies based on the script's inline metadata table, in + /// adherence with PEP 723. + #[arg( + long, + conflicts_with = "extra", + conflicts_with = "all_extras", + conflicts_with = "no_extra", + conflicts_with = "no_all_extras", + conflicts_with = "dev", + conflicts_with = "no_dev", + conflicts_with = "only_dev", + conflicts_with = "group", + conflicts_with = "no_group", + conflicts_with = "no_default_groups", + conflicts_with = "only_group", + conflicts_with = "all_groups", + conflicts_with = "no_project", + value_hint = ValueHint::FilePath, + )] + pub script: Option, + /// Include optional dependencies from the specified extra name. /// /// May be provided more than once. @@ -8255,6 +8278,13 @@ pub enum WorkspaceCommand { } #[derive(Args)] pub struct MetadataArgs { + /// View metadata for the specified PEP 723 Python script, rather than the current workspace. + /// + /// If provided, uv will resolve the dependencies based on the script's inline metadata table, + /// in adherence with PEP 723. + #[arg(long, value_hint = ValueHint::FilePath)] + pub script: Option, + /// Check if the lockfile is up-to-date [env: UV_LOCKED=] /// /// Asserts that the `uv.lock` would remain unchanged after a resolution. If the lockfile is diff --git a/crates/uv-resolver/src/lock/export/metadata.rs b/crates/uv-resolver/src/lock/export/metadata.rs index 89aa52ec03..e2a5f23286 100644 --- a/crates/uv-resolver/src/lock/export/metadata.rs +++ b/crates/uv-resolver/src/lock/export/metadata.rs @@ -66,6 +66,9 @@ pub struct Metadata { /// Information about the synchronized environment, when `--sync` was used. #[serde(skip_serializing_if = "Option::is_none", default)] environment: Option, + /// Information about the script root, when metadata was requested for a script. + #[serde(skip_serializing_if = "Option::is_none", default)] + script: Option, /// The version of python required by the workspace /// /// Every `marker` we emit implicitly assumes this constraint to keep things clean @@ -108,6 +111,15 @@ struct MetadataEnvironment { root: PortablePathBuf, } +/// The script entry-point. +#[derive(Debug, serde::Serialize)] +struct MetadataScript { + /// Absolute path to the script. + path: PortablePathBuf, + /// Key for the script's node in the `resolution` graph. + id: MetadataNodeIdFlat, +} + /// Info for looking up workspace members, most information is stored in the node behind `id` #[derive(Debug, serde::Serialize)] struct MetadataWorkspaceMember { @@ -126,10 +138,11 @@ struct MetadataModuleOwner { package_id: MetadataNodeIdFlat, } -/// A node in the dependency graph +/// A node in the dependency graph. /// -/// There are 4 kinds of nodes: +/// There are 5 kinds of nodes: /// +/// * scripts: `script+/workspace/script.py` /// * packages: `mypackage==1.0.0@registry+https://pypi.org/simple` /// * extras: `mypackage[myextra]==1.0.0@registry+https://pypi.org/simple` /// * groups: `mypackage:mygroup==1.0.0@registry+https://pypi.org/simple` @@ -216,6 +229,15 @@ impl MetadataNode { Self::new(MetadataNodeId::from_package_id(workspace_root, id, kind)) } + fn from_script(path: PortablePathBuf, dependencies: Vec) -> Self { + let mut node = Self::new(MetadataNodeId::Script(MetadataScriptNodeId { + kind: MetadataScriptNodeKind::Script, + path, + })); + node.dependencies = dependencies; + node + } + fn add_dependency(&mut self, workspace_root: &PortablePathBuf, dependency: &Dependency) { let extras = dependency.extra(); if extras.is_empty() { @@ -244,11 +266,79 @@ impl MetadataNode { } } -/// The unique key for every node in the graph +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "snake_case")] +enum MetadataScriptNodeKind { + Script, +} + +fn script_root_dependencies( + workspace_root: &PortablePathBuf, + lock: &Lock, +) -> Vec { + let mut dependencies = Vec::new(); + + // Root requirements retain names, extras, and markers rather than resolved package IDs. Match + // them to the locked packages using the same name and fork-marker logic as lock export. + for requirement in lock.requirements() { + for package in lock + .packages() + .iter() + .filter(|package| package.name() == &requirement.name) + { + let Some(marker) = lock.root_requirement_marker(requirement, package) else { + continue; + }; + + let marker = marker.try_to_string(); + let mut has_extra_node = false; + for extra in requirement + .extras + .iter() + .filter(|extra| package.optional_dependencies.contains_key(*extra)) + { + let id = MetadataNodeId::from_package_id( + workspace_root, + &package.id, + MetadataNodeKind::Extra(extra.clone()), + ); + dependencies.push(MetadataDependency { + id: id.to_flat(), + marker: marker.clone(), + }); + has_extra_node = true; + } + + if !has_extra_node { + let id = MetadataNodeId::from_package_id( + workspace_root, + &package.id, + MetadataNodeKind::Package, + ); + dependencies.push(MetadataDependency { + id: id.to_flat(), + marker, + }); + } + } + } + + dependencies +} + +/// The unique key for every node in the graph. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(untagged)] +enum MetadataNodeId { + Package(MetadataPackageNodeId), + Script(MetadataScriptNodeId), +} + +/// The unique key for a package-derived node. /// /// (It's not entirely clear to me that two nodes can differ only by `source` but it doesn't hurt.) #[derive(Debug, Clone, serde::Serialize)] -struct MetadataNodeId { +struct MetadataPackageNodeId { /// The name of the package name: PackageName, /// The version of the package, if any could be found (source trees may have no version) @@ -260,11 +350,17 @@ struct MetadataNodeId { kind: MetadataNodeKind, } +/// The unique key for a script node. +#[derive(Debug, Clone, serde::Serialize)] +struct MetadataScriptNodeId { + kind: MetadataScriptNodeKind, + /// Absolute path to the script. + path: PortablePathBuf, +} + /// This is intended to be an opaque unique id for referring to a node /// /// It's human readable for convenience but parsing it or relying on it is inadvisable. -/// As currently implemented this is just a concatenation of the 4 fields in `MetadataNodeId` -/// which every node includes, so parsing it is just making more work for yourself. type MetadataNodeIdFlat = String; impl MetadataNodeId { @@ -277,11 +373,18 @@ impl MetadataNodeId { let version = id.version.clone(); let source = MetadataSource::from_source(workspace_root, id.source.clone()); - Self { + Self::Package(MetadataPackageNodeId { name, version, source, kind, + }) + } + + fn as_package(&self) -> Option<&MetadataPackageNodeId> { + match self { + Self::Package(package) => Some(package), + Self::Script(_) => None, } } @@ -292,9 +395,16 @@ impl MetadataNodeId { impl Display for MetadataNodeId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match &self.version { - Some(version) => write!(f, "{}{}=={version}@{}", self.name, self.kind, self.source), - None => write!(f, "{}{}@{}", self.name, self.kind, self.source), + match self { + Self::Package(package) => match &package.version { + Some(version) => write!( + f, + "{}{}=={version}@{}", + package.name, package.kind, package.source + ), + None => write!(f, "{}{}@{}", package.name, package.kind, package.source), + }, + Self::Script(script) => write!(f, "script+{}", script.path), } } } @@ -704,11 +814,11 @@ impl MetadataConflictItem { .iter() .find(|member| &member.name == item.package()) .and_then(|member| { - let package_node = resolve.get(&member.id)?; - let id = MetadataNodeId { + let package_id = resolve.get(&member.id)?.id.as_package()?; + let id = MetadataNodeId::Package(MetadataPackageNodeId { kind: kind.to_node_kind(), - ..package_node.id.clone() - }; + ..package_id.clone() + }); Some(id.to_flat()) }); Self { @@ -745,11 +855,36 @@ impl MetadataConflictKind { } impl Metadata { - /// Construct a [`PylockToml`] from a uv lockfile. + /// Construct [`Metadata`] for a workspace from a uv lockfile. pub fn from_lock(workspace: &Workspace, lock: &Lock) -> Result { + Ok(Self::from_lock_target( + workspace.install_path(), + Some(workspace), + None, + lock, + )) + } + + /// Construct [`Metadata`] for a script from a uv lockfile. + pub fn from_script(script_path: &Path, lock: &Lock) -> Result { + let workspace_root = script_path.parent().unwrap_or_else(|| Path::new("")); + Ok(Self::from_lock_target( + workspace_root, + None, + Some(script_path), + lock, + )) + } + + fn from_lock_target( + workspace_root: &Path, + workspace: Option<&Workspace>, + script_path: Option<&Path>, + lock: &Lock, + ) -> Self { let mut resolve = BTreeMap::new(); let mut members = Vec::new(); - let workspace_root = PortablePathBuf::from(workspace.install_path().as_path()); + let workspace_root = PortablePathBuf::from(workspace_root); for lock_package in lock.packages() { let mut meta_package = MetadataNode::from_package_id( @@ -808,9 +943,11 @@ impl Metadata { } // Register this package if it appears to be a workspace member - if let Some(workspace_package) = workspace.packages().get(lock_package.name()) { + if let Some(workspace_package) = + workspace.and_then(|workspace| workspace.packages().get(lock_package.name())) + { let member = MetadataWorkspaceMember { - name: meta_package.id.name.clone(), + name: lock_package.name().clone(), path: normalize_workspace_relative_path( &workspace_root, workspace_package.root().as_path(), @@ -834,20 +971,31 @@ impl Metadata { resolve.insert(meta_package.id.to_flat(), meta_package); } + let script = script_path.map(|path| { + let path = PortablePathBuf::from(path); + let node = MetadataNode::from_script( + path.clone(), + script_root_dependencies(&workspace_root, lock), + ); + let id = node.id.to_flat(); + resolve.insert(id.clone(), node); + MetadataScript { path, id } + }); let conflicts = MetadataConflicts::from_conflicts(&members, &resolve, &lock.conflicts); - Ok(Self { + Self { schema: SchemaReport { version: SchemaVersion::Preview, }, conflicts, environment: None, + script, module_owners: BTreeMap::new(), workspace_root, requires_python: lock.requires_python.clone(), members, resolution: resolve, - }) + } } pub fn package_node_id( @@ -855,12 +1003,12 @@ impl Metadata { dist: &ResolvedDist, ) -> Result { let source = Source::from_resolved_dist(dist, workspace_root.as_ref())?; - Ok(MetadataNodeId { + Ok(MetadataNodeId::Package(MetadataPackageNodeId { name: dist.name().clone(), version: dist.version().cloned(), source: MetadataSource::from_source(workspace_root, source), kind: MetadataNodeKind::Package, - } + }) .to_flat()) } diff --git a/crates/uv-resolver/src/lock/export/mod.rs b/crates/uv-resolver/src/lock/export/mod.rs index 730e75fced..e34e73a75d 100644 --- a/crates/uv-resolver/src/lock/export/mod.rs +++ b/crates/uv-resolver/src/lock/export/mod.rs @@ -209,25 +209,12 @@ impl<'lock> ExportableRequirements<'lock> { for requirement in root_requirements { for dist in by_name.get(&requirement.name).into_iter().flatten() { - // Determine whether this entry is "relevant" for the requirement, by intersecting - // the markers. - let marker = if dist.fork_markers.is_empty() { - requirement.marker - } else { - let mut combined = MarkerTree::FALSE; - for fork_marker in &dist.fork_markers { - combined.or(fork_marker.pep508()); - } - combined.and(requirement.marker); - combined - }; - - if marker.is_false() { + // Determine whether this entry is relevant for the requirement by + // intersecting and simplifying the markers. + let Some(marker) = target.lock().root_requirement_marker(requirement, dist) + else { continue; - } - - // Simplify the marker. - let marker = target.lock().simplify_environment(marker); + }; // Add the dependency to the graph and get its index. let dep_index = *inverse diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 6399bbc113..97a5c3c15a 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -765,11 +765,32 @@ impl Lock { &self.manifest.members } - /// Returns the dependency groups that were used to generate this lock. + /// Returns the root requirements that were used to generate this lock. fn requirements(&self) -> &BTreeSet { &self.manifest.requirements } + /// Intersect a requirement marker with the forks that contain a package, then simplify it + /// under the lockfile's Python requirement. + pub(crate) fn root_requirement_marker( + &self, + requirement: &Requirement, + package: &Package, + ) -> Option { + let marker = if package.fork_markers.is_empty() { + requirement.marker + } else { + let mut combined = MarkerTree::FALSE; + for fork_marker in &package.fork_markers { + combined.or(fork_marker.pep508()); + } + combined.and(requirement.marker); + combined + }; + + (!marker.is_false()).then(|| self.simplify_environment(marker)) + } + /// Returns the dependency groups that were used to generate this lock. pub(crate) fn dependency_groups(&self) -> &BTreeMap> { &self.manifest.dependency_groups diff --git a/crates/uv/src/commands/project/check.rs b/crates/uv/src/commands/project/check.rs index da70f788f6..51bbd27d66 100644 --- a/crates/uv/src/commands/project/check.rs +++ b/crates/uv/src/commands/project/check.rs @@ -14,6 +14,7 @@ use uv_preview::{Preview, PreviewFeature}; use uv_python::{ EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest, }; +use uv_scripts::Pep723Script; use uv_settings::{MalwareCheckSettings, PythonInstallMirrors}; use uv_warnings::warn_user; use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceErrorKind}; @@ -22,9 +23,10 @@ use crate::commands::pip::loggers::{SummaryInstallLogger, SummaryResolveLogger}; use crate::commands::pip::operations::Modifications; use crate::commands::project::install_target::InstallTarget; use crate::commands::project::lock::LockMode; +use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ - ProjectEnvironment, ProjectError, ProjectInterpreter, UniversalState, WorkspacePython, - default_dependency_groups, validate_project_requires_python, + ProjectEnvironment, ProjectError, ProjectInterpreter, ScriptEnvironment, ScriptInterpreter, + UniversalState, WorkspacePython, default_dependency_groups, validate_project_requires_python, }; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::{ExitStatus, diagnostics, project}; @@ -49,6 +51,7 @@ pub(crate) async fn check( settings: ResolverInstallerSettings, ty_version: Option, show_version: bool, + script: Option, client_builder: BaseClientBuilder<'_>, python_preference: PythonPreference, python_downloads: PythonDownloads, @@ -70,7 +73,7 @@ pub(crate) async fn check( } // Discover the project. - let project = if no_project { + let project = if no_project || script.is_some() { None } else { match VirtualProject::discover( @@ -113,7 +116,7 @@ pub(crate) async fn check( if no_sync { warn_user!("`--no-sync` has no effect when used alongside `--no-project`"); } - } else if project.is_none() { + } else if project.is_none() && script.is_none() { for flag in extras.history().as_flags_pretty() { warn_user!("`{flag}` has no effect when used outside of a project"); } @@ -131,9 +134,11 @@ pub(crate) async fn check( } } - let target_dir = project + let target_dir = script .as_ref() - .map(|p| p.root().to_owned()) + .and_then(|script| script.path.parent()) + .map(Path::to_path_buf) + .or_else(|| project.as_ref().map(|project| project.root().to_owned())) .unwrap_or_else(|| project_dir.to_owned()); let groups = if let Some(project) = &project { @@ -147,45 +152,64 @@ pub(crate) async fn check( let isolated_venv = if isolated { debug!("Creating isolated virtual environment"); - let workspace = project.as_ref().map(VirtualProject::workspace); - let WorkspacePython { - source, - python_request, - requires_python, - } = WorkspacePython::from_request( - python.as_deref().map(PythonRequest::parse), - workspace, - &groups, - project_dir, - no_config, - ) - .await?; - - let reporter = PythonDownloadReporter::single(printer); - let interpreter = PythonInstallation::find_or_download( - python_request.as_ref(), - EnvironmentPreference::Any, - python_preference, - python_downloads, - &client_builder, - cache, - Some(&reporter), - install_mirrors.python_install_mirror.as_deref(), - install_mirrors.pypy_install_mirror.as_deref(), - install_mirrors.python_downloads_json_url.as_deref(), - ) - .await? - .into_interpreter(); - - if let Some(requires_python) = requires_python.as_ref() { - validate_project_requires_python( - &interpreter, + let interpreter = if let Some(script) = script.as_ref() { + ScriptInterpreter::discover( + script.into(), + python.as_deref().map(PythonRequest::parse), + &client_builder, + python_preference, + python_downloads, + &install_mirrors, + false, + no_config, + Some(false), + cache, + printer, + ) + .await? + .into_interpreter() + } else { + let workspace = project.as_ref().map(VirtualProject::workspace); + let WorkspacePython { + source, + python_request, + requires_python, + } = WorkspacePython::from_request( + python.as_deref().map(PythonRequest::parse), workspace, &groups, - requires_python, - &source, - )?; - } + project_dir, + no_config, + ) + .await?; + + let reporter = PythonDownloadReporter::single(printer); + let interpreter = PythonInstallation::find_or_download( + python_request.as_ref(), + EnvironmentPreference::Any, + python_preference, + python_downloads, + &client_builder, + cache, + Some(&reporter), + install_mirrors.python_install_mirror.as_deref(), + install_mirrors.pypy_install_mirror.as_deref(), + install_mirrors.python_downloads_json_url.as_deref(), + ) + .await? + .into_interpreter(); + + if let Some(requires_python) = requires_python.as_ref() { + validate_project_requires_python( + &interpreter, + workspace, + &groups, + requires_python, + &source, + )?; + } + interpreter + }; temp_dir = cache.venv_dir()?; Some(uv_virtualenv::create_venv( @@ -204,7 +228,139 @@ pub(crate) async fn check( // Select an environment and, if we found a project, sync it before running checks. let mut workspace_metadata = None; - let venv_path = if let Some(project) = &project { + let venv_path = if let Some(script) = &script { + let extras = extras.with_defaults(DefaultExtras::default()); + let venv = if let Some(venv) = isolated_venv { + venv + } else { + ScriptEnvironment::get_or_init( + script.into(), + python.as_deref().map(PythonRequest::parse), + &client_builder, + python_preference, + python_downloads, + &install_mirrors, + no_sync, + no_config, + Some(false), + cache, + DryRun::Disabled, + printer, + ) + .await? + .into_environment()? + }; + + let state = UniversalState::default(); + let lock_target = LockTarget::Script(script); + // Scripts always run in an isolated environment, so `--no-sync` has no effect. + let _environment_lock = venv + .lock() + .await + .inspect_err(|err| { + tracing::warn!("Failed to acquire environment lock: {err}"); + }) + .ok(); + let sync_state = state.fork(); + let mode = if let Some(frozen_source) = frozen { + LockMode::Frozen(frozen_source.into()) + } else if let LockCheck::Enabled(lock_check) = lock_check { + LockMode::Locked(venv.interpreter(), lock_check) + } else if isolated || !lock_target.lock_path().is_file() { + LockMode::DryRun(venv.interpreter()) + } else { + LockMode::Write(venv.interpreter()) + }; + let result = match Box::pin( + project::lock::LockOperation::new( + mode, + &settings.resolver, + &client_builder, + &state, + Box::new(SummaryResolveLogger), + &concurrency, + cache, + workspace_cache, + printer, + preview, + ) + .execute(lock_target), + ) + .await + { + Ok(result) => result, + Err(ProjectError::Operation(err)) => { + return diagnostics::OperationDiagnostic::with_system_certs( + client_builder.system_certs(), + ) + .report(err) + .map_or(Ok(ExitStatus::Failure), |err| Err(err.into())); + } + Err(err) => return Err(err.into()), + }; + + let target = InstallTarget::Script { + script, + lock: result.lock(), + }; + match project::sync::do_sync( + target, + &venv, + &extras, + &groups, + None, + InstallOptions::default(), + Modifications::Sufficient, + None, + (&settings).into(), + &client_builder, + &sync_state, + Box::new(SummaryInstallLogger), + installer_metadata, + &concurrency, + cache, + workspace_cache, + DryRun::Disabled, + printer, + preview, + &malware_settings, + ) + .await + { + Ok(_) => {} + Err(ProjectError::Operation(err)) => { + return diagnostics::OperationDiagnostic::with_system_certs( + client_builder.system_certs(), + ) + .report(err) + .map_or(Ok(ExitStatus::Failure), |err| Err(err.into())); + } + Err(err) => return Err(err.into()), + } + + if no_sync { + warn_user!( + "`--no-sync` is a no-op for Python scripts with inline metadata, which always run in isolation" + ); + } + + let lock = result.into_lock(); + let metadata = crate::commands::workspace::metadata::metadata_from_target( + Some(&venv), + InstallTarget::Script { + script, + lock: &lock, + }, + &extras, + &groups, + &settings.resolver, + )?; + let mut metadata = metadata.to_json()?; + metadata.push('\n'); + workspace_metadata = Some(metadata); + + Some(venv.root().to_owned()) + } else if let Some(project) = &project { let extras = extras.with_defaults(DefaultExtras::default()); let venv = if let Some(venv) = isolated_venv { @@ -384,7 +540,6 @@ pub(crate) async fn check( }, }; let metadata = crate::commands::workspace::metadata::metadata_from_target( - project.workspace(), (!no_sync).then_some(&venv), target, &extras, @@ -410,6 +565,7 @@ pub(crate) async fn check( ty_version, ty_path, &target_dir, + script.as_ref().map(|script| script.path.as_path()), venv_path.as_deref(), workspace_metadata, exclude_newer, diff --git a/crates/uv/src/commands/project/check/ty.rs b/crates/uv/src/commands/project/check/ty.rs index 0bed8170f1..92be30581b 100644 --- a/crates/uv/src/commands/project/check/ty.rs +++ b/crates/uv/src/commands/project/check/ty.rs @@ -40,6 +40,7 @@ pub(super) async fn run( version: Option, ty_path: Option, target_dir: &Path, + check_target: Option<&Path>, venv_path: Option<&Path>, workspace_metadata: Option, exclude_newer: Option, @@ -145,6 +146,16 @@ pub(super) async fn run( let mut command = Command::new(&ty_path); command.current_dir(target_dir); command.arg("check"); + if let Some(check_target) = check_target { + // Check only the requested script. Keep the path relative to the working directory for + // stable diagnostics, and use `--` so option-like filenames are treated as paths. + command.arg("--"); + command.arg( + check_target + .strip_prefix(target_dir) + .unwrap_or(check_target), + ); + } // Opt into ty querying uv for project metadata. command.env("TY_UV", "1"); diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 13dadea026..11b20e79e8 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -867,7 +867,7 @@ impl ScriptInterpreter { } /// Consume the [`PythonInstallation`] and return the [`Interpreter`]. - fn into_interpreter(self) -> Interpreter { + pub(crate) fn into_interpreter(self) -> Interpreter { match self { Self::Interpreter(interpreter) => interpreter, Self::Environment(venv) => venv.into_interpreter(), @@ -1609,7 +1609,7 @@ impl ProjectEnvironment { /// /// Returns an error if the environment was created in `--dry-run` mode, as dropping the /// associated temporary directory could lead to errors downstream. - fn into_environment(self) -> Result { + pub(crate) fn into_environment(self) -> Result { match self { Self::Existing(environment) => Ok(environment), Self::Replaced(environment) => Ok(environment), @@ -1644,7 +1644,7 @@ impl std::ops::Deref for ProjectEnvironment { /// The Python environment for a script. #[derive(Debug)] -enum ScriptEnvironment { +pub(crate) enum ScriptEnvironment { /// An existing [`PythonEnvironment`] was discovered, which satisfies the script's requirements. Existing(PythonEnvironment), /// An existing [`PythonEnvironment`] was discovered, but did not satisfy the script's @@ -1671,7 +1671,7 @@ enum ScriptEnvironment { impl ScriptEnvironment { /// Initialize a virtual environment for a PEP 723 script. - async fn get_or_init( + pub(crate) async fn get_or_init( script: Pep723ItemRef<'_>, python_request: Option, client_builder: &BaseClientBuilder<'_>, @@ -1796,7 +1796,7 @@ impl ScriptEnvironment { /// /// Returns an error if the environment was created in `--dry-run` mode, as dropping the /// associated temporary directory could lead to errors downstream. - fn into_environment(self) -> Result { + pub(crate) fn into_environment(self) -> Result { match self { Self::Existing(environment) => Ok(environment), Self::Replaced(environment) => Ok(environment), diff --git a/crates/uv/src/commands/workspace/metadata.rs b/crates/uv/src/commands/workspace/metadata.rs index 86075b15c9..a80eb6b38a 100644 --- a/crates/uv/src/commands/workspace/metadata.rs +++ b/crates/uv/src/commands/workspace/metadata.rs @@ -11,17 +11,19 @@ use uv_configuration::{ }; use uv_preview::{Preview, PreviewFeature}; use uv_python::{PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest}; -use uv_resolver::{Installable, Metadata}; +use uv_resolver::Metadata; +use uv_scripts::Pep723Script; use uv_settings::{MalwareCheckSettings, PythonInstallMirrors}; use uv_warnings::warn_user; -use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace, WorkspaceCache}; +use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache}; use crate::commands::pip::loggers::DefaultResolveLogger; use crate::commands::project::install_target::InstallTarget; use crate::commands::project::lock::{LockMode, LockOperation}; use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ - ProjectEnvironment, ProjectError, ProjectInterpreter, UniversalState, WorkspacePython, + ProjectEnvironment, ProjectError, ProjectInterpreter, ScriptEnvironment, ScriptInterpreter, + UniversalState, WorkspacePython, }; use crate::commands::{ExitStatus, diagnostics}; use crate::printer::Printer; @@ -42,6 +44,7 @@ pub(crate) async fn metadata( malware_settings: MalwareCheckSettings, settings: ResolverSettings, client_builder: BaseClientBuilder<'_>, + script: Option, python_preference: PythonPreference, python_downloads: PythonDownloads, concurrency: Concurrency, @@ -58,14 +61,19 @@ pub(crate) async fn metadata( ); } - let virtual_project = VirtualProject::discover( - project_dir, - &DiscoveryOptions::default(), - cache, - workspace_cache, - ) - .await?; - let target = LockTarget::Workspace(virtual_project.workspace()); + let virtual_project; + let target = if let Some(script) = script.as_ref() { + LockTarget::Script(script) + } else { + virtual_project = VirtualProject::discover( + project_dir, + &DiscoveryOptions::default(), + cache, + workspace_cache, + ) + .await?; + LockTarget::Workspace(virtual_project.workspace()) + }; // Don't enable any groups' requires-python for interpreter discovery. let groups = DependencyGroupsWithDefaults::none(); @@ -75,33 +83,54 @@ pub(crate) async fn metadata( let mode = if let Some(frozen_source) = frozen { LockMode::Frozen(frozen_source.into()) } else { - let workspace_python = WorkspacePython::from_request( - python.as_deref().map(PythonRequest::parse), - Some(virtual_project.workspace()), - &groups, - project_dir, - no_config, - ) - .await?; - interpreter = ProjectInterpreter::discover( - virtual_project.workspace(), - &groups, - workspace_python, - &client_builder, - python_preference, - python_downloads, - &install_mirrors, - false, - Some(false), - cache, - printer, - ) - .await? - .into_interpreter(); + interpreter = match target { + LockTarget::Script(script) => ScriptInterpreter::discover( + script.into(), + python.as_deref().map(PythonRequest::parse), + &client_builder, + python_preference, + python_downloads, + &install_mirrors, + false, + no_config, + Some(false), + cache, + printer, + ) + .await? + .into_interpreter(), + LockTarget::Workspace(workspace) => { + let workspace_python = WorkspacePython::from_request( + python.as_deref().map(PythonRequest::parse), + Some(workspace), + &groups, + project_dir, + no_config, + ) + .await?; + ProjectInterpreter::discover( + workspace, + &groups, + workspace_python, + &client_builder, + python_preference, + python_downloads, + &install_mirrors, + false, + Some(false), + cache, + printer, + ) + .await? + .into_interpreter() + } + }; if let LockCheck::Enabled(lock_check) = lock_check { LockMode::Locked(&interpreter, lock_check) - } else if dry_run.enabled() { + } else if dry_run.enabled() + || (matches!(target, LockTarget::Script(_)) && !target.lock_path().is_file()) + { LockMode::DryRun(&interpreter) } else { LockMode::Write(&interpreter) @@ -132,27 +161,62 @@ pub(crate) async fn metadata( { Ok(lock) => { let lock = lock.into_lock(); - let mut export = Metadata::from_lock(virtual_project.workspace(), &lock)?; + let install_target = match target { + LockTarget::Workspace(workspace) => InstallTarget::Workspace { + workspace, + lock: &lock, + }, + LockTarget::Script(script) => InstallTarget::Script { + script, + lock: &lock, + }, + }; + let mut export = metadata_for_target(install_target)?; if sync { - let environment = ProjectEnvironment::get_or_init( - virtual_project.workspace(), - &groups, - python.as_deref().map(PythonRequest::parse), - &install_mirrors, - &client_builder, - python_preference, - python_downloads, - false, - no_config, - Some(false), - cache, - DryRun::Disabled, - printer, - ) - .await?; + let environment = match target { + LockTarget::Workspace(workspace) => ProjectEnvironment::get_or_init( + workspace, + &groups, + python.as_deref().map(PythonRequest::parse), + &install_mirrors, + &client_builder, + python_preference, + python_downloads, + false, + no_config, + Some(false), + cache, + DryRun::Disabled, + printer, + ) + .await? + .into_environment()?, + LockTarget::Script(script) => ScriptEnvironment::get_or_init( + script.into(), + python.as_deref().map(PythonRequest::parse), + &client_builder, + python_preference, + python_downloads, + &install_mirrors, + false, + no_config, + Some(false), + cache, + DryRun::Disabled, + printer, + ) + .await? + .into_environment()?, + }; + let _lock = environment + .lock() + .await + .inspect_err(|err| { + tracing::warn!("Failed to acquire environment lock: {err}"); + }) + .ok(); let module_owners = collect_module_owners( - virtual_project.workspace(), - &lock, + install_target, &environment, &settings, &client_builder, @@ -185,16 +249,15 @@ pub(crate) async fn metadata( } } -/// Build workspace metadata from an existing lock and environment without synchronizing it. +/// Build metadata from an existing lock and environment without synchronizing it. pub(crate) fn metadata_from_target( - workspace: &Workspace, environment: Option<&PythonEnvironment>, target: InstallTarget<'_>, extras: &ExtrasSpecificationWithDefaults, groups: &DependencyGroupsWithDefaults, settings: &ResolverSettings, ) -> Result { - let mut export = Metadata::from_lock(workspace, target.lock())?; + let mut export = metadata_for_target(target)?; if let Some(environment) = environment { let module_owners = find_module_owners(target, environment, extras, groups, settings) .context("Failed to collect module owners")?; @@ -206,6 +269,22 @@ pub(crate) fn metadata_from_target( Ok(export) } +fn metadata_for_target(target: InstallTarget<'_>) -> Result { + match target { + InstallTarget::Project { + workspace, lock, .. + } + | InstallTarget::Projects { + workspace, lock, .. + } + | InstallTarget::Workspace { workspace, lock } + | InstallTarget::NonProjectWorkspace { workspace, lock } => { + Ok(Metadata::from_lock(workspace, lock)?) + } + InstallTarget::Script { script, lock } => Ok(Metadata::from_script(&script.path, lock)?), + } +} + fn print_metadata(export: &Metadata, printer: Printer) -> Result { writeln!(printer.stdout(), "{}", export.to_json()?)?; diff --git a/crates/uv/src/commands/workspace/module_owners.rs b/crates/uv/src/commands/workspace/module_owners.rs index b0bd1d142b..227b6842c2 100644 --- a/crates/uv/src/commands/workspace/module_owners.rs +++ b/crates/uv/src/commands/workspace/module_owners.rs @@ -14,9 +14,9 @@ use uv_normalize::{DefaultExtras, DefaultGroups, PackageName}; use uv_preview::Preview; use uv_pypi_types::ModuleName; use uv_python::PythonEnvironment; -use uv_resolver::{Installable, Lock, Metadata}; +use uv_resolver::{Installable, Metadata}; use uv_settings::MalwareCheckSettings; -use uv_workspace::{Workspace, WorkspaceCache}; +use uv_workspace::WorkspaceCache; use crate::commands::pip::loggers::DefaultInstallLogger; use crate::commands::pip::operations::Modifications; @@ -33,8 +33,7 @@ use crate::settings::{InstallerSettingsRef, ResolverSettings}; /// without removing unrelated packages from an existing environment. Only distributions in the /// selected resolution are assigned package IDs, so those unrelated packages are not reported. pub(crate) async fn collect_module_owners( - workspace: &Workspace, - lock: &Lock, + target: InstallTarget<'_>, venv: &PythonEnvironment, settings: &ResolverSettings, client_builder: &BaseClientBuilder<'_>, @@ -45,9 +44,7 @@ pub(crate) async fn collect_module_owners( preview: Preview, malware_settings: &MalwareCheckSettings, ) -> Result>> { - let target = InstallTarget::Workspace { workspace, lock }; - let extras = ExtrasSpecification::from_all_extras().with_defaults(DefaultExtras::default()); - let groups = DependencyGroups::from_all_groups().with_defaults(DefaultGroups::default()); + let (extras, groups) = target_selection(target); let Some(package_ids) = selected_package_ids(target, venv, &extras, &groups, settings)? else { return Ok(BTreeMap::new()); }; @@ -170,6 +167,27 @@ fn find_module_owners_in_environment( .collect()) } +fn target_selection( + target: InstallTarget<'_>, +) -> ( + ExtrasSpecificationWithDefaults, + DependencyGroupsWithDefaults, +) { + match target { + InstallTarget::Script { .. } => ( + ExtrasSpecification::default().with_defaults(DefaultExtras::default()), + DependencyGroups::default().with_defaults(DefaultGroups::default()), + ), + InstallTarget::Project { .. } + | InstallTarget::Projects { .. } + | InstallTarget::Workspace { .. } + | InstallTarget::NonProjectWorkspace { .. } => ( + ExtrasSpecification::from_all_extras().with_defaults(DefaultExtras::default()), + DependencyGroups::from_all_groups().with_defaults(DefaultGroups::default()), + ), + } +} + fn is_virtual(dist: &ResolvedDist) -> bool { let ResolvedDist::Installable { dist, .. } = dist else { return false; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 18e8370b20..ad5c96ce5c 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -362,6 +362,10 @@ async fn run(cli: Cli) -> Result { | ProjectCommand::Audit(uv_cli::AuditArgs { script: Some(script), .. + }) + | ProjectCommand::Check(uv_cli::CheckArgs { + script: Some(script), + .. }) => match Pep723Script::read(script).await { Ok(Some(script)) => Some(Pep723Item::Script(script)), Ok(None) => { @@ -382,6 +386,29 @@ async fn run(cli: Cli) -> Result { }, _ => None, } + } else if let Commands::Workspace(WorkspaceNamespace { + command: WorkspaceCommand::Metadata(args), + }) = &*cli.command + && let Some(script) = args.script.as_ref() + { + match Pep723Script::read(script).await { + Ok(Some(script)) => Some(Pep723Item::Script(script)), + Ok(None) => { + bail!( + "`{}` does not contain a PEP 723 metadata tag; run `{}` to initialize the script", + script.user_display().cyan(), + format!("uv init --script {}", script.user_display()).green() + ) + } + Err(Pep723Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => { + bail!( + "Failed to read `{}` (not found); run `{}` to create a PEP 723 script", + script.user_display().cyan(), + format!("uv init --script {}", script.user_display()).green() + ) + } + Err(err) => return Err(err.into()), + } } else if let Commands::Python(uv_cli::PythonNamespace { command: PythonCommand::Find(uv_cli::PythonFindArgs { @@ -1995,6 +2022,11 @@ async fn run(cli: Cli) -> Result { .combine(Refresh::from(args.settings.upgrade.clone())), ); + let script = script.and_then(|script| match script { + Pep723Item::Script(script) => Some(script), + Pep723Item::Remote(..) | Pep723Item::Stdin(..) => None, + }); + Box::pin(commands::metadata( &project_dir, args.lock_check, @@ -2007,6 +2039,7 @@ async fn run(cli: Cli) -> Result { args.malware_settings, args.settings, client_builder.subcommand(vec!["workspace".to_owned(), "metadata".to_owned()]), + script, globals.python_preference, globals.python_downloads, globals.concurrency, @@ -2783,6 +2816,11 @@ async fn run_project( .combine(Refresh::from(args.settings.resolver.upgrade.clone())), ); + let script = script.and_then(|script| match script { + Pep723Item::Script(script) => Some(script), + Pep723Item::Remote(..) | Pep723Item::Stdin(..) => None, + }); + Box::pin(commands::check( project_dir, args.ty_path, @@ -2797,6 +2835,7 @@ async fn run_project( args.settings, args.ty_version, args.show_version, + script, client_builder.subcommand(vec!["check".to_owned()]), globals.python_preference, globals.python_downloads, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index fd823aa841..137978b521 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -2093,6 +2093,8 @@ impl UpgradeSettings { /// The resolved settings to use for a `lock` invocation. #[derive(Debug, Clone)] pub(crate) struct MetadataSettings { + #[expect(dead_code)] + pub(crate) script: Option, pub(crate) lock_check: LockCheck, pub(crate) frozen: Option, pub(crate) dry_run: DryRun, @@ -2112,6 +2114,7 @@ impl MetadataSettings { environment: EnvironmentOptions, ) -> Self { let MetadataArgs { + script, locked, frozen, dry_run, @@ -2137,6 +2140,7 @@ impl MetadataSettings { let malware_settings = MalwareCheckSettings::from(&environment); Self { + script, lock_check: resolve_lock_check(locked), frozen: resolve_frozen(frozen), dry_run: DryRun::from_args(dry_run), @@ -2652,7 +2656,7 @@ pub(crate) struct TreeSettings { pub(crate) invert: bool, pub(crate) outdated: bool, pub(crate) show_sizes: bool, - #[allow(dead_code)] + #[expect(dead_code)] pub(crate) script: Option, pub(crate) python_version: Option, pub(crate) python_platform: Option, @@ -2975,6 +2979,8 @@ impl FormatSettings { #[derive(Debug, Clone)] pub(crate) struct CheckSettings { pub(crate) ty_path: Option, + #[expect(dead_code)] + pub(crate) script: Option, pub(crate) extras: ExtrasSpecification, pub(crate) groups: DependencyGroups, pub(crate) lock_check: LockCheck, @@ -2999,6 +3005,7 @@ impl CheckSettings { environment: EnvironmentOptions, ) -> Self { let CheckArgs { + script, extra, all_extras, no_extra, @@ -3052,6 +3059,7 @@ impl CheckSettings { Self { ty_path: environment.ty_path, + script, extras: ExtrasSpecification::from_args( extra.unwrap_or_default(), no_extra, diff --git a/crates/uv/tests/project/check.rs b/crates/uv/tests/project/check.rs index bb22bf9d46..ecef4040fe 100644 --- a/crates/uv/tests/project/check.rs +++ b/crates/uv/tests/project/check.rs @@ -559,6 +559,53 @@ fn check_uses_ty_from_environment() -> Result<()> { Ok(()) } +#[test] +#[cfg(feature = "test-pypi")] +fn check_script() -> Result<()> { + let context = + uv_test::test_context!("3.12").with_filter((r"WARN Failed to fetch `ty`[^\n]*\n", "")); + + // If `ty` accidentally uses the workspace environment, it will see this incompatible stub + // instead of the script dependency and report that `IniConfig` is not callable. + let workspace_iniconfig = context.site_packages().join("iniconfig"); + fs_err::create_dir_all(&workspace_iniconfig)?; + fs_err::write(workspace_iniconfig.join("__init__.pyi"), "IniConfig: int\n")?; + + let script = context.temp_dir.child("-script.py"); + script.write_str(indoc! {r#" + # /// script + # requires-python = ">=3.12" + # dependencies = ["iniconfig"] + # /// + + import iniconfig + + iniconfig.IniConfig("config.ini") + "#})?; + context + .temp_dir + .child("unrelated.py") + .write_str(indoc! {r#" + value: int = "wrong" + "#})?; + + uv_snapshot!(context.filters(), context.check().arg("--script").arg(script.path()).arg("--no-sync"), @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + warning: `uv check` is experimental and may change without warning. Pass `--preview-features check-command` to disable this warning. + Installed 1 package in [TIME] + warning: `--no-sync` is a no-op for Python scripts with inline metadata, which always run in isolation + "); + + assert!(!context.temp_dir.child("-script.py.lock").exists()); + + Ok(()) +} + #[test] fn check_passes_workspace_metadata_to_ty() -> Result<()> { let context = uv_test::test_context!("3.12"); @@ -648,6 +695,53 @@ fn check_no_sync_errors_on_invalid_lockfile() -> Result<()> { Ok(()) } +#[test] +fn check_script_no_sync_errors_on_invalid_lockfile() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let script = context.temp_dir.child("script.py"); + script.write_str(indoc! {r#" + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// + + x: int = 1 + "#})?; + context + .temp_dir + .child("script.py.lock") + .write_str("invalid")?; + + uv_snapshot!( + context.filters(), + context + .check() + .arg("--script") + .arg(script.path()) + .arg("--no-sync") + .arg("--ty-version") + .arg("0.0.17") + .env(EnvVars::RUST_LOG, "error"), + @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv check` is experimental and may change without warning. Pass `--preview-features check-command` to disable this warning. + error: Failed to parse `uv.lock` + Caused by: TOML parse error at line 1, column 8 + | + 1 | invalid + | ^ + key with no value, expected `=` + " + ); + + Ok(()) +} + #[test] fn check_rejects_tool_arguments() { let context = uv_test::test_context_with_versions!(&[]); diff --git a/crates/uv/tests/workspace/workspace_metadata.rs b/crates/uv/tests/workspace/workspace_metadata.rs index 66e912e059..8124fd3075 100644 --- a/crates/uv/tests/workspace/workspace_metadata.rs +++ b/crates/uv/tests/workspace/workspace_metadata.rs @@ -15,6 +15,17 @@ fn write_wheel( name: &str, dist_info_prefix: &str, files: &[(&str, &str)], +) -> Result<()> { + write_wheel_with_metadata(path, name, "0.1.0", dist_info_prefix, "", files) +} + +fn write_wheel_with_metadata( + path: &Path, + name: &str, + version: &str, + dist_info_prefix: &str, + additional_metadata: &str, + files: &[(&str, &str)], ) -> Result<()> { let mut writer = ZipFileWriter::new(Vec::new()); let mut record = Vec::new(); @@ -27,10 +38,15 @@ fn write_wheel( let metadata_path = format!("{dist_info_prefix}.dist-info/METADATA"); let entry = ZipEntryBuilder::new(metadata_path.clone().into(), Compression::Stored); - block_on(writer.write_entry_whole( - entry, - format!("Metadata-Version: 2.1\nName: {name}\nVersion: 0.1.0\n").as_bytes(), - ))?; + block_on( + writer.write_entry_whole( + entry, + format!( + "Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n{additional_metadata}" + ) + .as_bytes(), + ), + )?; record.push(format!("{metadata_path},,")); let wheel_path = format!("{dist_info_prefix}.dist-info/WHEEL"); @@ -102,6 +118,303 @@ fn workspace_metadata_simple() { ); } +#[test] +#[cfg(feature = "test-pypi")] +fn workspace_metadata_script() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let script = context.temp_dir.child("script.py"); + script.write_str( + r#"# /// script +# requires-python = ">=3.12" +# dependencies = ["iniconfig"] +# /// + +import iniconfig +"#, + )?; + + uv_snapshot!( + context.filters(), + context + .workspace_metadata() + .arg("--script") + .arg(script.path()) + .arg("--sync"), + @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "workspace_root": "[TEMP_DIR]/", + "environment": { + "root": "[CACHE_DIR]/environments-v2/script-[HASH]" + }, + "script": { + "path": "[TEMP_DIR]/script.py", + "id": "script+[TEMP_DIR]/script.py" + }, + "requires_python": ">=3.12", + "conflicts": { + "sets": [] + }, + "module_owners": { + "iniconfig": [ + { + "package_id": "iniconfig==2.0.0@registry+https://pypi.org/simple" + } + ], + "iniconfig._parse": [ + { + "package_id": "iniconfig==2.0.0@registry+https://pypi.org/simple" + } + ], + "iniconfig._version": [ + { + "package_id": "iniconfig==2.0.0@registry+https://pypi.org/simple" + } + ], + "iniconfig.exceptions": [ + { + "package_id": "iniconfig==2.0.0@registry+https://pypi.org/simple" + } + ] + }, + "resolution": { + "iniconfig==2.0.0@registry+https://pypi.org/simple": { + "name": "iniconfig", + "version": "2.0.0", + "source": { + "registry": { + "url": "https://pypi.org/simple" + } + }, + "kind": "package", + "dependencies": [], + "sdist": { + "url": "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", + "hashes": { + "sha256": "2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3" + }, + "size": 4646, + "upload_time": "2023-01-07T11:08:11.254Z" + }, + "wheels": [ + { + "url": "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", + "hashes": { + "sha256": "b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + }, + "size": 5892, + "upload_time": "2023-01-07T11:08:09.864Z", + "filename": "iniconfig-2.0.0-py3-none-any.whl" + } + ] + }, + "script+[TEMP_DIR]/script.py": { + "kind": "script", + "path": "[TEMP_DIR]/script.py", + "dependencies": [ + { + "id": "iniconfig==2.0.0@registry+https://pypi.org/simple" + } + ] + } + } + } + + ----- stderr ----- + warning: The `uv workspace metadata` command is experimental and may change without warning. Pass `--preview-features workspace-metadata` to disable this warning. + Resolved 1 package in [TIME] + "# + ); + + assert!(!context.temp_dir.child("script.py.lock").exists()); + + Ok(()) +} + +#[test] +fn workspace_metadata_script_no_dependencies() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let script = context.temp_dir.child("script.py"); + script.write_str( + r#"# /// script +# requires-python = ">=3.12" +# dependencies = [] +# /// + +print("Hello, world!") +"#, + )?; + + uv_snapshot!( + context.filters(), + context + .workspace_metadata() + .arg("--script") + .arg(script.path()), + @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "workspace_root": "[TEMP_DIR]/", + "script": { + "path": "[TEMP_DIR]/script.py", + "id": "script+[TEMP_DIR]/script.py" + }, + "requires_python": ">=3.12", + "conflicts": { + "sets": [] + }, + "resolution": { + "script+[TEMP_DIR]/script.py": { + "kind": "script", + "path": "[TEMP_DIR]/script.py", + "dependencies": [] + } + } + } + + ----- stderr ----- + warning: The `uv workspace metadata` command is experimental and may change without warning. Pass `--preview-features workspace-metadata` to disable this warning. + Resolved in [TIME] + "# + ); + + assert!(!context.temp_dir.child("script.py.lock").exists()); + + Ok(()) +} + +#[test] +fn workspace_metadata_script_dependency_edges() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let child = context + .temp_dir + .child("metadata_edge_child-0.1.0-py3-none-any.whl"); + write_wheel( + child.path(), + "metadata-edge-child", + "metadata_edge_child-0.1.0", + &[], + )?; + let child_url = Url::from_file_path(child.path()) + .map_err(|()| anyhow::anyhow!("failed to convert wheel path to file URL"))?; + + let first = context + .temp_dir + .child("metadata_edge-1.0.0-py3-none-any.whl"); + write_wheel_with_metadata( + first.path(), + "metadata-edge", + "1.0.0", + "metadata_edge-1.0.0", + &format!( + "Provides-Extra: feature\nRequires-Dist: metadata-edge-child @ {child_url}; extra == 'feature'\n" + ), + &[], + )?; + let first_url = Url::from_file_path(first.path()) + .map_err(|()| anyhow::anyhow!("failed to convert wheel path to file URL"))?; + + let second = context + .temp_dir + .child("metadata_edge-2.0.0-py3-none-any.whl"); + write_wheel_with_metadata( + second.path(), + "metadata-edge", + "2.0.0", + "metadata_edge-2.0.0", + &format!( + "Provides-Extra: feature\nRequires-Dist: metadata-edge-child @ {child_url}; extra == 'feature'\n" + ), + &[], + )?; + let second_url = Url::from_file_path(second.path()) + .map_err(|()| anyhow::anyhow!("failed to convert wheel path to file URL"))?; + + let script = context.temp_dir.child("script.py"); + script.write_str(&format!( + r#"# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "metadata-edge[feature] @ {first_url}; sys_platform == 'win32'", +# "metadata-edge[feature] @ {second_url}; sys_platform != 'win32'", +# ] +# /// +"# + ))?; + + let assert = context + .workspace_metadata() + .arg("--script") + .arg(script.path()) + .assert() + .success(); + let metadata: serde_json::Value = serde_json::from_slice(&assert.get_output().stdout)?; + + let resolution = metadata["resolution"] + .as_object() + .ok_or_else(|| anyhow::anyhow!("metadata resolution was not an object"))?; + let script_id = metadata["script"]["id"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("script ID was not a string"))?; + let script_node = resolution + .get(script_id) + .ok_or_else(|| anyhow::anyhow!("missing resolution node for {script_id}"))?; + + insta::with_settings!({ filters => context.filters() }, { + insta::assert_json_snapshot!(serde_json::json!({ + "script": metadata["script"], + "node": script_node, + }), @r#" + { + "node": { + "dependencies": [ + { + "id": "metadata-edge[feature]==2.0.0@path+[TEMP_DIR]/metadata_edge-2.0.0-py3-none-any.whl", + "marker": "sys_platform != 'win32'" + }, + { + "id": "metadata-edge[feature]==1.0.0@path+[TEMP_DIR]/metadata_edge-1.0.0-py3-none-any.whl", + "marker": "sys_platform == 'win32'" + } + ], + "kind": "script", + "path": "[TEMP_DIR]/script.py" + }, + "script": { + "id": "script+[TEMP_DIR]/script.py", + "path": "[TEMP_DIR]/script.py" + } + } + "#); + }); + + for dependency in script_node["dependencies"] + .as_array() + .ok_or_else(|| anyhow::anyhow!("script dependencies was not an array"))? + { + let id = dependency["id"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("script dependency ID was not a string"))?; + anyhow::ensure!( + resolution.contains_key(id), + "missing resolution node for {id}" + ); + } + + Ok(()) +} + #[test] fn workspace_metadata_module_owners_from_locked_wheels() -> Result<()> { let context = uv_test::test_context!("3.12"); diff --git a/docs/reference/internals/metadata.md b/docs/reference/internals/metadata.md index 3e0a655201..07ebe3ade4 100644 --- a/docs/reference/internals/metadata.md +++ b/docs/reference/internals/metadata.md @@ -1,8 +1,9 @@ # Workspace metadata -`uv workspace metadata` exports the information uv has about your workspace as JSON so other tools -can use it. In particular, if you want access to the information in a `uv.lock`, you should prefer -this command's output, as `uv.lock` is not a stable format we guarantee anything about. +`uv workspace metadata` exports the information uv has about your workspace or PEP 723 script as +JSON so other tools can use it. In particular, if you want access to the information in a `uv.lock` +or script lockfile, you should prefer this command's output, as lockfiles are not a stable format we +guarantee anything about. Pass `--script path/to/script.py` to request metadata for a script. The primary structure is the "resolution" field which contains the dependency graph with exact package versions that a `uv.lock` encodes. @@ -14,10 +15,12 @@ for the node it refers to, and an optional `marker` that [specifies on what platforms the dependency is required](https://packaging.python.org/en/latest/specifications/dependency-specifiers/#dependency-specifiers) (if there is no marker the dependency is always required). -Nodes in the graph are uniquely identified by package `name`, `version`, `source`, and `kind`. +Package-derived nodes in the graph are uniquely identified by package `name`, `version`, `source`, +and `kind`. Script nodes are identified by their path. All node ids should be treated as opaque. -There are 3 kinds of node in the graph: +There are 4 kinds of node in the graph: +- `"script"` -- a PEP 723 script and its direct dependencies - `"package"` -- the package itself - `{ "extra": "extraname" }` -- an extra the package defines - `{ "group": "groupname" }` -- a dependency group the package defines @@ -63,15 +66,15 @@ the graph and get actual resolutions you will likely need to consult `conflicts` understand how to resolve `markers` for a specific platform. The best way to avoid mistakes when working with multiple versions of a package is to keep your -queries into the dependency graph rooted in operations on workspace members, as those are the -natural entry-points to the graph that uv wants to work on, and can give coherent responses for: -"install `member1` and `member2[extra]`". +queries into the dependency graph rooted in operations on workspace members or the requested script. +These are the natural entry-points to the graph and can give coherent responses for operations such +as "install `member1` and `member2[extra]`" or "install this script's declared dependencies". Another way to put this is that when possible _you should avoid iterating over the `resolution` object to find a node_. Only access `resolution` like a map using ids that were provided by another -part of the metadata. The only ids this initially gives you access to are the ones listed in the -`members` array, which lists all the workspace members. From there you may find the ids of that -package's dependencies, extras, and dependency groups and recursively discover other packages. +part of the metadata. For a workspace, the initial ids are listed in the `members` array. For a +script, the initial id is `script.id`. From there you may recursively discover other packages by +following dependency edges. So rather than trying to find a node for anyio in the dependency graph directly, you should decide what workspace member(s) you're interested in analyzing as if they were going to be installed. While @@ -102,6 +105,13 @@ for member_name in ["package1", "package2"]: visit(metadata, to_analyze) ``` +For a script, start from its resolution node in exactly the same way: + +```python +script_node = metadata.resolution[metadata.script.id] +visit(metadata, [script_node]) +``` + Where `visit` is your favourite graph traversal algorithm like depth-first-search: ```python @@ -147,6 +157,14 @@ Here is a human-readable annotated example: // The absolute path to the environment root "root": "/workspace/.venv" }, + // Information about the script target, only present with `--script`. + // Workspace metadata uses `members` below as its graph entry-points instead. + "script": { + // The absolute path to the script + "path": "/workspace/script.py", + // The id of the script's node in the `resolution` map below + "id": "script+/workspace/script.py" + }, // Any requirements on the python version this workspace has // // `marker` fields all have this as an implicit constraint that is omitted for cleanliness @@ -193,8 +211,9 @@ Here is a human-readable annotated example: // Resolved information about packages and dependencies. // // Each entry in this map is a node in the dependency graph. There are currently - // 3 kinds of node in the dependency graph, although more are planned in the future. + // 4 kinds of node in the dependency graph, although more are planned in the future. // + // * Scripts -- "kind": "script" // * Packages -- "kind": "package" // * Extras -- "kind": { "extra": "extraname" } // * Groups -- "kind": { "group": "groupname" } @@ -211,6 +230,18 @@ Here is a human-readable annotated example: // the same information in a more convenient form). "resolution": { + // The script node is present when metadata was requested with `--script`. Its dependencies + // are the direct requirements declared by the script. + "script+/workspace/script.py": { + "kind": "script", + "path": "/workspace/script.py", + "dependencies": [ + { + "id": "iniconfig==2.0.0@registry+https://pypi.org/simple" + } + ] + }, + // This node is a workspace member "mypackage==0.1.0@editable+/workspace/packages/mypackage": { // The name of the package