Skip to content

Commit

Permalink
Add uv python install --default
Browse files Browse the repository at this point in the history
  • Loading branch information
zanieb committed Oct 31, 2024
1 parent 85f9a0d commit 4e4d3cd
Show file tree
Hide file tree
Showing 11 changed files with 292 additions and 57 deletions.
15 changes: 15 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3907,6 +3907,21 @@ pub struct PythonInstallArgs {
/// installed.
#[arg(long, short, alias = "force")]
pub reinstall: bool,

/// Use as the default Python version.
///
/// By default, only a `python{major}.{minor}` executable is installed, e.g., `python3.10`. When
/// the `--default` flag is used, `python{major}`, e.g., `python3`, and `python` executables are
/// also installed.
///
/// Alternative Python variants will still include their tag. For example, installing
/// 3.13+freethreaded with `--default` will include in `python3t` and `pythont`, not `python3`
/// and `python`.
///
/// If multiple Python versions are requested during the installation, the first request will be
/// the default.
#[arg(long)]
pub default: bool,
}

#[derive(Args)]
Expand Down
23 changes: 21 additions & 2 deletions crates/uv-python/src/installation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -306,8 +306,8 @@ impl PythonInstallationKey {
&self.libc
}

/// Return a canonical name for a versioned executable.
pub fn versioned_executable_name(&self) -> String {
/// Return a canonical name for a minor versioned executable.
pub fn executable_name_minor(&self) -> String {
format!(
"python{maj}.{min}{var}{exe}",
maj = self.major,
Expand All @@ -316,6 +316,25 @@ impl PythonInstallationKey {
exe = std::env::consts::EXE_SUFFIX
)
}

/// Return a canonical name for a major versioned executable.
pub fn executable_name_major(&self) -> String {
format!(
"python{maj}{var}{exe}",
maj = self.major,
var = self.variant.suffix(),
exe = std::env::consts::EXE_SUFFIX
)
}

/// Return a canonical name for an un-versioned executable.
pub fn executable_name(&self) -> String {
format!(
"python{var}{exe}",
var = self.variant.suffix(),
exe = std::env::consts::EXE_SUFFIX
)
}
}

impl fmt::Display for PythonInstallationKey {
Expand Down
14 changes: 6 additions & 8 deletions crates/uv-python/src/managed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -475,26 +475,24 @@ impl ManagedPythonInstallation {
Ok(())
}

/// Create a link to the Python executable in the given `bin` directory.
pub fn create_bin_link(&self, bin: &Path) -> Result<PathBuf, Error> {
/// Create a link to the managed Python executable.
pub fn create_bin_link(&self, target: &Path) -> Result<(), Error> {
let python = self.executable();

let bin = target.parent().ok_or(Error::NoExecutableDirectory)?;
fs_err::create_dir_all(bin).map_err(|err| Error::ExecutableDirectory {
to: bin.to_path_buf(),
err,
})?;

// TODO(zanieb): Add support for a "default" which
let python_in_bin = bin.join(self.key.versioned_executable_name());

match uv_fs::symlink_copy_fallback_file(&python, &python_in_bin) {
Ok(()) => Ok(python_in_bin),
match uv_fs::symlink_copy_fallback_file(&python, target) {
Ok(()) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::NotFound => {
Err(Error::MissingExecutable(python.clone()))
}
Err(err) => Err(Error::LinkExecutable {
from: python,
to: python_in_bin,
to: target.to_path_buf(),
err,
}),
}
Expand Down
86 changes: 54 additions & 32 deletions crates/uv/src/commands/python/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,12 @@ impl Changelog {
}

/// Download and install Python versions.
#[allow(clippy::fn_params_excessive_bools)]
pub(crate) async fn install(
project_dir: &Path,
targets: Vec<String>,
reinstall: bool,
default: bool,
python_downloads: PythonDownloads,
native_tls: bool,
connectivity: Connectivity,
Expand All @@ -117,6 +119,11 @@ pub(crate) async fn install(
) -> Result<ExitStatus> {
let start = std::time::Instant::now();

if default && !preview.is_enabled() {
writeln!(printer.stderr(), "The `--default` flag is only available in preview mode; add the `--preview` flag to use `--default.")?;
return Ok(ExitStatus::Failure);
}

// Resolve the requests
let mut is_default_install = false;
let requests: Vec<_> = if targets.is_empty() {
Expand Down Expand Up @@ -274,29 +281,24 @@ pub(crate) async fn install(
.expect("We should have a bin directory with preview enabled")
.as_path();

match installation.create_bin_link(bin) {
Ok(target) => {
debug!(
"Installed executable at {} for {}",
target.user_display(),
installation.key(),
);
changelog.installed.insert(installation.key().clone());
changelog
.installed_executables
.entry(installation.key().clone())
.or_default()
.push(target.clone());
}
Err(uv_python::managed::Error::LinkExecutable { from, to, err })
if err.kind() == ErrorKind::AlreadyExists =>
{
// TODO(zanieb): Add `--force`
if reinstall {
fs_err::remove_file(&to)?;
let target = installation.create_bin_link(bin)?;
let targets = if (default || is_default_install)
&& first_request.matches_installation(installation)
{
vec![
installation.key().executable_name_minor(),
installation.key().executable_name_major(),
installation.key().executable_name(),
]
} else {
vec![installation.key().executable_name_minor()]
};

for target in targets {
let target = bin.join(target);
match installation.create_bin_link(&target) {
Ok(()) => {
debug!(
"Updated executable at {} to {}",
"Installed executable at {} for {}",
target.user_display(),
installation.key(),
);
Expand All @@ -306,20 +308,40 @@ pub(crate) async fn install(
.entry(installation.key().clone())
.or_default()
.push(target.clone());
changelog.uninstalled_executables.insert(target);
} else {
if !is_same_file(&to, &from).unwrap_or_default() {
errors.push((
}
Err(uv_python::managed::Error::LinkExecutable { from, to, err })
if err.kind() == ErrorKind::AlreadyExists =>
{
// TODO(zanieb): Add `--force`
if reinstall {
fs_err::remove_file(&to)?;
installation.create_bin_link(&target)?;
debug!(
"Updated executable at {} to {}",
target.user_display(),
installation.key(),
anyhow::anyhow!(
"Executable already exists at `{}`. Use `--reinstall` to force replacement.",
to.user_display()
),
));
);
changelog.installed.insert(installation.key().clone());
changelog
.installed_executables
.entry(installation.key().clone())
.or_default()
.push(target.clone());
changelog.uninstalled_executables.insert(target);
} else {
if !is_same_file(&to, &from).unwrap_or_default() {
errors.push((
installation.key(),
anyhow::anyhow!(
"Executable already exists at `{}`. Use `--reinstall` to force replacement.",
to.user_display()
),
));
}
}
}
Err(err) => return Err(err.into()),
}
Err(err) => return Err(err.into()),
}
}

Expand Down
8 changes: 5 additions & 3 deletions crates/uv/src/commands/python/uninstall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,10 @@ async fn do_uninstall(
// leave broken links behind, i.e., if the user created them.
.filter(|path| {
matching_installations.iter().any(|installation| {
path.file_name().and_then(|name| name.to_str())
== Some(&installation.key().versioned_executable_name())
let name = path.file_name().and_then(|name| name.to_str());
name == Some(&installation.key().executable_name_minor())
|| name == Some(&installation.key().executable_name_major())
|| name == Some(&installation.key().executable_name())
})
})
// Only include Python executables that match the installations
Expand Down Expand Up @@ -222,7 +224,7 @@ async fn do_uninstall(
" {} {} ({})",
"-".red(),
event.key.bold(),
event.key.versioned_executable_name()
event.key.executable_name_minor()
)?;
}
_ => unreachable!(),
Expand Down
1 change: 1 addition & 0 deletions crates/uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
&project_dir,
args.targets,
args.reinstall,
args.default,
globals.python_downloads,
globals.native_tls,
globals.connectivity,
Expand Down
13 changes: 11 additions & 2 deletions crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -620,15 +620,24 @@ impl PythonDirSettings {
pub(crate) struct PythonInstallSettings {
pub(crate) targets: Vec<String>,
pub(crate) reinstall: bool,
pub(crate) default: bool,
}

impl PythonInstallSettings {
/// Resolve the [`PythonInstallSettings`] from the CLI and filesystem configuration.
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn resolve(args: PythonInstallArgs, _filesystem: Option<FilesystemOptions>) -> Self {
let PythonInstallArgs { targets, reinstall } = args;
let PythonInstallArgs {
targets,
reinstall,
default,
} = args;

Self { targets, reinstall }
Self {
targets,
reinstall,
default,
}
}
}

Expand Down
8 changes: 7 additions & 1 deletion crates/uv/tests/it/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,10 +215,16 @@ impl TestContext {

/// Adds a filter that ignores platform information in a Python installation key.
pub fn with_filtered_python_keys(mut self) -> Self {
// Filter platform keys
self.filters.push((
r"((?:cpython|pypy)-\d+\.\d+(:?\.\d+)?[a-z]?(:?\+[a-z]+)?)-.*".to_string(),
r"((?:cpython|pypy)-\d+\.\d+(?:\.(?:\[X\]|\d+))?[a-z]?(?:\+[a-z]+)?)-.*".to_string(),
"$1-[PLATFORM]".to_string(),
));
// Filter patch versions
self.filters.push((
r"((?:cpython|pypy)-\d+\.\d+)\.\d+([a-z])?".to_string(),
"$1.[X]$2".to_string(),
));
self
}

Expand Down
15 changes: 15 additions & 0 deletions crates/uv/tests/it/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,20 @@ fn help_subsubcommand() {
By default, uv will exit successfully if the version is already installed.
--default
Use as the default Python version.
By default, only a `python{major}.{minor}` executable is installed, e.g., `python3.10`.
When the `--default` flag is used, `python{major}`, e.g., `python3`, and `python`
executables are also installed.
Alternative Python variants will still include their tag. For example, installing
3.13+freethreaded with `--default` will include in `python3t` and `pythont`, not `python3`
and `python`.
If multiple Python versions are requested during the installation, the first request will
be the default.
Cache options:
-n, --no-cache
Avoid reading from or writing to the cache, instead using a temporary directory for the
Expand Down Expand Up @@ -646,6 +660,7 @@ fn help_flag_subsubcommand() {
Options:
-r, --reinstall Reinstall the requested Python version, if it's already installed
--default Use as the default Python version
Cache options:
-n, --no-cache Avoid reading from or writing to the cache, instead using a temporary
Expand Down
Loading

0 comments on commit 4e4d3cd

Please sign in to comment.