Implement uv build (#6895)

## Summary

This PR exposes uv's PEP 517 implementation via a `uv build` frontend,
such that you can use `uv build` to build source and binary
distributions (i.e., wheels and sdists) from a given directory.

There are some TODOs that I'll tackle in separate PRs:

- [x] Support building a wheel from a source distribution (rather than
from source) (#6898)
- [x] Stream the build output (#6912)

Closes https://github.com/astral-sh/uv/issues/1510

Closes https://github.com/astral-sh/uv/issues/1663.
This commit is contained in:
Charlie Marsh 2024-09-04 11:23:46 -04:00 committed by GitHub
parent a3a1bfd5ec
commit df84d25a7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1028 additions and 168 deletions

View file

@ -80,7 +80,7 @@ static DEFAULT_BACKEND: LazyLock<Pep517Backend> = LazyLock::new(|| Pep517Backend
pub enum Error {
#[error(transparent)]
Io(#[from] io::Error),
#[error("{} does not appear to be a Python project, as neither `pyproject.toml` nor `setup.py` is present in the directory", _0.simplified_display())]
#[error("{} does not appear to be a Python project, as neither `pyproject.toml` nor `setup.py` are present in the directory", _0.simplified_display())]
InvalidSourceDist(PathBuf),
#[error("Invalid `pyproject.toml`")]
InvalidPyprojectToml(#[from] toml::de::Error),

View file

@ -339,6 +339,21 @@ pub enum Commands {
after_long_help = ""
)]
Venv(VenvArgs),
/// Build Python packages into source distributions and wheels.
///
/// By default, `uv build` will build a source distribution ("sdist")
/// from the source directory, and a binary distribution ("wheel") from
/// the source distribution.
///
/// `uv build --sdist` can be used to build only the source distribution,
/// `uv build --wheel` can be used to build only the binary distribution,
/// and `uv build --sdist --wheel` can be used to build both distributions
/// from source.
#[command(
after_help = "Use `uv help build` for more details.",
after_long_help = ""
)]
Build(BuildArgs),
/// Manage uv's cache.
#[command(
after_help = "Use `uv help cache` for more details.",
@ -1126,7 +1141,7 @@ pub struct PipSyncArgs {
/// The Python interpreter into which packages should be installed.
///
/// By default, syncing requires a virtual environment. An path to an
/// By default, syncing requires a virtual environment. A path to an
/// alternative Python can be provided, but it is only recommended in
/// continuous integration (CI) environments and should be used with
/// caution, as it can modify the system Python installation.
@ -1408,7 +1423,7 @@ pub struct PipInstallArgs {
/// The Python interpreter into which packages should be installed.
///
/// By default, installation requires a virtual environment. An path to an
/// By default, installation requires a virtual environment. A path to an
/// alternative Python can be provided, but it is only recommended in
/// continuous integration (CI) environments and should be used with
/// caution, as it can modify the system Python installation.
@ -1573,7 +1588,7 @@ pub struct PipUninstallArgs {
/// The Python interpreter from which packages should be uninstalled.
///
/// By default, uninstallation requires a virtual environment. An path to an
/// By default, uninstallation requires a virtual environment. A path to an
/// alternative Python can be provided, but it is only recommended in
/// continuous integration (CI) environments and should be used with
/// caution, as it can modify the system Python installation.
@ -1924,6 +1939,55 @@ pub struct PipTreeArgs {
pub compat_args: compat::PipGlobalCompatArgs,
}
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct BuildArgs {
/// The directory from which distributions should be built.
///
/// Defaults to the current working directory.
#[arg(value_parser = parse_file_path)]
pub src_dir: Option<PathBuf>,
/// The output directory to which distributions should be written.
///
/// Defaults to the `dist` subdirectory within the source directory.
#[arg(long, short, value_parser = parse_file_path)]
pub out_dir: Option<PathBuf>,
/// Build a source distribution ("sdist") from the given directory.
#[arg(long)]
pub sdist: bool,
/// Build a binary distribution ("wheel") from the given directory.
#[arg(long)]
pub wheel: bool,
/// The Python interpreter to use for the build environment.
///
/// By default, builds are executed in isolated virtual environments. The
/// discovered interpreter will be used to create those environments, and
/// will be symlinked or copied in depending on the platform.
///
/// See `uv help python` to view supported request formats.
#[arg(
long,
short,
env = "UV_PYTHON",
verbatim_doc_comment,
help_heading = "Python options"
)]
pub python: Option<String>,
#[command(flatten)]
pub resolver: ResolverArgs,
#[command(flatten)]
pub build: BuildOptionsArgs,
#[command(flatten)]
pub refresh: RefreshArgs,
}
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct VenvArgs {
@ -2318,7 +2382,7 @@ pub struct RunArgs {
pub installer: ResolverInstallerArgs,
#[command(flatten)]
pub build: BuildArgs,
pub build: BuildOptionsArgs,
#[command(flatten)]
pub refresh: RefreshArgs,
@ -2452,7 +2516,7 @@ pub struct SyncArgs {
pub installer: ResolverInstallerArgs,
#[command(flatten)]
pub build: BuildArgs,
pub build: BuildOptionsArgs,
#[command(flatten)]
pub refresh: RefreshArgs,
@ -2505,7 +2569,7 @@ pub struct LockArgs {
pub resolver: ResolverArgs,
#[command(flatten)]
pub build: BuildArgs,
pub build: BuildOptionsArgs,
#[command(flatten)]
pub refresh: RefreshArgs,
@ -2619,7 +2683,7 @@ pub struct AddArgs {
pub installer: ResolverInstallerArgs,
#[command(flatten)]
pub build: BuildArgs,
pub build: BuildOptionsArgs,
#[command(flatten)]
pub refresh: RefreshArgs,
@ -2688,7 +2752,7 @@ pub struct RemoveArgs {
pub installer: ResolverInstallerArgs,
#[command(flatten)]
pub build: BuildArgs,
pub build: BuildOptionsArgs,
#[command(flatten)]
pub refresh: RefreshArgs,
@ -2748,7 +2812,7 @@ pub struct TreeArgs {
pub frozen: bool,
#[command(flatten)]
pub build: BuildArgs,
pub build: BuildOptionsArgs,
#[command(flatten)]
pub resolver: ResolverArgs,
@ -2853,7 +2917,7 @@ pub struct ExportArgs {
pub resolver: ResolverArgs,
#[command(flatten)]
pub build: BuildArgs,
pub build: BuildOptionsArgs,
#[command(flatten)]
pub refresh: RefreshArgs,
@ -3000,7 +3064,7 @@ pub struct ToolRunArgs {
pub installer: ResolverInstallerArgs,
#[command(flatten)]
pub build: BuildArgs,
pub build: BuildOptionsArgs,
#[command(flatten)]
pub refresh: RefreshArgs,
@ -3052,7 +3116,7 @@ pub struct ToolInstallArgs {
pub installer: ResolverInstallerArgs,
#[command(flatten)]
pub build: BuildArgs,
pub build: BuildOptionsArgs,
#[command(flatten)]
pub refresh: RefreshArgs,
@ -3137,7 +3201,7 @@ pub struct ToolUpgradeArgs {
pub installer: ResolverInstallerArgs,
#[command(flatten)]
pub build: BuildArgs,
pub build: BuildOptionsArgs,
}
#[derive(Args)]
@ -3441,7 +3505,7 @@ pub struct RefreshArgs {
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct BuildArgs {
pub struct BuildOptionsArgs {
/// Don't build source distributions.
///
/// When enabled, resolving will not run arbitrary Python code. The cached wheels of

View file

@ -4,7 +4,8 @@ use uv_resolver::PrereleaseMode;
use uv_settings::{PipOptions, ResolverInstallerOptions, ResolverOptions};
use crate::{
BuildArgs, IndexArgs, InstallerArgs, Maybe, RefreshArgs, ResolverArgs, ResolverInstallerArgs,
BuildOptionsArgs, IndexArgs, InstallerArgs, Maybe, RefreshArgs, ResolverArgs,
ResolverInstallerArgs,
};
/// Given a boolean flag pair (like `--upgrade` and `--no-upgrade`), resolve the value of the flag.
@ -206,8 +207,11 @@ impl From<IndexArgs> for PipOptions {
}
}
/// Construct the [`ResolverOptions`] from the [`ResolverArgs`] and [`BuildArgs`].
pub fn resolver_options(resolver_args: ResolverArgs, build_args: BuildArgs) -> ResolverOptions {
/// Construct the [`ResolverOptions`] from the [`ResolverArgs`] and [`BuildOptionsArgs`].
pub fn resolver_options(
resolver_args: ResolverArgs,
build_args: BuildOptionsArgs,
) -> ResolverOptions {
let ResolverArgs {
index_args,
upgrade,
@ -228,7 +232,7 @@ pub fn resolver_options(resolver_args: ResolverArgs, build_args: BuildArgs) -> R
no_sources,
} = resolver_args;
let BuildArgs {
let BuildOptionsArgs {
no_build,
build,
no_build_package,
@ -281,10 +285,10 @@ pub fn resolver_options(resolver_args: ResolverArgs, build_args: BuildArgs) -> R
}
}
/// Construct the [`ResolverInstallerOptions`] from the [`ResolverInstallerArgs`] and [`BuildArgs`].
/// Construct the [`ResolverInstallerOptions`] from the [`ResolverInstallerArgs`] and [`BuildOptionsArgs`].
pub fn resolver_installer_options(
resolver_installer_args: ResolverInstallerArgs,
build_args: BuildArgs,
build_args: BuildOptionsArgs,
) -> ResolverInstallerOptions {
let ResolverInstallerArgs {
index_args,
@ -311,7 +315,7 @@ pub fn resolver_installer_options(
no_sources,
} = resolver_installer_args;
let BuildArgs {
let BuildOptionsArgs {
no_build,
build,
no_build_package,

View file

@ -18,23 +18,16 @@ workspace = true
[dependencies]
distribution-filename = { workspace = true }
distribution-types = { workspace = true }
install-wheel-rs = { workspace = true }
pep508_rs = { workspace = true }
pypi-types = { workspace = true }
uv-build = { workspace = true }
uv-cache = { workspace = true, features = ["clap"] }
uv-cli = { workspace = true }
uv-client = { workspace = true }
uv-configuration = { workspace = true }
uv-dispatch = { workspace = true }
uv-git = { workspace = true }
uv-installer = { workspace = true }
uv-macros = { workspace = true }
uv-options-metadata = { workspace = true }
uv-python = { workspace = true }
uv-resolver = { workspace = true }
uv-settings = { workspace = true, features = ["schemars"] }
uv-types = { workspace = true }
uv-workspace = { workspace = true, features = ["schemars"] }
# Any dependencies that are exclusively used in `uv-dev` should be listed as non-workspace
@ -44,12 +37,11 @@ anyhow = { workspace = true }
clap = { workspace = true, features = ["derive", "wrap_help"] }
fs-err = { workspace = true, features = ["tokio"] }
itertools = { workspace = true }
markdown = "0.3.0"
markdown = { version = "0.3.0" }
owo-colors = { workspace = true }
poloto = { version = "19.1.2", optional = true }
pretty_assertions = { version = "1.4.0" }
resvg = { version = "0.29.0", optional = true }
rustc-hash = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

View file

@ -1,114 +0,0 @@
use std::env;
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::Parser;
use fs_err as fs;
use rustc_hash::FxHashMap;
use distribution_types::IndexLocations;
use uv_build::{SourceBuild, SourceBuildContext};
use uv_cache::{Cache, CacheArgs};
use uv_client::RegistryClientBuilder;
use uv_configuration::{
BuildKind, BuildOptions, Concurrency, ConfigSettings, IndexStrategy, SourceStrategy,
};
use uv_dispatch::BuildDispatch;
use uv_git::GitResolver;
use uv_python::{EnvironmentPreference, PythonEnvironment, PythonRequest};
use uv_resolver::{FlatIndex, InMemoryIndex};
use uv_types::{BuildIsolation, InFlight};
#[derive(Parser)]
pub(crate) struct BuildArgs {
/// Base python in a way that can be found with `which`
/// TODO(konstin): Also use proper python parsing here
#[clap(short, long)]
python: Option<PathBuf>,
/// Directory to story the built wheel in
#[clap(short, long)]
wheels: Option<PathBuf>,
/// The source distribution to build, as a directory.
sdist: PathBuf,
/// The subdirectory to build within the source distribution.
subdirectory: Option<PathBuf>,
/// You can edit the python sources of an editable install and the changes will be used without
/// the need to reinstall it.
#[clap(short, long)]
editable: bool,
#[command(flatten)]
cache_args: CacheArgs,
}
/// Build a source distribution to a wheel
pub(crate) async fn build(args: BuildArgs) -> Result<PathBuf> {
let wheel_dir = if let Some(wheel_dir) = args.wheels {
fs::create_dir_all(&wheel_dir).context("Invalid wheel directory")?;
wheel_dir
} else {
env::current_dir()?
};
let build_kind = if args.editable {
BuildKind::Editable
} else {
BuildKind::Wheel
};
let cache = Cache::try_from(args.cache_args)?.init()?;
let client = RegistryClientBuilder::new(cache.clone()).build();
let concurrency = Concurrency::default();
let config_settings = ConfigSettings::default();
let exclude_newer = None;
let flat_index = FlatIndex::default();
let git = GitResolver::default();
let in_flight = InFlight::default();
let index = InMemoryIndex::default();
let index_urls = IndexLocations::default();
let index_strategy = IndexStrategy::default();
let sources = SourceStrategy::default();
let python = PythonEnvironment::find(
&PythonRequest::default(),
EnvironmentPreference::OnlyVirtual,
&cache,
)?;
let build_options = BuildOptions::default();
let build_constraints = [];
let build_dispatch = BuildDispatch::new(
&client,
&cache,
&build_constraints,
python.interpreter(),
&index_urls,
&flat_index,
&index,
&git,
&in_flight,
index_strategy,
&config_settings,
BuildIsolation::Isolated,
install_wheel_rs::linker::LinkMode::default(),
&build_options,
exclude_newer,
sources,
concurrency,
);
let builder = SourceBuild::setup(
&args.sdist,
args.subdirectory.as_deref(),
None,
python.interpreter(),
&build_dispatch,
SourceBuildContext::default(),
args.sdist.display().to_string(),
config_settings.clone(),
BuildIsolation::Isolated,
build_kind,
FxHashMap::default(),
concurrency.builds,
)
.await?;
Ok(wheel_dir.join(builder.build(&wheel_dir).await?))
}

View file

@ -4,7 +4,7 @@ use std::process::ExitCode;
use std::str::FromStr;
use std::time::Instant;
use anstream::{eprintln, println};
use anstream::eprintln;
use anyhow::Result;
use clap::Parser;
use owo_colors::OwoColorize;
@ -16,7 +16,6 @@ use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, Layer};
use crate::build::{build, BuildArgs};
use crate::clear_compile::ClearCompileArgs;
use crate::compile::CompileArgs;
use crate::generate_all::Args as GenerateAllArgs;
@ -43,7 +42,6 @@ static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
mod build;
mod clear_compile;
mod compile;
mod generate_all;
@ -57,8 +55,6 @@ const ROOT_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../");
#[derive(Parser)]
enum Cli {
/// Build a source distribution into a wheel.
Build(BuildArgs),
/// Display the metadata for a `.whl` at a given URL.
WheelMetadata(WheelMetadataArgs),
/// Compile all `.py` to `.pyc` files in the tree.
@ -82,10 +78,6 @@ enum Cli {
async fn run() -> Result<()> {
let cli = Cli::parse();
match cli {
Cli::Build(args) => {
let target = build(args).await?;
println!("Wheel built to {}", target.display());
}
Cli::WheelMetadata(args) => wheel_metadata::wheel_metadata(args).await?,
Cli::Compile(args) => compile::compile(args).await?,
Cli::ClearCompile(args) => clear_compile::clear_compile(&args)?,

View file

@ -15,6 +15,7 @@ workspace = true
[dependencies]
cache-key = { workspace = true }
distribution-filename = { workspace = true }
distribution-types = { workspace = true }
install-wheel-rs = { workspace = true, default-features = false }
pep440_rs = { workspace = true }
@ -28,6 +29,7 @@ uv-client = { workspace = true }
uv-configuration = { workspace = true }
uv-dispatch = { workspace = true }
uv-distribution = { workspace = true }
uv-extract = { workspace = true }
uv-fs = { workspace = true }
uv-git = { workspace = true }
uv-installer = { workspace = true }
@ -64,6 +66,7 @@ regex = { workspace = true }
rustc-hash = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tempfile = { workspace = true }
textwrap = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }

View file

@ -0,0 +1,383 @@
use crate::commands::project::find_requires_python;
use crate::commands::reporters::PythonDownloadReporter;
use crate::commands::{ExitStatus, SharedState};
use crate::printer::Printer;
use crate::settings::{ResolverSettings, ResolverSettingsRef};
use std::borrow::Cow;
use anyhow::Result;
use distribution_filename::SourceDistExtension;
use owo_colors::OwoColorize;
use std::path::{Path, PathBuf};
use uv_auth::store_credentials_from_url;
use uv_cache::Cache;
use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder};
use uv_configuration::{BuildKind, Concurrency};
use uv_dispatch::BuildDispatch;
use uv_fs::{Simplified, CWD};
use uv_python::{
EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation,
PythonPreference, PythonRequest, PythonVersionFile, VersionRequest,
};
use uv_resolver::{FlatIndex, RequiresPython};
use uv_types::{BuildContext, BuildIsolation, HashStrategy};
use uv_warnings::warn_user_once;
use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceError};
/// Build source distributions and wheels.
#[allow(clippy::fn_params_excessive_bools)]
pub(crate) async fn build(
src_dir: Option<PathBuf>,
output_dir: Option<PathBuf>,
sdist: bool,
wheel: bool,
python: Option<String>,
settings: ResolverSettings,
no_config: bool,
python_preference: PythonPreference,
python_downloads: PythonDownloads,
connectivity: Connectivity,
concurrency: Concurrency,
native_tls: bool,
cache: &Cache,
printer: Printer,
) -> Result<ExitStatus> {
let assets = build_impl(
src_dir.as_deref(),
output_dir.as_deref(),
sdist,
wheel,
python.as_deref(),
settings.as_ref(),
no_config,
python_preference,
python_downloads,
connectivity,
concurrency,
native_tls,
cache,
printer,
)
.await?;
match assets {
BuiltDistributions::Wheel(wheel) => {
anstream::eprintln!("Successfully built {}", wheel.user_display().bold().cyan());
}
BuiltDistributions::Sdist(sdist) => {
anstream::eprintln!("Successfully built {}", sdist.user_display().bold().cyan());
}
BuiltDistributions::Both(sdist, wheel) => {
anstream::eprintln!(
"Successfully built {} and {}",
sdist.user_display().bold().cyan(),
wheel.user_display().bold().cyan()
);
}
}
Ok(ExitStatus::Success)
}
#[allow(clippy::fn_params_excessive_bools)]
async fn build_impl(
src_dir: Option<&Path>,
output_dir: Option<&Path>,
sdist: bool,
wheel: bool,
python_request: Option<&str>,
settings: ResolverSettingsRef<'_>,
no_config: bool,
python_preference: PythonPreference,
python_downloads: PythonDownloads,
connectivity: Connectivity,
concurrency: Concurrency,
native_tls: bool,
cache: &Cache,
printer: Printer,
) -> Result<BuiltDistributions> {
// Extract the resolver settings.
let ResolverSettingsRef {
index_locations,
index_strategy,
keyring_provider,
allow_insecure_host,
resolution: _,
prerelease: _,
config_setting,
no_build_isolation,
no_build_isolation_package,
exclude_newer,
link_mode,
upgrade: _,
build_options,
sources,
} = settings;
let client_builder = BaseClientBuilder::default()
.connectivity(connectivity)
.native_tls(native_tls);
let src_dir = if let Some(src_dir) = src_dir {
Cow::Owned(std::path::absolute(src_dir)?)
} else {
Cow::Borrowed(&*CWD)
};
let output_dir = if let Some(output_dir) = output_dir {
std::path::absolute(output_dir)?
} else {
src_dir.join("dist")
};
// (1) Explicit request from user
let mut interpreter_request = python_request.map(PythonRequest::parse);
// (2) Request from `.python-version`
if interpreter_request.is_none() {
interpreter_request = PythonVersionFile::discover(src_dir.as_ref(), no_config, false)
.await?
.and_then(PythonVersionFile::into_version);
}
// (3) `Requires-Python` in `pyproject.toml`
if interpreter_request.is_none() {
let project =
match VirtualProject::discover(src_dir.as_ref(), &DiscoveryOptions::default()).await {
Ok(project) => Some(project),
Err(WorkspaceError::MissingProject(_)) => None,
Err(WorkspaceError::MissingPyprojectToml) => None,
Err(WorkspaceError::NonWorkspace(_)) => None,
Err(err) => {
warn_user_once!("{err}");
None
}
};
if let Some(project) = project {
interpreter_request = find_requires_python(project.workspace())?
.as_ref()
.map(RequiresPython::specifiers)
.map(|specifiers| {
PythonRequest::Version(VersionRequest::Range(specifiers.clone()))
});
}
}
// Locate the Python interpreter to use in the environment.
let interpreter = PythonInstallation::find_or_download(
interpreter_request.as_ref(),
EnvironmentPreference::Any,
python_preference,
python_downloads,
&client_builder,
cache,
Some(&PythonDownloadReporter::single(printer)),
)
.await?
.into_interpreter();
// Add all authenticated sources to the cache.
for url in index_locations.urls() {
store_credentials_from_url(url);
}
// Initialize the registry client.
let client = RegistryClientBuilder::new(cache.clone())
.native_tls(native_tls)
.connectivity(connectivity)
.index_urls(index_locations.index_urls())
.index_strategy(index_strategy)
.keyring(keyring_provider)
.allow_insecure_host(allow_insecure_host.to_vec())
.markers(interpreter.markers())
.platform(interpreter.platform())
.build();
// Determine whether to enable build isolation.
let environment;
let build_isolation = if no_build_isolation {
environment = PythonEnvironment::from_interpreter(interpreter.clone());
BuildIsolation::Shared(&environment)
} else if no_build_isolation_package.is_empty() {
BuildIsolation::Isolated
} else {
environment = PythonEnvironment::from_interpreter(interpreter.clone());
BuildIsolation::SharedPackage(&environment, no_build_isolation_package)
};
// TODO(charlie): These are all default values. We should consider whether we want to make them
// optional on the downstream APIs.
let build_constraints = [];
let hasher = HashStrategy::None;
// Resolve the flat indexes from `--find-links`.
let flat_index = {
let client = FlatIndexClient::new(&client, cache);
let entries = client.fetch(index_locations.flat_index()).await?;
FlatIndex::from_entries(entries, None, &hasher, build_options)
};
// Initialize any shared state.
let state = SharedState::default();
// Create a build dispatch.
let build_dispatch = BuildDispatch::new(
&client,
cache,
&build_constraints,
&interpreter,
index_locations,
&flat_index,
&state.index,
&state.git,
&state.in_flight,
index_strategy,
config_setting,
build_isolation,
link_mode,
build_options,
exclude_newer,
sources,
concurrency,
);
fs_err::tokio::create_dir_all(&output_dir).await?;
// Determine the build plan from the command-line arguments.
let plan = match (sdist, wheel) {
(false, false) => BuildPlan::SdistToWheel,
(true, false) => BuildPlan::Sdist,
(false, true) => BuildPlan::Wheel,
(true, true) => BuildPlan::SdistAndWheel,
};
// Prepare some common arguments for the build.
let subdirectory = None;
let version_id = src_dir.file_name().unwrap().to_string_lossy();
let dist = None;
let assets = match plan {
BuildPlan::SdistToWheel => {
// Build the sdist.
let builder = build_dispatch
.setup_build(
src_dir.as_ref(),
subdirectory,
&version_id,
dist,
BuildKind::Sdist,
)
.await?;
let sdist = builder.build(&output_dir).await?;
// Extract the source distribution into a temporary directory.
let path = output_dir.join(&sdist);
let reader = fs_err::tokio::File::open(&path).await?;
let ext = SourceDistExtension::from_path(&path)?;
let temp_dir = tempfile::tempdir_in(&output_dir)?;
uv_extract::stream::archive(reader, ext, temp_dir.path()).await?;
// Extract the top-level directory from the archive.
let extracted = match uv_extract::strip_component(temp_dir.path()) {
Ok(top_level) => top_level,
Err(uv_extract::Error::NonSingularArchive(_)) => temp_dir.path().to_path_buf(),
Err(err) => return Err(err.into()),
};
// Build a wheel from the source distribution.
let builder = build_dispatch
.setup_build(
&extracted,
subdirectory,
&version_id,
dist,
BuildKind::Wheel,
)
.await?;
let wheel = builder.build(&output_dir).await?;
BuiltDistributions::Both(output_dir.join(sdist), output_dir.join(wheel))
}
BuildPlan::Sdist => {
let builder = build_dispatch
.setup_build(
src_dir.as_ref(),
subdirectory,
&version_id,
dist,
BuildKind::Sdist,
)
.await?;
let sdist = builder.build(&output_dir).await?;
BuiltDistributions::Sdist(output_dir.join(sdist))
}
BuildPlan::Wheel => {
let builder = build_dispatch
.setup_build(
src_dir.as_ref(),
subdirectory,
&version_id,
dist,
BuildKind::Wheel,
)
.await?;
let wheel = builder.build(&output_dir).await?;
BuiltDistributions::Wheel(output_dir.join(wheel))
}
BuildPlan::SdistAndWheel => {
let builder = build_dispatch
.setup_build(
src_dir.as_ref(),
subdirectory,
&version_id,
dist,
BuildKind::Sdist,
)
.await?;
let sdist = builder.build(&output_dir).await?;
let builder = build_dispatch
.setup_build(
src_dir.as_ref(),
subdirectory,
&version_id,
dist,
BuildKind::Wheel,
)
.await?;
let wheel = builder.build(&output_dir).await?;
BuiltDistributions::Both(output_dir.join(&sdist), output_dir.join(&wheel))
}
};
Ok(assets)
}
#[derive(Debug, Clone, PartialEq)]
enum BuiltDistributions {
/// A built wheel.
Wheel(PathBuf),
/// A built source distribution.
Sdist(PathBuf),
/// A built source distribution and wheel.
Both(PathBuf, PathBuf),
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum BuildPlan {
/// Build a source distribution from source, then build the wheel from the source distribution.
SdistToWheel,
/// Build a source distribution from source.
Sdist,
/// Build a wheel from source.
Wheel,
/// Build a source distribution and a wheel from source.
SdistAndWheel,
}

View file

@ -4,6 +4,7 @@ use std::{fmt::Display, fmt::Write, process::ExitCode};
use anyhow::Context;
use owo_colors::OwoColorize;
pub(crate) use build::build;
pub(crate) use cache_clean::cache_clean;
pub(crate) use cache_dir::cache_dir;
pub(crate) use cache_prune::cache_prune;
@ -65,6 +66,7 @@ mod python;
pub(crate) mod reporters;
mod tool;
mod build;
#[cfg(feature = "self-update")]
mod self_update;
mod venv;

View file

@ -659,6 +659,35 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
commands::cache_dir(&cache);
Ok(ExitStatus::Success)
}
Commands::Build(args) => {
// Resolve the settings from the command-line arguments and workspace configuration.
let args = settings::BuildSettings::resolve(args, filesystem);
show_settings!(args);
// Initialize the cache.
let cache = cache.init()?.with_refresh(
args.refresh
.combine(Refresh::from(args.settings.upgrade.clone())),
);
commands::build(
args.src_dir,
args.out_dir,
args.sdist,
args.wheel,
args.python,
args.settings,
cli.no_config,
globals.python_preference,
globals.python_downloads,
globals.connectivity,
globals.concurrency,
globals.native_tls,
&cache,
printer,
)
.await
}
Commands::Venv(args) => {
args.compat_args.validate()?;
@ -1134,7 +1163,10 @@ async fn run_project(
show_settings!(args);
// Initialize the cache.
let cache = cache.init()?;
let cache = cache.init()?.with_refresh(
args.refresh
.combine(Refresh::from(args.settings.upgrade.clone())),
);
commands::lock(
args.locked,

View file

@ -11,7 +11,7 @@ use pypi_types::{Requirement, SupportedEnvironments};
use uv_cache::{CacheArgs, Refresh};
use uv_cli::{
options::{flag, resolver_installer_options, resolver_options},
ExportArgs, ToolUpgradeArgs,
BuildArgs, ExportArgs, ToolUpgradeArgs,
};
use uv_cli::{
AddArgs, ColorChoice, ExternalCommand, GlobalArgs, InitArgs, ListFormat, LockArgs, Maybe,
@ -1611,7 +1611,46 @@ impl PipCheckSettings {
}
}
/// The resolved settings to use for a `pip check` invocation.
/// The resolved settings to use for a `build` invocation.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
pub(crate) struct BuildSettings {
pub(crate) src_dir: Option<PathBuf>,
pub(crate) out_dir: Option<PathBuf>,
pub(crate) sdist: bool,
pub(crate) wheel: bool,
pub(crate) python: Option<String>,
pub(crate) refresh: Refresh,
pub(crate) settings: ResolverSettings,
}
impl BuildSettings {
/// Resolve the [`BuildSettings`] from the CLI and filesystem configuration.
pub(crate) fn resolve(args: BuildArgs, filesystem: Option<FilesystemOptions>) -> Self {
let BuildArgs {
src_dir,
out_dir,
sdist,
wheel,
python,
build,
refresh,
resolver,
} = args;
Self {
src_dir,
out_dir,
sdist,
wheel,
python,
refresh: Refresh::from(refresh),
settings: ResolverSettings::combine(resolver_options(resolver, build), filesystem),
}
}
}
/// The resolved settings to use for a `venv` invocation.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
pub(crate) struct VenvSettings {

191
crates/uv/tests/build.rs Normal file
View file

@ -0,0 +1,191 @@
#![cfg(all(feature = "python", feature = "pypi"))]
use anyhow::Result;
use assert_fs::prelude::*;
use common::{uv_snapshot, TestContext};
use predicates::prelude::predicate;
mod common;
#[test]
fn build() -> Result<()> {
let context = TestContext::new("3.12");
let project = context.temp_dir.child("project");
let pyproject_toml = project.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["anyio==3.7.0"]
[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
"#,
)?;
project.child("src").child("__init__.py").touch()?;
// Build the specified path.
uv_snapshot!(context.filters(), context.build().arg("project"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Successfully built project/dist/project-0.1.0.tar.gz and project/dist/project-0.1.0-py3-none-any.whl
"###);
// Build the current working directory.
uv_snapshot!(context.filters(), context.build().current_dir(project.path()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Successfully built dist/project-0.1.0.tar.gz and dist/project-0.1.0-py3-none-any.whl
"###);
// Error if there's nothing to build.
uv_snapshot!(context.filters(), context.build(), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: [TEMP_DIR]/ does not appear to be a Python project, as neither `pyproject.toml` nor `setup.py` are present in the directory
"###);
// Build to a specified path.
uv_snapshot!(context.filters(), context.build().arg("--out-dir").arg("out").current_dir(project.path()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Successfully built out/project-0.1.0.tar.gz and out/project-0.1.0-py3-none-any.whl
"###);
project
.child("out")
.child("project-0.1.0.tar.gz")
.assert(predicate::path::is_file());
project
.child("out")
.child("project-0.1.0-py3-none-any.whl")
.assert(predicate::path::is_file());
Ok(())
}
#[test]
fn sdist() -> Result<()> {
let context = TestContext::new("3.12");
let project = context.temp_dir.child("project");
let pyproject_toml = project.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["anyio==3.7.0"]
[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
"#,
)?;
project.child("src").child("__init__.py").touch()?;
// Build the specified path.
uv_snapshot!(context.filters(), context.build().arg("--sdist").current_dir(&project), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Successfully built dist/project-0.1.0.tar.gz
"###);
Ok(())
}
#[test]
fn wheel() -> Result<()> {
let context = TestContext::new("3.12");
let project = context.temp_dir.child("project");
let pyproject_toml = project.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["anyio==3.7.0"]
[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
"#,
)?;
project.child("src").child("__init__.py").touch()?;
// Build the specified path.
uv_snapshot!(context.filters(), context.build().arg("--wheel").current_dir(&project), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Successfully built dist/project-0.1.0-py3-none-any.whl
"###);
Ok(())
}
#[test]
fn sdist_wheel() -> Result<()> {
let context = TestContext::new("3.12");
let project = context.temp_dir.child("project");
let pyproject_toml = project.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["anyio==3.7.0"]
[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
"#,
)?;
project.child("src").child("__init__.py").touch()?;
// Build the specified path.
uv_snapshot!(context.filters(), context.build().arg("--sdist").arg("--wheel").current_dir(&project), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Successfully built dist/project-0.1.0.tar.gz and dist/project-0.1.0-py3-none-any.whl
"###);
Ok(())
}

View file

@ -569,6 +569,14 @@ impl TestContext {
command
}
/// Create a `uv build` command with options shared across scenarios.
pub fn build(&self) -> Command {
let mut command = Command::new(get_bin());
command.arg("build");
self.add_shared_args(&mut command, false);
command
}
/// Create a `uv python find` command with options shared across scenarios.
pub fn python_find(&self) -> Command {
let mut command = Command::new(get_bin());

View file

@ -28,6 +28,7 @@ fn help() {
python Manage Python versions and installations
pip Manage Python packages with a pip-compatible interface
venv Create a virtual environment
build Build Python packages into source distributions and wheels
cache Manage uv's cache
version Display uv's version
generate-shell-completion Generate shell completion
@ -92,6 +93,7 @@ fn help_flag() {
python Manage Python versions and installations
pip Manage Python packages with a pip-compatible interface
venv Create a virtual environment
build Build Python packages into source distributions and wheels
cache Manage uv's cache
version Display uv's version
help Display documentation for a command
@ -154,6 +156,7 @@ fn help_short_flag() {
python Manage Python versions and installations
pip Manage Python packages with a pip-compatible interface
venv Create a virtual environment
build Build Python packages into source distributions and wheels
cache Manage uv's cache
version Display uv's version
help Display documentation for a command
@ -633,6 +636,7 @@ fn help_unknown_subcommand() {
python
pip
venv
build
cache
version
generate-shell-completion
@ -657,6 +661,7 @@ fn help_unknown_subcommand() {
python
pip
venv
build
cache
version
generate-shell-completion
@ -708,6 +713,7 @@ fn help_with_global_option() {
python Manage Python versions and installations
pip Manage Python packages with a pip-compatible interface
venv Create a virtual environment
build Build Python packages into source distributions and wheels
cache Manage uv's cache
version Display uv's version
generate-shell-completion Generate shell completion
@ -808,6 +814,7 @@ fn help_with_no_pager() {
python Manage Python versions and installations
pip Manage Python packages with a pip-compatible interface
venv Create a virtual environment
build Build Python packages into source distributions and wheels
cache Manage uv's cache
version Display uv's version
generate-shell-completion Generate shell completion