├── src ├── mod.rs ├── tests │ ├── mod.rs │ ├── shared.rs │ ├── version.rs │ └── special_home_dir.rs ├── commands │ ├── self_version.rs │ ├── mod.rs │ ├── runuv.rs │ ├── freeze.rs │ ├── runpip.rs │ ├── uninstall_all.rs │ ├── completions.rs │ ├── activate.rs │ ├── upgrade_all.rs │ ├── create.rs │ ├── runpython.rs │ ├── self_link.rs │ ├── reinstall_all.rs │ ├── uninject.rs │ ├── self_migrate.rs │ ├── inject.rs │ ├── thaw.rs │ ├── uninstall.rs │ ├── ensurepath.rs │ ├── reinstall.rs │ ├── setup.rs │ ├── list.rs │ ├── self_changelog.rs │ ├── run.rs │ ├── check.rs │ ├── upgrade.rs │ ├── self_update.rs │ ├── install.rs │ └── self_info.rs ├── shells │ ├── zsh.sh │ └── bash.sh ├── promises.rs ├── macros.rs ├── lockfile │ ├── v0.rs │ ├── mod.rs │ └── v1.rs ├── animate.rs ├── cmd.rs ├── main.rs ├── helpers.rs ├── venv.rs ├── pip.rs ├── symlinks.rs ├── shell.rs ├── pypi.rs └── uv.rs ├── .cargo └── config.toml ├── rustfmt.toml ├── .gitignore ├── .github └── workflows │ ├── snap.yml │ ├── create-pr-on-uv-release.yml │ └── pypi.yml ├── pyproject.toml ├── docs ├── snap.md ├── lockfile_v1.md └── installation.md ├── snapcraft.yaml ├── install.sh ├── Cargo.toml └── README.md /src/mod.rs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.aarch64-unknown-linux-gnu] 2 | linker = "/usr/bin/aarch64-linux-gnu-gcc" 3 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2024" 2 | fn_params_layout = "vertical" 3 | match_block_trailing_comma = true 4 | use_try_shorthand = true 5 | -------------------------------------------------------------------------------- /src/tests/mod.rs: -------------------------------------------------------------------------------- 1 | #![expect(dead_code, reason = "This is a tests module.")] 2 | mod shared; 3 | mod special_home_dir; 4 | mod version; 5 | -------------------------------------------------------------------------------- /src/commands/self_version.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::{Process, SelfVersionOptions}; 2 | use crate::commands::self_info::self_info; 3 | use owo_colors::OwoColorize; 4 | 5 | impl Process for SelfVersionOptions { 6 | async fn process(self) -> anyhow::Result { 7 | eprintln!( 8 | "{}: {} is deprecated in favor of {}.", 9 | "Warning".yellow(), 10 | "`self version`".red(), 11 | "`self info`".green() 12 | ); 13 | self_info().await 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/shells/zsh.sh: -------------------------------------------------------------------------------- 1 | function uvenv() { 2 | local subcommand=$1; 3 | local venv_name=$2; 4 | 5 | if [[ "$subcommand" == "activate" ]]; then 6 | if [[ -z "$venv_name" ]]; then 7 | echo "Error: No virtual environment name provided."; 8 | return 1; 9 | elif [[ ! -d "$HOME/.local/uvenv/venvs/$venv_name" ]]; then 10 | echo "Error: Virtual environment '$venv_name' does not exist."; 11 | return 2; 12 | else 13 | source "$HOME/.local/uvenv/venvs/$venv_name/bin/activate"; 14 | fi; 15 | else 16 | command uvenv "$@"; 17 | fi; 18 | } 19 | -------------------------------------------------------------------------------- /src/shells/bash.sh: -------------------------------------------------------------------------------- 1 | function uvenv() { 2 | subcommand=$1 3 | venv_name=$2 4 | 5 | if [ "$subcommand" == "activate" ]; then 6 | # todo: eval uvenv activate ? 7 | if [ -z "$venv_name" ]; then 8 | echo "Error: No virtual environment name provided." 9 | return 1 10 | elif [ ! -d "$HOME/.local/uvenv/venvs/$venv_name" ]; then 11 | echo "Error: Virtual environment '$venv_name' does not exist." 12 | return 2 13 | else 14 | source "$HOME/.local/uvenv/venvs/$venv_name/bin/activate" 15 | fi 16 | else 17 | command uvenv "$@" 18 | fi 19 | } 20 | -------------------------------------------------------------------------------- /src/tests/shared.rs: -------------------------------------------------------------------------------- 1 | use crate::metadata::get_home_dir; 2 | use core::error::Error; 3 | use std::path::Path; 4 | 5 | pub type TestResult = Result<(), Box>; 6 | 7 | pub fn is_empty(some_dir: &Path) -> bool { 8 | let Ok(mut dir) = some_dir.read_dir() else { 9 | return false; 10 | }; 11 | 12 | dir.next().is_none() 13 | } 14 | 15 | pub fn cleanup() { 16 | // every get_home_dir should clean it up (in test mode) 17 | let home_dir = get_home_dir(); 18 | assert!(home_dir.exists(), "Home should exist"); 19 | assert!(is_empty(&home_dir), "Home should be empty"); 20 | } 21 | -------------------------------------------------------------------------------- /src/promises.rs: -------------------------------------------------------------------------------- 1 | use crate::helpers::fmt_error; 2 | use core::future::Future; 3 | use futures::future; 4 | 5 | #[expect(dead_code, reason = "May be useful again in the future")] 6 | pub async fn handle_promises>>(promises: Vec) -> Vec { 7 | future::join_all(promises) 8 | .await 9 | .into_iter() 10 | .filter_map(|res| match res { 11 | Ok(data) => Some(data), 12 | Err(msg) => { 13 | eprintln!("{}", fmt_error(&msg)); 14 | None 15 | }, 16 | }) 17 | .collect() 18 | } 19 | -------------------------------------------------------------------------------- /src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod activate; 2 | pub mod completions; 3 | pub mod create; 4 | 5 | pub mod check; 6 | pub mod ensurepath; 7 | pub mod freeze; 8 | pub mod inject; 9 | pub mod install; 10 | pub mod list; 11 | pub mod reinstall; 12 | pub mod reinstall_all; 13 | pub mod run; 14 | pub mod runpip; 15 | pub mod runpython; 16 | pub mod runuv; 17 | pub mod self_changelog; 18 | pub mod self_info; 19 | pub mod self_link; 20 | pub mod self_migrate; 21 | pub mod self_update; 22 | #[deprecated(since = "3.7.0")] 23 | pub mod self_version; 24 | pub mod setup; 25 | pub mod thaw; 26 | pub mod uninject; 27 | pub mod uninstall; 28 | pub mod uninstall_all; 29 | pub mod upgrade; 30 | pub mod upgrade_all; 31 | -------------------------------------------------------------------------------- /src/tests/version.rs: -------------------------------------------------------------------------------- 1 | #[expect(unused_imports, reason = "This is a test file.")] 2 | use crate::commands::self_info::compare_versions; 3 | #[expect(unused_imports, reason = "This is a test file.")] 4 | use crate::tests::shared::TestResult; 5 | 6 | #[test] 7 | /// special test which makes sure uvenv uses a custom home directory 8 | /// to prevent breaking normal installed uvenv packages on host system. 9 | fn test_is_latest() -> TestResult { 10 | assert!(compare_versions("1.2.3", "1.2.3")); 11 | assert!(compare_versions("1.3.3", "1.2.3")); 12 | assert!(compare_versions("1.2.10", "1.2.3")); 13 | assert!(!compare_versions("1.2.3", "1.3.3")); 14 | assert!(!compare_versions("1.3.3", "1.3.13")); 15 | 16 | Ok(()) 17 | } 18 | -------------------------------------------------------------------------------- /src/commands/runuv.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | cli::{Process, RunuvOptions}, 3 | uv::uv_with_output, 4 | venv::setup_environ_from_requirement, 5 | }; 6 | use anyhow::Context; 7 | 8 | pub async fn runuv( 9 | venv_name: &str, 10 | uv_args: &[String], 11 | ) -> anyhow::Result { 12 | setup_environ_from_requirement(venv_name).await?; 13 | 14 | uv_with_output(uv_args).await 15 | } 16 | 17 | impl Process for RunuvOptions { 18 | async fn process(self) -> anyhow::Result { 19 | match runuv(&self.venv, &self.uv_args).await { 20 | Ok(code) => Ok(code), 21 | Err(msg) => Err(msg).with_context(|| { 22 | format!("Something went wrong trying to run uv in '{}';", &self.venv) 23 | }), 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/macros.rs: -------------------------------------------------------------------------------- 1 | pub mod iterable_enum_macro { 2 | /// [Source: Stackoverflow](https://stackoverflow.com/questions/21371534/in-rust-is-there-a-way-to-iterate-through-the-values-of-an-enum) 3 | macro_rules! iterable_enum {( 4 | $(#[$derives:meta])* 5 | $pub:vis enum $name:ident { 6 | $( 7 | $(#[$nested_meta:meta])* 8 | $member:ident, 9 | )* 10 | }) => { 11 | const _MEMBERS_COUNT:usize = iterable_enum!(@count $($member)*); 12 | $(#[$derives])* 13 | $pub enum $name { 14 | $($(#[$nested_meta])* $member),* 15 | } 16 | impl $name { 17 | pub fn into_iter() -> core::array::IntoIter<$name, _MEMBERS_COUNT> { 18 | [$($name::$member,)*].into_iter() 19 | } 20 | } 21 | }; 22 | (@count) => (0_usize); 23 | (@count $x:tt $($xs:tt)* ) => (1_usize + iterable_enum!(@count $($xs)*)); 24 | } 25 | pub(crate) use iterable_enum; // <-- the trick 26 | } 27 | -------------------------------------------------------------------------------- /src/commands/freeze.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::{FreezeOptions, Process}; 2 | use crate::lockfile::v0::LockfileV0; 3 | use crate::lockfile::v1::LockfileV1; 4 | use anyhow::bail; 5 | use core::fmt::Debug; 6 | use serde::Serialize; 7 | 8 | static LATEST_VERSION: &str = "1"; 9 | 10 | pub trait Freeze { 11 | async fn freeze(options: &FreezeOptions) -> anyhow::Result 12 | where 13 | Self: Sized + Debug + Serialize; 14 | } 15 | 16 | impl Process for FreezeOptions { 17 | async fn process(self) -> anyhow::Result { 18 | let version = self.version.as_ref().map_or(LATEST_VERSION, |ver| ver); 19 | 20 | match version { 21 | #[cfg(debug_assertions)] 22 | "0" => LockfileV0::freeze(&self).await, 23 | "1" => LockfileV1::freeze(&self).await, 24 | _ => { 25 | bail!("Unsupported version!") 26 | }, 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/commands/runpip.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | cli::{Process, RunpipOptions}, 3 | cmd::run_print_output, 4 | venv::{setup_environ_from_requirement, venv_script}, 5 | }; 6 | use anyhow::Context; 7 | 8 | pub async fn runpip( 9 | venv_name: &str, 10 | pip_args: &[String], 11 | ) -> anyhow::Result { 12 | let (_, env) = setup_environ_from_requirement(venv_name).await?; 13 | 14 | let script = venv_script(&env, "pip"); 15 | 16 | run_print_output(script, pip_args).await 17 | } 18 | 19 | impl Process for RunpipOptions { 20 | async fn process(self) -> anyhow::Result { 21 | match runpip(&self.venv, &self.pip_args).await { 22 | Ok(code) => Ok(code), 23 | Err(msg) => Err(msg).with_context(|| { 24 | format!( 25 | "Something went wrong trying to run pip in '{}';", 26 | &self.venv 27 | ) 28 | }), 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | .pytest_cache/ 6 | *.py[cod] 7 | 8 | # C extensions 9 | *.so 10 | .intentionally-empty-file.o 11 | 12 | # Distribution / packaging 13 | .Python 14 | .venv/ 15 | env/ 16 | bin/ 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | include/ 27 | man/ 28 | venv/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | *.snap 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | pip-selfcheck.json 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | 47 | # Translations 48 | *.mo 49 | 50 | # Mr Developer 51 | .mr.developer.cfg 52 | .project 53 | .pydevproject 54 | 55 | # Rope 56 | .ropeproject 57 | 58 | # Django stuff: 59 | *.log 60 | *.pot 61 | 62 | .DS_Store 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyCharm 68 | .idea/ 69 | 70 | # VSCode 71 | .vscode/ 72 | 73 | # Pyenv 74 | .python-version 75 | .env 76 | 77 | # Uvenv 78 | *.lock 79 | 80 | -------------------------------------------------------------------------------- /.github/workflows/snap.yml: -------------------------------------------------------------------------------- 1 | name: Build and Upload Snap Package 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' # Only run on version tags 7 | workflow_dispatch: # Allow manual triggering 8 | 9 | env: 10 | SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_LOGIN }} 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Install uv 18 | uses: astral-sh/setup-uv@v5 19 | - name: Set up Python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: '3.11' 23 | 24 | - name: Install dependencies 25 | run: | 26 | uv venv venv --seed 27 | . venv/bin/activate 28 | uv pip install maturin[zig] uv 29 | 30 | - name: Build release 31 | run: | 32 | rustup update 33 | cargo build --release --features snap 34 | 35 | - name: Copy uv 36 | run: cp ./venv/bin/uv target/release/uv 37 | 38 | - name: Build snap 39 | # this installs snapcraft, sets up LXD and runs `snapcraft`: 40 | uses: snapcore/action-build@v1 41 | 42 | - name: Upload snap 43 | run: | 44 | snapcraft upload --release=stable *.snap -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "maturin>=1.5,<2.0", 4 | # Add a fake dependency for unsupported platforms: 5 | "please-use-uvx-v1-on-this-platform; (sys_platform != 'linux' and sys_platform != 'darwin') or (platform_machine != 'x86_64' and platform_machine != 'aarch64')", 6 | ] 7 | build-backend = "maturin" 8 | 9 | [project] 10 | name = "uvenv" 11 | # requires-python = ">=3.10" 12 | description = 'uvenv: pipx for uv (🦀)' 13 | readme = "README.md" 14 | license-expression = "MIT" 15 | keywords = [] 16 | authors = [ 17 | { name = "Robin van der Noord", email = "robinvandernoord@gmail.com" }, 18 | ] 19 | 20 | classifiers = [ 21 | "Programming Language :: Rust", 22 | "Programming Language :: Python :: Implementation :: CPython", 23 | "Programming Language :: Python :: Implementation :: PyPy", 24 | "Operating System :: POSIX :: Linux", 25 | "Operating System :: MacOS", 26 | # "Architecture :: x86_64", 27 | # "Architecture :: aarch64", 28 | "Development Status :: 4 - Beta", 29 | ] 30 | 31 | dynamic = ["version"] 32 | 33 | dependencies = [ 34 | "uv==0.9.18", # obviously 35 | "pip", # self-update 36 | "patchelf; platform_system == 'Linux'", # idk, but required 37 | ] 38 | 39 | [project.optional-dependencies] 40 | dev = [ 41 | "maturin[zig]", 42 | ] 43 | 44 | [tool.maturin] 45 | bindings = "bin" 46 | -------------------------------------------------------------------------------- /src/commands/uninstall_all.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::{Process, UninstallAllOptions}; 2 | use crate::commands::list::list_packages; 3 | use crate::commands::uninstall::uninstall_package; 4 | use crate::metadata::LoadMetadataConfig; 5 | use anyhow::{Context, anyhow}; 6 | 7 | pub async fn uninstall_all( 8 | force: bool, 9 | venv_names: &[String], 10 | ) -> anyhow::Result<()> { 11 | let mut all_ok = true; 12 | let mut err_result = Err(anyhow!("-> Failed uninstall-all.")); 13 | 14 | for meta in list_packages(&LoadMetadataConfig::none(), Some(venv_names), None).await? { 15 | match uninstall_package(&meta.name, force).await { 16 | Ok(msg) => { 17 | println!("{msg}"); 18 | }, 19 | Err(msg) => { 20 | // eprintln!("{}", msg.red()); 21 | err_result = err_result.with_context(|| msg); 22 | all_ok = false; 23 | }, 24 | } 25 | } 26 | 27 | if all_ok { 28 | Ok(()) 29 | } else { 30 | err_result.with_context(|| "⚠️ Not all packages were properly uninstalled!") 31 | } 32 | } 33 | 34 | impl Process for UninstallAllOptions { 35 | async fn process(self) -> anyhow::Result { 36 | match uninstall_all(self.force, &self.venv_names).await { 37 | Ok(()) => Ok(0), 38 | Err(msg) => Err(msg), 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/commands/completions.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::{CompletionsOptions, Process}; 2 | use anyhow::{Context, bail}; 3 | 4 | use crate::shell::{SupportedShell, run_if_supported_shell_else_warn}; 5 | use owo_colors::OwoColorize; 6 | 7 | pub async fn completions(install: bool) -> anyhow::Result { 8 | let shell = SupportedShell::detect(); 9 | let shell_code = format!(r#"eval "$(uvenv --generate={} completions)""#, shell.name()); 10 | let Some(rc_file) = shell.rc_file() else { 11 | bail!("Unsupported shell {}!", shell.name()); 12 | }; 13 | 14 | if install { 15 | // you probably want `uvenv setup` but keep this for legacy. 16 | shell.add_to_rcfile(&shell_code, true).await?; 17 | Ok(0) 18 | } else { 19 | Ok(run_if_supported_shell_else_warn(|_shell| { 20 | eprintln!( 21 | "Tip: place this line in your {} or run '{}' to do this automatically!", 22 | format!("~/{rc_file}").blue(), 23 | "uvenv setup".green() 24 | ); 25 | println!("{shell_code}"); 26 | Some(0) 27 | }) 28 | .unwrap_or(1)) 29 | } 30 | } 31 | 32 | impl Process for CompletionsOptions { 33 | async fn process(self) -> anyhow::Result { 34 | completions(self.install) 35 | .await 36 | .with_context(|| "Something went wrong trying to generate or install completions;") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/commands/activate.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::{ActivateOptions, Process}; 2 | use crate::shell::{SupportedShell, run_if_supported_shell_else_warn}; 3 | use owo_colors::OwoColorize; 4 | 5 | pub async fn generate_activate() -> &'static str { 6 | // Used by `uvenv --generate bash/zsh activate _` 7 | let shell = SupportedShell::detect(); 8 | shell.activation_script() 9 | } 10 | 11 | pub async fn install_activate() -> anyhow::Result<()> { 12 | let shell = SupportedShell::detect(); 13 | let sh_code = format!(r#"eval "$(uvenv --generate={} activate _)""#, shell.name()); 14 | // call eval instead of actually adding the shell function() to bashrc/zshrc 15 | // so updates are available immediately 16 | shell.add_to_rcfile(&sh_code, true).await 17 | } 18 | 19 | impl Process for ActivateOptions { 20 | async fn process(self) -> anyhow::Result { 21 | Ok( 22 | run_if_supported_shell_else_warn(|shell| { 23 | println!("Your shell ({}) is supported, but the shell extension is not set up.\n\ 24 | You can use `uvenv setup` to do this automatically, or add `{}` to your shell's configuration file to enable it manually.", 25 | &shell.blue(), 26 | format!(r#"eval "$(uvenv --generate={shell} activate _)""#).green() 27 | ); 28 | Some(1) 29 | }).unwrap_or(126) // Return 126 if shell is unsupported 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/commands/upgrade_all.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::{Process, UpgradeAllOptions}; 2 | use crate::commands::list::list_packages; 3 | use crate::commands::upgrade::upgrade_package; 4 | use crate::metadata::LoadMetadataConfig; 5 | use anyhow::{Context, anyhow}; 6 | 7 | pub async fn upgrade_all( 8 | force: bool, 9 | no_cache: bool, 10 | skip_injected: bool, 11 | venv_names: &[String], 12 | ) -> anyhow::Result<()> { 13 | let mut all_ok = true; 14 | let mut err_result = Err(anyhow!("-> Failed upgrade-all.")); 15 | 16 | for meta in list_packages(&LoadMetadataConfig::none(), Some(venv_names), None).await? { 17 | match upgrade_package(&meta.name, force, no_cache, skip_injected).await { 18 | Ok(msg) => { 19 | println!("{msg}"); 20 | }, 21 | Err(msg) => { 22 | // eprintln!("{}", msg.red()); 23 | err_result = err_result.with_context(|| msg); 24 | all_ok = false; 25 | }, 26 | } 27 | } 28 | 29 | if all_ok { 30 | Ok(()) 31 | } else { 32 | err_result.with_context(|| "⚠️ Not all packages were properly upgraded!") 33 | } 34 | } 35 | 36 | impl Process for UpgradeAllOptions { 37 | async fn process(self) -> anyhow::Result { 38 | upgrade_all( 39 | self.force, 40 | self.no_cache, 41 | self.skip_injected, 42 | &self.venv_names, 43 | ) 44 | .await 45 | .map(|()| 0) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /docs/snap.md: -------------------------------------------------------------------------------- 1 | # uvenv Snap Package 2 | 3 | uvenv is a simple command-line tool for managing virtual environments, written in Rust. Think of it as pipx, but for uv. 4 | 5 | ## Getting started 6 | 7 | ```bash 8 | snap install uvenv 9 | ``` 10 | 11 | ## Snap Installation Caveats 12 | 13 | When installed via Snap, there are some important differences to note: 14 | 15 | - Tools are downloaded to `~/snap/uvenv/current/.local/uvenv` instead of `~/.local/uvenv` 16 | - Scripts are installed in `~/snap/uvenv/current/.local/bin` instead of `~/.local/bin` 17 | - The snap package cannot update files like `~/.bashrc` or perform self-updates. 18 | 19 | ### Thawing with Snap 20 | Note that due to snap's strict sandboxing, `uvenv thaw` can not access `uvenv.lock` in most directories 21 | and the command will fail with a permission error. 22 | Instead, it must be run from the snap directory: 23 | ```bash 24 | cd ~/snap/uvenv/current 25 | ls 26 | # uvenv.lock 27 | uvenv thaw 28 | ``` 29 | 30 | ## Setting Up Bash Integration 31 | 32 | To enable all Bash-specific features, add the following lines to your `~/.bashrc`: 33 | 34 | ```bash 35 | eval "$(uvenv --generate=bash ensurepath)" # Fix PATH (or you can add `~/snap/uvenv/current/.local/bin` to your PATH manually) 36 | eval "$(uvenv --generate=bash completions)" # Optional: Enable tab completion 37 | eval "$(uvenv --generate=bash activate _)" # Optional: Enable the `uvenv activate` command 38 | ``` 39 | 40 | For other shells, run to display the appropriate setup instructions: 41 | 42 | ```bash 43 | uvenv setup 44 | ``` 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/commands/create.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::{CreateOptions, Process}; 2 | use crate::metadata::{Metadata, get_venv_dir}; 3 | use crate::venv::{activate_venv, create_venv_raw}; 4 | use anyhow::Context; 5 | use owo_colors::OwoColorize; 6 | 7 | pub async fn create( 8 | name: &str, 9 | python: Option<&str>, 10 | seed: bool, 11 | force: bool, 12 | ) -> anyhow::Result { 13 | let venv_path = get_venv_dir().join(name); 14 | 15 | create_venv_raw(&venv_path, python, force, seed).await?; 16 | let venv = activate_venv(&venv_path).await?; 17 | 18 | let mut metadata = Metadata::new(name); 19 | // install spec should be empty to indicate bare create! 20 | metadata.install_spec = String::new(); 21 | metadata.fill_python(&venv); 22 | 23 | metadata.save(&venv_path).await?; 24 | 25 | Ok(format!("🏗️ Succesfully created '{}'!", name.green())) 26 | } 27 | 28 | impl Process for CreateOptions { 29 | async fn process(self) -> anyhow::Result { 30 | match create( 31 | &self.venv_name, 32 | self.python.as_deref(), 33 | !self.no_seed, 34 | self.force, 35 | ) 36 | .await 37 | { 38 | Ok(msg) => { 39 | println!("{msg}"); 40 | Ok(0) 41 | }, 42 | Err(msg) => Err(msg).with_context(|| { 43 | format!( 44 | "Something went wrong trying to create '{}';", 45 | &self.venv_name 46 | ) 47 | }), 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/commands/runpython.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use std::ffi::OsStr; 3 | use std::path::Path; 4 | use subprocess::Exec; 5 | 6 | use crate::{ 7 | cli::{Process, RunpythonOptions}, 8 | venv::setup_environ_from_requirement, 9 | }; 10 | 11 | #[expect( 12 | clippy::cast_lossless, 13 | clippy::as_conversions, 14 | reason = "The numbers wont be that big." 15 | )] 16 | pub fn process_subprocess>( 17 | exec_path: &Path, 18 | args: &[S], 19 | ) -> anyhow::Result { 20 | Ok(match Exec::cmd(exec_path).args(args).join()? { 21 | subprocess::ExitStatus::Exited(int) => int as i32, 22 | subprocess::ExitStatus::Signaled(int) => int as i32, 23 | subprocess::ExitStatus::Other(int) => int, 24 | subprocess::ExitStatus::Undetermined => 0, 25 | }) 26 | } 27 | 28 | pub async fn run_python( 29 | venv_name: &str, 30 | python_args: &[String], 31 | ) -> anyhow::Result { 32 | let (_, environ) = setup_environ_from_requirement(venv_name).await?; 33 | 34 | let py = environ.interpreter().sys_executable(); 35 | 36 | // Launch Python in interactive mode 37 | process_subprocess(py, python_args) 38 | } 39 | 40 | impl Process for RunpythonOptions { 41 | async fn process(self) -> anyhow::Result { 42 | run_python(&self.venv, &self.python_args) 43 | .await 44 | .with_context(|| { 45 | format!( 46 | "Something went wrong trying to run Python in '{}';", 47 | &self.venv 48 | ) 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/commands/self_link.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::{Process, SelfLinkOptions}; 2 | use crate::helpers::PathAsStr; 3 | use crate::metadata::ensure_bin_dir; 4 | use anyhow::Context; 5 | use owo_colors::OwoColorize; 6 | use std::env; 7 | use tokio::fs::symlink; 8 | 9 | pub async fn self_link( 10 | force: bool, 11 | quiet: bool, 12 | ) -> anyhow::Result { 13 | let bin = ensure_bin_dir().await; 14 | let uvenv = bin.join("uvenv"); 15 | let current = env::current_exe()?; 16 | 17 | if uvenv == current { 18 | // nothing to do 19 | Ok(0) 20 | } else if uvenv.exists() && !force { 21 | if !quiet { 22 | eprintln!( 23 | "{}: {} already exists. Use '--force' to overwrite.", 24 | "Warning".yellow(), 25 | uvenv.as_str().green() 26 | ); 27 | } 28 | // don't bail/Err because it's just a warning. 29 | // still exit with code > 0 30 | Ok(2) // missing -f 31 | } else { 32 | symlink(¤t, &uvenv).await.with_context(|| { 33 | format!( 34 | "Failed to create symlink {} -> {}", 35 | uvenv.display(), 36 | current.display() 37 | ) 38 | })?; 39 | 40 | Ok(0) 41 | } 42 | } 43 | 44 | impl Process for SelfLinkOptions { 45 | async fn process(self) -> anyhow::Result { 46 | let result = self_link(self.force, self.quiet).await; 47 | 48 | if self.quiet { 49 | // don't complain 50 | Ok(0) 51 | } else { 52 | result.with_context(|| "Something went wrong trying to symlink 'uvenv';") 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/commands/reinstall_all.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::{Process, ReinstallAllOptions}; 2 | use crate::commands::list::list_packages; 3 | use crate::commands::reinstall::reinstall; 4 | use crate::metadata::LoadMetadataConfig; 5 | use anyhow::{Context, anyhow}; 6 | 7 | pub async fn reinstall_all( 8 | python: Option<&str>, 9 | force: bool, 10 | without_injected: bool, 11 | no_cache: bool, 12 | editable: bool, 13 | venv_names: &[String], 14 | ) -> anyhow::Result<()> { 15 | let mut all_ok = true; 16 | // only used if not all_ok, but already created for chaining: 17 | let mut err_result = Err(anyhow!("-> Failed reinstall-all.")); 18 | 19 | for meta in list_packages(&LoadMetadataConfig::none(), Some(venv_names), None).await? { 20 | match reinstall( 21 | &meta.name, 22 | python, 23 | force, 24 | !without_injected, 25 | no_cache, 26 | editable, 27 | ) 28 | .await 29 | { 30 | Ok(msg) => { 31 | println!("{msg}"); 32 | }, 33 | Err(msg) => { 34 | err_result = err_result.with_context(|| msg); 35 | // eprintln!("{}", msg.red()); 36 | all_ok = false; 37 | }, 38 | } 39 | } 40 | if all_ok { 41 | Ok(()) 42 | } else { 43 | err_result.with_context(|| "⚠️ Not all packages were properly reinstalled!") 44 | } 45 | } 46 | 47 | impl Process for ReinstallAllOptions { 48 | async fn process(self) -> anyhow::Result { 49 | match reinstall_all( 50 | self.python.as_deref(), 51 | self.force, 52 | self.without_injected, 53 | self.no_cache, 54 | self.editable, 55 | &self.venv_names, 56 | ) 57 | .await 58 | { 59 | Ok(()) => Ok(0), 60 | Err(msg) => Err(msg), 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/tests/special_home_dir.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::fs::File; 3 | use std::io::Write; 4 | 5 | use crate::helpers::PathToString; 6 | use crate::metadata::get_home_dir; 7 | use crate::tests::shared; 8 | 9 | fn test_0_custom_home_dir() { 10 | let home_dir_path = get_home_dir(); 11 | let home_dir_str = home_dir_path.clone().to_string(); 12 | 13 | assert!( 14 | !home_dir_str.contains(".local"), 15 | ".local shouldn't exist yet" 16 | ); 17 | assert!( 18 | home_dir_str.starts_with("/tmp/uvenv-test"), 19 | "Home should live at /tmp for tests!" 20 | ); 21 | 22 | assert!(home_dir_path.exists(), "Home should exist!"); 23 | assert!(shared::is_empty(&home_dir_path), "Home should be empty!"); 24 | } 25 | 26 | #[expect(clippy::panic_in_result_fn, reason = "This is a test file.")] 27 | fn test_1_write_file() -> shared::TestResult { 28 | let home = get_home_dir(); 29 | let file_path = home.join("assert_write_file"); 30 | 31 | // Write to the file 32 | let mut file = File::create(&file_path)?; 33 | let content = b"Hello, Rust!"; 34 | file.write_all(content)?; 35 | 36 | // Read from the file 37 | // let mut file = File::open(&file_path)?; 38 | // let mut buffer = Vec::new(); 39 | // file.read_to_end(&mut buffer)?; 40 | 41 | let buffer = fs::read(&file_path)?; 42 | 43 | // Assert that the content is as expected 44 | assert_eq!( 45 | buffer, content, 46 | "loaded file contents should the same as was written" 47 | ); 48 | 49 | assert!(!shared::is_empty(&home), "Home should not be empty!"); 50 | 51 | Ok(()) 52 | } 53 | 54 | #[test] 55 | /// special test which makes sure uvenv uses a custom home directory 56 | /// to prevent breaking normal installed uvenv packages on host system. 57 | fn test_home_dir_flow() -> shared::TestResult { 58 | test_0_custom_home_dir(); 59 | test_1_write_file()?; 60 | shared::cleanup(); 61 | 62 | Ok(()) 63 | } 64 | -------------------------------------------------------------------------------- /src/commands/uninject.rs: -------------------------------------------------------------------------------- 1 | use crate::animate::{AnimationSettings, show_loading_indicator}; 2 | use crate::cli::{Process, UnInjectOptions}; 3 | use crate::metadata::{LoadMetadataConfig, Metadata}; 4 | use crate::venv::setup_environ_from_requirement; 5 | use anyhow::anyhow; 6 | use itertools::Itertools; 7 | use owo_colors::OwoColorize; 8 | 9 | use crate::uv::{Helpers, uv}; 10 | 11 | pub async fn eject_package( 12 | from: &str, 13 | to_eject_specs: &[String], // .contains is used, so String is required. 14 | ) -> anyhow::Result { 15 | let (requirement, environ) = setup_environ_from_requirement(from).await?; 16 | let mut metadata = Metadata::for_requirement(&requirement, &LoadMetadataConfig::none()).await; 17 | 18 | let mut args = vec!["pip", "uninstall"]; 19 | 20 | let eject_args: Vec<&str> = to_eject_specs.iter().map(AsRef::as_ref).collect(); 21 | args.extend(eject_args); 22 | 23 | let promise = uv(&args); 24 | 25 | let to_eject_str = &to_eject_specs.iter().map(|it| it.green()).join(", "); 26 | show_loading_indicator( 27 | promise, 28 | format!("injecting {} into {}", &to_eject_str, &metadata.name), 29 | AnimationSettings::default(), 30 | ) 31 | .await?; 32 | 33 | metadata.injected = metadata 34 | .injected 35 | .iter() 36 | .filter(|i| !to_eject_specs.contains(i)) 37 | .map(ToString::to_string) 38 | .collect(); 39 | 40 | metadata.save(&environ.to_path_buf()).await?; 41 | 42 | Ok(format!( 43 | "⏏️ Ejected [{}] from {}.", 44 | &to_eject_str, 45 | &metadata.name.green(), 46 | )) 47 | } 48 | 49 | impl Process for UnInjectOptions { 50 | async fn process(self) -> anyhow::Result { 51 | match eject_package(&self.outof, &self.package_specs).await { 52 | Ok(msg) => { 53 | println!("{msg}"); 54 | Ok(0) 55 | }, 56 | Err(msg) => Err(anyhow!(msg)), 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/commands/self_migrate.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::{Process, SelfMigrateOptions}; 2 | use crate::commands::reinstall_all::reinstall_all; 3 | use crate::metadata::get_work_dir; 4 | use anyhow::{Context, anyhow}; 5 | use owo_colors::OwoColorize; 6 | use tokio::fs::rename; 7 | 8 | async fn migrate_uvx_directory() -> anyhow::Result<()> { 9 | eprintln!( 10 | "Moving {} to {}.", 11 | "~/.local/uvx".yellow(), 12 | "~/.local/uvenv".green() 13 | ); 14 | 15 | let workdir = get_work_dir(); 16 | let old_workdir = workdir 17 | .parent() 18 | .ok_or_else(|| anyhow!("~/.local missing!"))? 19 | .join("uvx"); 20 | 21 | rename(old_workdir, workdir).await?; 22 | 23 | Ok(()) 24 | } 25 | 26 | impl Process for SelfMigrateOptions { 27 | async fn process(self) -> anyhow::Result { 28 | let mut error = Err(anyhow!("-> Possibly failed auto-migration.")); 29 | let mut any_err = false; 30 | 31 | // move uvx directory: 32 | // note: this leads to invalid venvs, since the python symlinks are now incorrect. 33 | // also the ~/.local/bin symlinks will be broken. 34 | // `reinstall_all` fixes both of those issues. 35 | 36 | match migrate_uvx_directory().await { 37 | Ok(()) => {}, 38 | Err(err) => { 39 | error = error 40 | .with_context(|| "(while moving directory)") 41 | .with_context(|| err); 42 | any_err = true; 43 | }, 44 | } 45 | 46 | // reinstall to setup proper symlinks etc: 47 | 48 | match reinstall_all(None, true, false, false, false, &[]).await { 49 | Ok(()) => {}, 50 | Err(err) => { 51 | error = error.with_context(|| err); 52 | any_err = true; 53 | }, 54 | } 55 | 56 | if any_err { 57 | error.with_context(|| "Migration possibly failed;") 58 | } else { 59 | Ok(0) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/lockfile/v0.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::{FreezeOptions, OutputFormat, ThawOptions}; 2 | use crate::commands::freeze::Freeze; 3 | use crate::commands::thaw::Thaw; 4 | use crate::lockfile::{Lockfile, PackageMap, PackageSpec}; 5 | use crate::metadata::{Metadata, serialize_msgpack}; 6 | use core::fmt::Debug; 7 | use serde::de::DeserializeOwned; 8 | /// Barebones placeholder to be extended in later versions 9 | use serde::{Deserialize, Serialize}; 10 | 11 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)] 12 | struct PackageSpecV0; 13 | 14 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)] 15 | pub struct LockfileV0 { 16 | version: i8, 17 | } 18 | 19 | impl From for PackageSpecV0 { 20 | fn from(_: Metadata) -> Self { 21 | Self {} 22 | } 23 | } 24 | 25 | impl PackageSpec for PackageSpecV0 {} 26 | 27 | impl Lockfile<'_, PackageSpecV0> for LockfileV0 { 28 | fn new(_: PackageMap) -> Self { 29 | Self { version: 0 } 30 | } 31 | 32 | async fn serialize_and_patch( 33 | &self, 34 | options: &FreezeOptions, 35 | ) -> anyhow::Result> { 36 | Ok(match options.format { 37 | OutputFormat::TOML => toml::to_string(self)?.into_bytes(), 38 | OutputFormat::JSON => serde_json::to_string_pretty(self)?.into_bytes(), 39 | OutputFormat::Binary => serialize_msgpack(self).await?, 40 | }) 41 | } 42 | } 43 | 44 | impl Freeze for LockfileV0 { 45 | async fn freeze(options: &FreezeOptions) -> anyhow::Result 46 | where 47 | Self: Sized + Debug + Serialize, 48 | { 49 | let packages = PackageMap::new(); 50 | Ok(Self::write(packages, options).await?.into()) 51 | } 52 | } 53 | 54 | impl Thaw for LockfileV0 { 55 | async fn thaw( 56 | _options: &ThawOptions, 57 | _data: &[u8], 58 | _format: OutputFormat, 59 | ) -> anyhow::Result 60 | where 61 | Self: Sized + Debug + DeserializeOwned, 62 | { 63 | Ok(0) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /docs/lockfile_v1.md: -------------------------------------------------------------------------------- 1 | # Lockfile V1 Format 2 | 3 | The `uvenv.lock` file is a TOML-based lockfile (with support for other output formats) 4 | that pins exact package versions and configurations for reproducible environments. 5 | 6 | ## Structure 7 | 8 | ```toml 9 | version = 1 10 | 11 | [packages] 12 | # Package specifications go here 13 | ``` 14 | 15 | ### Package Specification 16 | 17 | Each package in the `[packages]` table supports the following options: 18 | 19 | | Field | Type | Required | Description | 20 | |------------|--------|----------|------------------------------------------------------------| 21 | | `spec` | string | Yes | Package specifier (name, with optional constraints) | 22 | | `version` | string | No | Pinned version number | 23 | | `python` | string | No | Exact Python version the package was locked for (or empty) | 24 | | `injected` | array | No | List of injected dependency names | 25 | | `editable` | bool | No | Whether the package is installed in editable mode | 26 | 27 | ## Formats 28 | 29 | ### Minimal (Compact Format) 30 | 31 | ```toml 32 | version = 1 33 | 34 | [packages] 35 | python-semantic-release = { spec = "python-semantic-release<8", version = "<8" } 36 | pgcli = { spec = "pgcli", version = "~=4.3.0", python = "3.13", injected = ["psycopg-binary", "psycopg2-binary"] } 37 | ``` 38 | 39 | ### Compact (Official Output) 40 | 41 | ```toml 42 | version = 1 43 | 44 | [packages] 45 | python-semantic-release = { spec = "python-semantic-release<8", version = "<8", python = "3.14", injected = [], editable = false } 46 | pgcli = { spec = "pgcli", version = "~=4.3.0", python = "3.13", injected = ["psycopg-binary", "psycopg2-binary"], editable = false } 47 | ``` 48 | 49 | ### Expanded (Alternative) 50 | 51 | ```toml 52 | version = 1 53 | 54 | [packages.python-semantic-release] 55 | spec = "python-semantic-release<8" 56 | version = "<8" 57 | python = "3.14" 58 | injected = [] 59 | editable = false 60 | 61 | [packages.pgcli] 62 | spec = "pgcli" 63 | version = "~=4.3.0" 64 | python = "3.13" 65 | injected = ["psycopg-binary", "psycopg2-binary"] 66 | editable = false 67 | ``` 68 | -------------------------------------------------------------------------------- /src/commands/inject.rs: -------------------------------------------------------------------------------- 1 | use crate::metadata::LoadMetadataConfig; 2 | use crate::{ 3 | animate::{AnimationSettings, show_loading_indicator}, 4 | cli::{InjectOptions, Process}, 5 | metadata::Metadata, 6 | uv::{Helpers, uv}, 7 | venv::setup_environ_from_requirement, 8 | }; 9 | use anyhow::Context; 10 | use core::fmt::Display; 11 | use owo_colors::OwoColorize; 12 | 13 | pub async fn inject_package + Display>( 14 | venv_spec: &str, 15 | to_inject_specs: &[S], 16 | no_cache: bool, 17 | ) -> anyhow::Result { 18 | let (requirement, environ) = setup_environ_from_requirement(venv_spec).await?; 19 | let mut metadata = Metadata::for_requirement(&requirement, &LoadMetadataConfig::none()).await; 20 | 21 | let mut args = vec!["pip", "install"]; 22 | 23 | if no_cache { 24 | args.push("--no-cache"); 25 | } 26 | 27 | // &[&str] -> Vec<&str> 28 | let to_inject_specs_vec: Vec<&str> = to_inject_specs.iter().map(AsRef::as_ref).collect(); 29 | args.extend(&to_inject_specs_vec); 30 | 31 | let promise = uv(&args); 32 | 33 | let to_inject_str = &to_inject_specs_vec.join(", "); 34 | show_loading_indicator( 35 | promise, 36 | format!("injecting {} into {}", &to_inject_str, &metadata.name), 37 | AnimationSettings::default(), 38 | ) 39 | .await?; 40 | 41 | metadata 42 | .injected 43 | // Vec<&str> -> Vec 44 | .extend(to_inject_specs_vec.iter().map(ToString::to_string)); 45 | 46 | metadata.save(&environ.to_path_buf()).await?; 47 | 48 | Ok(format!( 49 | "💉 Injected [{}] into {}.", 50 | &to_inject_str, 51 | &metadata.name.green(), 52 | )) 53 | } 54 | 55 | impl Process for InjectOptions { 56 | async fn process(self) -> anyhow::Result { 57 | // vec -> vec 58 | match inject_package(&self.into, &self.package_specs, self.no_cache).await { 59 | Ok(msg) => { 60 | println!("{msg}"); 61 | Ok(0) 62 | }, 63 | Err(msg) => Err(msg).with_context(|| { 64 | format!( 65 | "Something went wrong trying to inject {:?} into '{}';", 66 | &self.package_specs, self.into 67 | ) 68 | }), 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: uvenv 2 | base: core24 3 | adopt-info: version 4 | # note: current metadata is managed at https://snapcraft.io/uvenv/listing 5 | summary: "uvenv: pipx for uv (🦀)" 6 | description: | 7 | uvenv is a simple command-line tool for managing virtual environments, written in Rust. 8 | Think of it as pipx, but for uv. 9 | 10 | 📖 Full documentation: https://github.com/robinvandernoord/uvenv 11 | 12 | **Getting started** 13 | 14 | snap install uvenv 15 | 16 | **Snap Installation Caveats** 17 | 18 | When installed via Snap, there are some important differences to note: 19 | 20 | - Tools are downloaded to `~/snap/uvenv//.local/uvenv` instead of `~/.local/uvenv` 21 | - Scripts are installed in `~/snap/uvenv//.local/bin` instead of `~/.local/bin` 22 | - The snap package cannot update files like `~/.bashrc` or perform self-updates. 23 | 24 | **Setting Up Bash Integration** 25 | 26 | To enable all Bash-specific features, add the following lines to your `~/.bashrc`: 27 | 28 | eval "$(uvenv --generate=bash ensurepath)" # Required: Fix PATH 29 | eval "$(uvenv --generate=bash completions)" # Optional: Enable tab completion 30 | eval "$(uvenv --generate=bash activate _)" # Optional: Enable the `uvenv activate` command 31 | 32 | For other shells, run: 33 | 34 | uvenv setup 35 | 36 | This will display the appropriate setup instructions for your shell. 37 | 38 | grade: stable 39 | confinement: strict 40 | 41 | apps: 42 | uvenv: 43 | command: usr/bin/uvenv 44 | extensions: [] 45 | environment: 46 | UV_CACHE_DIR: "$SNAP_USER_COMMON/.uv-cache" 47 | plugs: 48 | - network 49 | 50 | parts: 51 | version: 52 | plugin: nil 53 | source: https://github.com/robinvandernoord/uvenv.git 54 | source-type: git 55 | build-packages: 56 | - git 57 | override-pull: | 58 | snapcraftctl pull 59 | git pull 60 | git fetch --tags 61 | snapcraftctl set-version "$(git describe --tags `git rev-list --tags --max-count=1`)" 62 | 63 | link-app: 64 | # build happens outside of snapcraft, this step turns binary into snap. 65 | plugin: dump 66 | source: target/release 67 | override-build: | 68 | set -eu 69 | install -D ./uv $SNAPCRAFT_PART_INSTALL/usr/bin/uv 70 | install -D ./uvenv $SNAPCRAFT_PART_INSTALL/usr/bin/uvenv 71 | 72 | -------------------------------------------------------------------------------- /src/animate.rs: -------------------------------------------------------------------------------- 1 | use core::future::Future; 2 | use core::iter::Cycle; 3 | use core::slice::Iter; 4 | use core::time::Duration; 5 | use scopeguard::defer; 6 | use std::io::{self, Write}; 7 | use tokio::task; 8 | 9 | #[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Ord, Eq, Hash, Default)] 10 | pub enum AnimationStyle { 11 | #[default] 12 | Modern, 13 | #[expect(dead_code, reason = "Nice to have")] 14 | Classic, 15 | } 16 | 17 | #[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Ord, Eq, Hash, Default)] 18 | pub enum AnimationOrder { 19 | #[default] 20 | Before, 21 | #[expect(dead_code, reason = "Nice to have")] 22 | After, 23 | } 24 | 25 | #[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Ord, Eq, Hash, Default)] 26 | pub struct AnimationSettings { 27 | pub style: AnimationStyle, 28 | pub order: AnimationOrder, 29 | } 30 | 31 | impl AnimationSettings { 32 | pub fn get_spinner_chars(&self) -> Cycle> { 33 | match self.style { 34 | AnimationStyle::Classic => ['|', '/', '-', '\\'].iter(), 35 | AnimationStyle::Modern => ['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', '⣾'].iter(), 36 | } 37 | .cycle() 38 | } 39 | } 40 | 41 | pub async fn animation( 42 | message: String, 43 | style: AnimationSettings, 44 | ) -> Option<()> { 45 | let mut spinner_chars = style.get_spinner_chars(); 46 | let ordering = &style.order; 47 | loop { 48 | let char = spinner_chars.next()?; // Cycle never returns None, but ? required for type 49 | match &ordering { 50 | AnimationOrder::Before => { 51 | eprint!("\r{} {} ", &char, &message); 52 | }, 53 | AnimationOrder::After => { 54 | eprint!("\r{} {} ", &message, &char); 55 | }, 56 | } 57 | 58 | io::stdout().flush().expect("Writing to stdout failed?"); 59 | tokio::time::sleep(Duration::from_millis(100)).await; 60 | } 61 | } 62 | 63 | pub async fn show_loading_indicator, P: Future>( 64 | promise: P, 65 | message: S, 66 | style: AnimationSettings, 67 | ) -> T { 68 | let spinner = task::spawn(animation(message.into(), style)); 69 | 70 | defer! { 71 | spinner.abort(); // Abort the spinner loop as download completes 72 | eprint!("\r\x1B[2K"); // clear the line 73 | } 74 | 75 | promise.await 76 | } 77 | -------------------------------------------------------------------------------- /src/commands/thaw.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::{OutputFormat, Process, ThawOptions}; 2 | use crate::lockfile::AutoDeserialize; 3 | use crate::lockfile::v0::LockfileV0; 4 | use crate::lockfile::v1::LockfileV1; 5 | use anyhow::{Context, bail}; 6 | use core::fmt::Debug; 7 | use owo_colors::OwoColorize; 8 | use serde::de::DeserializeOwned; 9 | use serde::{Deserialize, Serialize}; 10 | 11 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)] 12 | struct OnlyVersion { 13 | // only load version first. Other fields may change but this will remain the same. 14 | version: i8, 15 | } 16 | 17 | pub trait Thaw { 18 | async fn thaw( 19 | options: &ThawOptions, 20 | data: &[u8], 21 | format: OutputFormat, 22 | ) -> anyhow::Result 23 | where 24 | Self: Sized + Debug + DeserializeOwned; 25 | } 26 | 27 | async fn search_default_files() -> std::io::Result> { 28 | // 1. uvenv.lock 29 | // 2. uvenv.toml 30 | // 3. uvenv.json 31 | let possible_files = ["uvenv.lock", "uvenv.toml", "uvenv.json"]; 32 | 33 | let mut last_err = None; 34 | for filename in possible_files { 35 | match tokio::fs::read(filename).await { 36 | Ok(contents) => return Ok(contents), 37 | Err(err) => last_err = Some(err), 38 | } 39 | } 40 | 41 | Err(last_err 42 | .unwrap_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "No lockfile found"))) 43 | } 44 | 45 | impl Process for ThawOptions { 46 | async fn process(self) -> anyhow::Result { 47 | let maybe_contents = if self.filename.is_empty() { 48 | // try to find file instead: 49 | search_default_files().await 50 | } else { 51 | tokio::fs::read(&self.filename).await 52 | }; 53 | 54 | let contents = maybe_contents.with_context(|| { 55 | format!( 56 | "Failed to determine lockfile version in {}", 57 | self.filename.red() 58 | ) 59 | })?; 60 | 61 | if let Some((version, format)) = OnlyVersion::auto(&contents) { 62 | match version { 63 | OnlyVersion { version: 0 } => LockfileV0::thaw(&self, &contents, format).await, 64 | OnlyVersion { version: 1 } => LockfileV1::thaw(&self, &contents, format).await, 65 | OnlyVersion { .. } => { 66 | bail!("Unsupported version!") 67 | }, 68 | } 69 | } else { 70 | bail!("Could not determine filetype of {}.", self.filename.red()); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/commands/uninstall.rs: -------------------------------------------------------------------------------- 1 | use crate::pip::parse_requirement; 2 | use crate::uv::Helpers; 3 | use anyhow::{Context, bail}; 4 | use owo_colors::OwoColorize; 5 | 6 | use crate::cli::{Process, UninstallOptions}; 7 | use crate::metadata::{LoadMetadataConfig, Metadata, venv_path}; 8 | use crate::symlinks::{find_symlinks, remove_symlink, remove_symlinks}; 9 | use crate::venv::{activate_venv, remove_venv}; 10 | 11 | pub async fn uninstall_package( 12 | package_name: &str, 13 | force: bool, 14 | ) -> anyhow::Result { 15 | let (requirement, _) = parse_requirement(package_name).await?; 16 | let requirement_name = requirement.name.to_string(); 17 | 18 | let venv_dir = venv_path(&requirement_name); 19 | 20 | if !venv_dir.exists() { 21 | #[expect(clippy::redundant_else, reason = "Clarity")] 22 | if force { 23 | remove_symlink(&requirement_name).await?; 24 | bail!( 25 | "{}: No virtualenv for '{}'.", 26 | "Warning".yellow(), 27 | &requirement_name.green() 28 | ) 29 | } else { 30 | bail!( 31 | "No virtualenv for '{}', stopping.\nUse '{}' to remove an executable with that name anyway.", 32 | &requirement_name.green(), 33 | "--force".blue() 34 | ) 35 | }; 36 | } 37 | 38 | let venv = activate_venv(&venv_dir).await?; 39 | 40 | let metadata = Metadata::for_requirement(&requirement, &LoadMetadataConfig::none()).await; 41 | 42 | // symlinks = find_symlinks(package_name, venv_path) or [package_name] 43 | let symlinks = find_symlinks(&requirement, &metadata.installed_version, &venv).await; 44 | 45 | remove_symlinks(&symlinks).await?; 46 | 47 | remove_venv(&venv.to_path_buf()).await?; 48 | 49 | let version_msg = if metadata.installed_version.is_empty() { 50 | String::new() 51 | } else { 52 | format!(" ({})", metadata.installed_version.cyan()) 53 | }; 54 | 55 | let msg = format!("🗑️ {package_name}{version_msg} removed!"); 56 | 57 | Ok(msg) 58 | } 59 | 60 | impl Process for UninstallOptions { 61 | async fn process(self) -> anyhow::Result { 62 | match uninstall_package(&self.package_name, self.force).await { 63 | Ok(msg) => { 64 | println!("{msg}"); 65 | Ok(0) 66 | }, 67 | Err(msg) => Err(msg).with_context(|| { 68 | format!( 69 | "Something went wrong while uninstalling '{}';", 70 | &self.package_name 71 | ) 72 | }), 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/cmd.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use std::env; 3 | use std::ffi::OsStr; 4 | use std::path::PathBuf; 5 | use std::process::{Output, Stdio}; 6 | 7 | use tokio::fs::canonicalize; 8 | use tokio::process::Command; 9 | 10 | pub async fn find_sibling(name: &str) -> Option { 11 | let Ok(binary_path) = &env::current_exe() else { 12 | return None; 13 | }; 14 | 15 | let Ok(real_path) = canonicalize(&binary_path).await else { 16 | return None; 17 | }; 18 | 19 | let parent = real_path.parent()?; 20 | // resolve symlinks etc: 21 | 22 | let binary = parent.join(name); 23 | 24 | // .then(|| binary) is the same: 25 | binary.exists().then_some(binary) // else None 26 | } 27 | 28 | pub async fn run_print_output, S2: AsRef>( 29 | command: S1, 30 | args: &[S2], 31 | ) -> anyhow::Result { 32 | let mut cmd = Command::new(command); 33 | cmd.args(args); 34 | cmd.stdout(Stdio::inherit()); 35 | cmd.stderr(Stdio::inherit()); 36 | let code = cmd.status().await?; 37 | 38 | Ok(code.code().unwrap_or(-1)) 39 | } 40 | 41 | pub async fn run_get_output, S2: AsRef>( 42 | command: S1, 43 | args: &[S2], 44 | ) -> anyhow::Result { 45 | let command_result = Command::new(command).args(args).output().await; 46 | 47 | match command_result { 48 | Ok(result) => match result.status.code() { 49 | Some(0) => Ok(String::from_utf8(result.stdout).unwrap_or_default()), 50 | Some(_) | None => Err(anyhow!( 51 | String::from_utf8(result.stderr).unwrap_or_default() 52 | )), 53 | }, 54 | Err(result_err) => Err(result_err.into()), 55 | } 56 | } 57 | 58 | pub async fn run, S2: AsRef>( 59 | script: S1, 60 | args: &[S2], 61 | err_prefix: Option, 62 | ) -> anyhow::Result { 63 | let command_result = Command::new(script).args(args).output().await; 64 | 65 | #[expect( 66 | clippy::option_if_let_else, 67 | reason = "map_or_else complains about moved 'err'" 68 | )] 69 | match command_result { 70 | Ok(Output { status, stderr, .. }) => { 71 | if status.success() { 72 | Ok(true) 73 | } else { 74 | Err(String::from_utf8(stderr).unwrap_or_default()) 75 | } 76 | }, 77 | Err(result) => Err(result.to_string()), 78 | } // if err, add prefix: 79 | .map_err(|err| { 80 | if let Some(prefix) = err_prefix { 81 | anyhow!("{prefix} | {err}") 82 | } else { 83 | anyhow!(err) 84 | } 85 | }) 86 | } 87 | -------------------------------------------------------------------------------- /src/commands/ensurepath.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, bail}; 2 | use std::path::PathBuf; 3 | 4 | use chrono::Local; 5 | use owo_colors::OwoColorize; 6 | use tokio::{fs::OpenOptions, io::AsyncWriteExt}; 7 | 8 | use crate::helpers::PathAsStr; 9 | use crate::shell::SupportedShell; 10 | use crate::{ 11 | cli::{EnsurepathOptions, Process}, 12 | metadata::ensure_bin_dir, 13 | }; 14 | 15 | pub fn now() -> String { 16 | let dt = Local::now(); 17 | 18 | match dt.to_string().split_once('.') { 19 | None => String::new(), 20 | Some((datetime, _)) => datetime.to_owned(), 21 | } 22 | } 23 | 24 | pub async fn append( 25 | file_path: &PathBuf, 26 | text: &str, 27 | ) -> anyhow::Result<()> { 28 | let mut file = OpenOptions::new().append(true).open(file_path).await?; 29 | 30 | file.write_all(text.as_bytes()).await?; 31 | file.flush().await?; 32 | Ok(()) 33 | } 34 | 35 | pub fn check_in_path(dir: &str) -> bool { 36 | let path = std::env::var("PATH").unwrap_or_default(); 37 | 38 | path.split(':').any(|x| x == dir) 39 | } 40 | 41 | pub const SNAP_ENSUREPATH: &str = "eval \"$(uvenv --generate=bash ensurepath)\""; 42 | 43 | pub async fn ensure_path(force: bool) -> anyhow::Result { 44 | let bin_path = ensure_bin_dir().await; 45 | let bin_dir = bin_path.as_str(); 46 | 47 | let shell = SupportedShell::detect(); 48 | 49 | let already_in_path = check_in_path(bin_dir); 50 | let rcfile = shell.rc_file().unwrap_or("rc"); 51 | 52 | if !force && already_in_path { 53 | eprintln!( 54 | "{}: {} is already added to your path. Use '{}' to add it to your {} file anyway.", 55 | "Warning".yellow(), 56 | bin_dir.green(), 57 | "--force".blue(), 58 | rcfile, 59 | ); 60 | // don't bail/Err because it's just a warning. 61 | // still exit with code > 0 62 | Ok(2) // missing -f 63 | } else { 64 | if cfg!(feature = "snap") { 65 | bail!( 66 | "{} snap-installed {} cannot write directly to `{}`. You can add the following line to make this feature work:\n\n{SNAP_ENSUREPATH}\n", 67 | "Warning:".yellow(), 68 | "`uvenv`".blue(), 69 | rcfile.blue() 70 | ); 71 | } 72 | 73 | shell.add_to_path(bin_dir, true).await?; 74 | 75 | eprintln!( 76 | "Added '{}' to ~/{}", 77 | bin_dir.green(), 78 | shell.rc_file().unwrap_or_default() 79 | ); 80 | Ok(0) 81 | } 82 | } 83 | 84 | pub async fn ensure_path_generate() -> String { 85 | let bin_path = ensure_bin_dir().await; 86 | let bin_dir = bin_path.as_str(); 87 | format!("export PATH=\"$PATH:{bin_dir}\"") 88 | } 89 | 90 | impl Process for EnsurepathOptions { 91 | async fn process(self) -> anyhow::Result { 92 | if let Err(msg) = ensure_path(self.force).await { 93 | Err(msg).with_context(|| "Something went wrong trying to ensure a proper PATH;") 94 | } else { 95 | Ok(0) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod animate; 2 | mod cli; 3 | mod cmd; 4 | mod commands; 5 | mod helpers; 6 | mod metadata; 7 | mod pip; 8 | mod promises; 9 | mod pypi; 10 | mod symlinks; 11 | mod tests; 12 | mod uv; 13 | mod venv; 14 | 15 | mod lockfile; 16 | mod macros; 17 | mod shell; 18 | 19 | use std::io; 20 | 21 | use clap::{Command, CommandFactory, Parser}; 22 | use clap_complete::{Generator, Shell, generate}; 23 | 24 | use crate::cli::{Args, Process}; 25 | use crate::commands::activate::generate_activate; 26 | use crate::commands::ensurepath::ensure_path_generate; 27 | use crate::helpers::fmt_error; 28 | use crate::shell::SupportedShell; 29 | use std::process::exit; 30 | 31 | pub fn print_completions( 32 | generator: G, 33 | cmd: &mut Command, 34 | ) { 35 | // get_name returns a str, to_owned = to_string (but restriction::str_to_string) 36 | generate(generator, cmd, cmd.get_name().to_owned(), &mut io::stdout()); 37 | } 38 | 39 | pub async fn generate_completions_shell(generator: Shell) { 40 | let mut cmd = Args::command(); 41 | 42 | let args = cmd.clone().get_matches(); 43 | match args.subcommand_name() { 44 | Some("activate") => { 45 | // generate code for uvenv activate 46 | println!("{}", generate_activate().await); 47 | }, 48 | Some("ensurepath") => { 49 | // geneate code for uvenv ensurepath 50 | println!("{}", ensure_path_generate().await); 51 | }, 52 | _ => { 53 | // other cases: show regular completions 54 | // note: this should support zsh but doesn't seem to actually work :( 55 | print_completions(generator, &mut cmd); 56 | // todo: dynamic completions for e.g. `uvenv upgrade ` 57 | }, 58 | } 59 | } 60 | 61 | pub async fn generate_code(target: Shell) -> i32 { 62 | match target { 63 | Shell::Bash | Shell::Zsh => { 64 | generate_completions_shell(target).await; 65 | 0 66 | }, 67 | Shell::Elvish | Shell::Fish | Shell::PowerShell => { 68 | eprintln!( 69 | "Error: only '{}' are supported at this moment.", 70 | SupportedShell::list_options_formatted() 71 | ); 72 | 126 73 | }, 74 | #[expect( 75 | clippy::todo, 76 | reason = "This is used in a catch all that should be exhaustive" 77 | )] 78 | _ => { 79 | todo!("Unknown shell, not implemented yet!"); 80 | }, 81 | } 82 | } 83 | 84 | #[tokio::main] 85 | async fn main() { 86 | let args = Args::parse(); 87 | 88 | let exit_code = if let Some(generator) = args.generator { 89 | generate_code(generator).await 90 | } else { 91 | args.cmd.process().await.unwrap_or_else(|msg| { 92 | eprintln!("{}", fmt_error(&msg)); 93 | 1 94 | }) 95 | }; 96 | 97 | // If bundled via an entrypoint, the first argument is 'python' so skip it: 98 | // let args = Args::parse_from_python(); 99 | 100 | exit(exit_code); 101 | } 102 | -------------------------------------------------------------------------------- /src/commands/reinstall.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, bail}; 2 | use owo_colors::OwoColorize; 3 | 4 | use crate::commands::create::create; 5 | use crate::metadata::LoadMetadataConfig; 6 | use crate::{ 7 | cli::{Process, ReinstallOptions}, 8 | commands::{install::install_package, uninstall::uninstall_package}, 9 | metadata::{Metadata, venv_path}, 10 | pip::parse_requirement, 11 | uv::ExtractInfo, 12 | }; 13 | 14 | pub async fn reinstall( 15 | install_spec: &str, 16 | python: Option<&str>, 17 | force: bool, 18 | with_injected: bool, 19 | no_cache: bool, 20 | editable: bool, 21 | ) -> anyhow::Result { 22 | let (requirement, _resolved_install_spec) = parse_requirement(install_spec).await?; 23 | let requirement_name = requirement.name.to_string(); 24 | 25 | let venv_dir = venv_path(&requirement_name); 26 | 27 | if !venv_dir.exists() && !force { 28 | bail!( 29 | "'{}' was not previously installed. Please run 'uvenv install {}' or pass `--force` instead.", 30 | &requirement_name, 31 | &install_spec, 32 | ); 33 | } 34 | 35 | let current_metadata = 36 | Metadata::for_requirement(&requirement, &LoadMetadataConfig::none()).await; 37 | 38 | let install_spec_changed = 39 | editable || !requirement.version().is_empty() || !requirement.extras().is_empty(); 40 | 41 | if let Err(err) = uninstall_package(&requirement_name, force).await { 42 | eprintln!( 43 | "{}: something went wrong during uninstall ({});", 44 | "Warning".yellow(), 45 | err 46 | ); 47 | } 48 | 49 | let new_install_spec = if install_spec_changed { 50 | install_spec 51 | } else { 52 | ¤t_metadata.install_spec 53 | }; 54 | 55 | // use --python if specified, otherwise use the current python spec (which can be none) 56 | let python_spec = python.or(current_metadata.python_spec.as_deref()); 57 | 58 | let inject = if with_injected { 59 | current_metadata.vec_injected() 60 | } else { 61 | Vec::new() 62 | }; 63 | 64 | if new_install_spec.is_empty() { 65 | create( 66 | ¤t_metadata.name, 67 | python_spec, 68 | true, // force seed for now 69 | force, 70 | ) 71 | .await 72 | } else { 73 | install_package( 74 | new_install_spec, 75 | None, 76 | python_spec, 77 | force, 78 | &inject, 79 | no_cache, 80 | editable, 81 | ) 82 | .await 83 | } 84 | } 85 | 86 | impl Process for ReinstallOptions { 87 | async fn process(self) -> anyhow::Result { 88 | match reinstall( 89 | &self.package, 90 | self.python.as_deref(), 91 | self.force, 92 | !self.without_injected, 93 | self.no_cache, 94 | self.editable, 95 | ) 96 | .await 97 | { 98 | Ok(msg) => { 99 | println!("{msg}"); 100 | Ok(0) 101 | }, 102 | Err(msg) => Err(msg).with_context(|| { 103 | format!( 104 | "Something went wrong trying to reinstall '{}';", 105 | self.package 106 | ) 107 | }), 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | PACKAGE_NAME="uvenv" 6 | INSTALL_DIR="$HOME/.local/bin" 7 | VENVS_DIR="$HOME/.local/uvenv/venvs" 8 | VENV_DIR="$VENVS_DIR/$PACKAGE_NAME" 9 | TMPDIR="$(mktemp -d)" 10 | cd "$TMPDIR" 11 | 12 | detect_platform() { 13 | OS="$(uname -s)" 14 | ARCH="$(uname -m)" 15 | 16 | case "$ARCH" in 17 | x86_64) ARCH="x86_64" ;; 18 | aarch64 | arm64) ARCH="aarch64" ;; 19 | *) echo "❌ Unsupported architecture: $ARCH" && exit 1 ;; 20 | esac 21 | 22 | case "$OS" in 23 | Linux) 24 | if ldd --version 2>&1 | grep -qi musl; then 25 | LIBC="musl" 26 | else 27 | LIBC="gnu" 28 | fi 29 | TARGET="${ARCH}-unknown-linux-${LIBC}" 30 | ;; 31 | Darwin) 32 | TARGET="${ARCH}-apple-darwin" 33 | ;; 34 | *) 35 | echo "❌ Unsupported OS: $OS" && exit 1 36 | ;; 37 | esac 38 | 39 | echo "🔍 Detected target: $TARGET" 40 | } 41 | 42 | download_uv() { 43 | UV_FILENAME="uv-${TARGET}.tar.gz" 44 | UV_URL="https://github.com/astral-sh/uv/releases/latest/download/${UV_FILENAME}" 45 | echo "⬇️ Downloading uv from $UV_URL..." 46 | curl -sSL "$UV_URL" -o uv.tar.gz 47 | tar -xzf uv.tar.gz 48 | UV_BIN="./uv-${TARGET}/uv" 49 | chmod +x "$UV_BIN" 50 | } 51 | 52 | create_venv_and_install() { 53 | echo "📦 Creating virtual environment at $VENV_DIR..." 54 | mkdir -p "$VENVS_DIR" 55 | "$UV_BIN" venv "$VENV_DIR" 56 | export VIRTUAL_ENV="$VENV_DIR" 57 | echo "📥 Installing $PACKAGE_NAME with uv..." 58 | "$UV_BIN" pip install "$PACKAGE_NAME" 2> /dev/null 59 | } 60 | 61 | link_executable() { 62 | echo "🔗 Linking executable to $INSTALL_DIR..." 63 | mkdir -p "$INSTALL_DIR" 64 | ln -sf "$VENV_DIR/bin/$PACKAGE_NAME" "$INSTALL_DIR/$PACKAGE_NAME" 65 | } 66 | 67 | get_shell() { 68 | SHELL_NAME=$(ps -p $$ -o comm= | sed 's/^-//') 69 | basename "$SHELL_NAME" 70 | } 71 | 72 | get_shell_rc_file() { 73 | SHELL_NAME=$(get_shell) 74 | case "$SHELL_NAME" in 75 | bash) echo "$HOME/.bashrc" ;; 76 | zsh) echo "$HOME/.zshrc" ;; 77 | sh) echo "$HOME/.profile" ;; 78 | *) echo "$HOME/.profile" ;; 79 | esac 80 | } 81 | 82 | add_path_to_rc() { 83 | printf "\n# Added by uvenv installer\nexport PATH=\"%s:\$PATH\"\n" "$INSTALL_DIR" >> "$RC_FILE" 84 | } 85 | 86 | maybe_update_path() { 87 | echo "" 88 | echo "🧪 Checking if $INSTALL_DIR is in your PATH..." 89 | 90 | case ":$PATH:" in 91 | *":$INSTALL_DIR:"*) 92 | echo "✅ $INSTALL_DIR is already in PATH." 93 | return 94 | ;; 95 | esac 96 | 97 | RC_FILE=$(get_shell_rc_file) 98 | echo "❓ $INSTALL_DIR is not in your PATH." 99 | printf " Would you like to automatically add it to your shell config (%s)? [y/N] " "$RC_FILE" 100 | read -r REPLY 101 | case "$REPLY" in 102 | [yY][eE][sS]|[yY]) 103 | add_path_to_rc 104 | echo "✅ Added to $RC_FILE" 105 | echo "❗ Run: source $RC_FILE" 106 | ;; 107 | *) 108 | echo "⚠️ Please add the following line to your shell config manually:" 109 | echo " export PATH=\"$INSTALL_DIR:\$PATH\"" 110 | ;; 111 | esac 112 | } 113 | 114 | main() { 115 | detect_platform 116 | download_uv 117 | create_venv_and_install 118 | link_executable 119 | maybe_update_path 120 | 121 | echo "" 122 | echo "🎉 $PACKAGE_NAME installed successfully!" 123 | echo "➡️ After reloading your shell, you can run it with: $PACKAGE_NAME" 124 | } 125 | 126 | main 127 | -------------------------------------------------------------------------------- /src/helpers.rs: -------------------------------------------------------------------------------- 1 | use core::any::type_name; 2 | use core::fmt::Display; 3 | use std::env; 4 | use std::ffi::OsStr; 5 | use std::fs::File; 6 | use std::path::{Path, PathBuf}; 7 | 8 | /// Wrapper for unsafe `env::set_var` 9 | pub fn set_env_var, V: AsRef>( 10 | key: K, 11 | value: V, 12 | ) { 13 | // SAFETY: as specified in rustc_deprecated_safe_2024(audit_that): 14 | // ✅ the environment access only happens in single-threaded code 15 | unsafe { 16 | env::set_var(key, value); 17 | } 18 | } 19 | 20 | pub trait ResultToString { 21 | // #[expect(dead_code, reason = "Could still be useful sometimes")] 22 | fn map_err_to_string(self) -> Result; 23 | } 24 | 25 | impl ResultToString for Result { 26 | fn map_err_to_string(self) -> Result { 27 | // instead of to_string(), this will include more info: 28 | self.map_err(|err| format!("{err:#}")) 29 | } 30 | } 31 | 32 | pub fn fmt_error(err: &anyhow::Error) -> String { 33 | format!("{err:?}") 34 | } 35 | 36 | /// Source: 37 | #[expect(dead_code, clippy::use_debug, reason = "Debugging reasons.")] 38 | pub fn print_type(_: &T) { 39 | println!("{:?}", type_name::()); 40 | } 41 | 42 | // https://users.rust-lang.org/t/is-there-a-simple-way-to-give-a-default-string-if-the-string-variable-is-empty/100411 43 | 44 | pub trait StringExt { 45 | fn or( 46 | self, 47 | dflt: &str, 48 | ) -> String; 49 | } 50 | 51 | impl> StringExt for S { 52 | fn or( 53 | self, 54 | dflt: &str, 55 | ) -> String { 56 | // Re-use a `String`s capacity, maybe 57 | let mut result_string = self.into(); 58 | if result_string.is_empty() { 59 | result_string.push_str(dflt); 60 | } 61 | result_string 62 | } 63 | } 64 | 65 | pub trait PathAsStr<'path> { 66 | fn as_str(&'path self) -> &'path str; 67 | } 68 | 69 | impl<'path> PathAsStr<'path> for PathBuf { 70 | fn as_str(&'path self) -> &'path str { 71 | self.to_str().unwrap_or_default() 72 | } 73 | } 74 | 75 | impl<'path> PathAsStr<'path> for Path { 76 | fn as_str(&'path self) -> &'path str { 77 | self.to_str().unwrap_or_default() 78 | } 79 | } 80 | 81 | pub trait PathToString<'path>: PathAsStr<'path> { 82 | fn to_string(self) -> String; 83 | } 84 | 85 | /// `PathToString` can't be implemented for Path because sizes need to be known at comptime 86 | impl PathToString<'_> for PathBuf { 87 | fn to_string(self) -> String { 88 | self.into_os_string().into_string().unwrap_or_default() 89 | } 90 | } 91 | 92 | /// `Option>` can be flattened with `.flatten()` 93 | /// but this can be used for Option<&Option> 94 | #[expect(dead_code, reason = "Could still be useful in the future.")] 95 | pub const fn flatten_option_ref(nested: Option<&Option>) -> Option<&T> { 96 | match nested { 97 | Some(Some(version)) => Some(version), 98 | _ => None, 99 | } 100 | } 101 | 102 | pub trait Touch { 103 | fn touch(&self) -> anyhow::Result<()>; 104 | } 105 | 106 | fn touch>(path: P) -> anyhow::Result<()> { 107 | let path_ref = path.as_ref(); 108 | if !path_ref.exists() { 109 | File::create(path_ref)?; 110 | } 111 | Ok(()) 112 | } 113 | 114 | impl Touch for Path { 115 | fn touch(&self) -> anyhow::Result<()> { 116 | touch(self) 117 | } 118 | } 119 | 120 | impl Touch for PathBuf { 121 | fn touch(&self) -> anyhow::Result<()> { 122 | touch(self) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /.github/workflows/create-pr-on-uv-release.yml: -------------------------------------------------------------------------------- 1 | name: Update uv Dependencies 2 | 3 | on: 4 | schedule: 5 | - cron: '0 6 * * *' # Runs daily at 6 AM UTC 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | update-uv: 14 | runs-on: ubuntu-22.04 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: '3.12' 23 | 24 | - name: Fetch latest uv tag 25 | id: get_tag 26 | run: | 27 | latest_tag=$(git ls-remote --tags --refs https://github.com/astral-sh/uv.git | awk -F/ '{print $NF}' | sort -V | tail -n1) 28 | # Remove any leading 'v' 29 | version=$(echo "$latest_tag" | sed 's/^v//') 30 | echo "tag=$latest_tag" >> $GITHUB_OUTPUT 31 | echo "version=$version" >> $GITHUB_OUTPUT 32 | 33 | - name: Get current uv version from pyproject.toml 34 | id: current_py_uv 35 | run: | 36 | current_version=$(grep -oP 'uv==\K[0-9\.]+' pyproject.toml | head -n 1) 37 | echo "current=$current_version" >> $GITHUB_OUTPUT 38 | 39 | - name: Check if update is needed 40 | id: check_update 41 | run: | 42 | if [ "${{ steps.get_tag.outputs.version }}" = "${{ steps.current_py_uv.outputs.current }}" ]; then 43 | echo "No update needed." 44 | echo "update_needed=false" >> $GITHUB_OUTPUT 45 | exit 0 46 | else 47 | echo "Update needed: ${{ steps.get_tag.outputs.version }}" 48 | echo "update_needed=true" >> $GITHUB_OUTPUT 49 | fi 50 | 51 | - name: Update Cargo.toml uv* dependencies 52 | if: steps.check_update.outputs.update_needed == 'true' 53 | run: | 54 | latest_tag="${{ steps.get_tag.outputs.tag }}" 55 | # For each uv* dependency, update its tag in Cargo.toml 56 | sed -i 's/\(uv\(-[a-z0-9]\+\)*\s*=\s*{[^}]*tag\s*=\s*"\)[^"]*\(".*\)/\1'"$latest_tag"'\3/' Cargo.toml 57 | 58 | - name: Update uv in pyproject.toml 59 | if: steps.check_update.outputs.update_needed == 'true' 60 | run: | 61 | latest_version="${{ steps.get_tag.outputs.version }}" 62 | # Update uv==... line in pyproject.toml 63 | sed -i -E 's/(uv==)[0-9\.]+/\1'"$latest_version"'/g' pyproject.toml 64 | 65 | - name: Set up Rust 66 | if: steps.check_update.outputs.update_needed == 'true' 67 | uses: dtolnay/rust-toolchain@stable 68 | 69 | - name: Cache Rust dependencies 70 | if: steps.check_update.outputs.update_needed == 'true' 71 | uses: actions/cache@v4 72 | with: 73 | path: | 74 | ~/.cargo/registry 75 | ~/.cargo/git 76 | target 77 | key: ${{ runner.os }}-cargo 78 | restore-keys: | 79 | ${{ runner.os }}-cargo 80 | 81 | - name: Run cargo check 82 | if: steps.check_update.outputs.update_needed == 'true' 83 | run: | 84 | cargo update 85 | cargo check 86 | 87 | - name: Create Pull Request 88 | if: steps.check_update.outputs.update_needed == 'true' 89 | uses: peter-evans/create-pull-request@v6 90 | with: 91 | commit-message: "chore: update uv dependencies to ${{ steps.get_tag.outputs.version }}" 92 | branch: "update/uv-${{ steps.get_tag.outputs.version }}" 93 | title: "chore: update uv dependencies to ${{ steps.get_tag.outputs.version }}" 94 | body: | 95 | This PR updates all uv* dependencies in Cargo.toml and the uv version in pyproject.toml to ${{ steps.get_tag.outputs.version }}. 96 | delete-branch: true -------------------------------------------------------------------------------- /src/lockfile/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::{FreezeOptions, OutputFormat}; 2 | use crate::metadata::{Metadata, atomic_write}; 3 | use anyhow::anyhow; 4 | use core::fmt::Debug; 5 | use owo_colors::OwoColorize; 6 | use regex::Regex; 7 | use serde::Serialize; 8 | use serde::de::DeserializeOwned; 9 | use std::collections::BTreeMap; 10 | 11 | pub mod v0; 12 | pub mod v1; 13 | 14 | type PackageMap

= BTreeMap; 15 | 16 | trait PackageSpec: From {} 17 | 18 | trait Lockfile<'de, P: PackageSpec + From + Debug + Serialize> { 19 | fn new(packages: PackageMap

) -> Self; 20 | 21 | async fn serialize_and_patch( 22 | &self, 23 | options: &FreezeOptions, 24 | ) -> anyhow::Result> 25 | where 26 | Self: Sized + Serialize; 27 | 28 | // predefined implementations: 29 | 30 | async fn dump_to_file( 31 | &self, 32 | options: &FreezeOptions, 33 | ) -> anyhow::Result<()> 34 | where 35 | Self: Sized + Serialize, 36 | { 37 | let format = &options.format; 38 | let filename = &options.filename; 39 | 40 | let serialized = self.serialize_and_patch(options).await?; 41 | 42 | atomic_write(filename, &serialized).await?; 43 | 44 | eprintln!( 45 | "Saved {} to {}.", 46 | format.to_string().blue(), 47 | filename.green() 48 | ); 49 | 50 | Ok(()) 51 | } 52 | 53 | async fn write( 54 | packages: PackageMap

, 55 | options: &FreezeOptions, 56 | ) -> anyhow::Result 57 | where 58 | Self: Sized + Debug + Serialize, 59 | { 60 | let instance = Self::new(packages); 61 | instance.dump_to_file(options).await?; 62 | Ok(true) 63 | } 64 | } 65 | 66 | pub trait AutoDeserialize: DeserializeOwned { 67 | fn from_json(data: &[u8]) -> anyhow::Result { 68 | serde_json::from_slice(data).map_err(|err| anyhow!(err)) 69 | } 70 | fn from_msgpack(data: &[u8]) -> anyhow::Result { 71 | rmp_serde::decode::from_slice(data).map_err(|err| anyhow!(err)) 72 | } 73 | fn from_toml(data: &[u8]) -> anyhow::Result { 74 | let data_str = String::from_utf8(data.to_owned())?; 75 | toml::from_str(&data_str).map_err(|err| anyhow!(err)) 76 | } 77 | 78 | fn from_format( 79 | data: &[u8], 80 | format: OutputFormat, 81 | ) -> anyhow::Result { 82 | match format { 83 | OutputFormat::JSON => Self::from_json(data), 84 | OutputFormat::TOML => Self::from_toml(data), 85 | OutputFormat::Binary => Self::from_msgpack(data), 86 | } 87 | } 88 | 89 | fn auto(data: &[u8]) -> Option<(Self, OutputFormat)> { 90 | None /* Start with None so the rest or_else are all the same structure */ 91 | .or_else(|| { 92 | Self::from_json(data) 93 | .ok() 94 | .map(|version| (version, OutputFormat::JSON)) 95 | }) 96 | .or_else(|| { 97 | Self::from_msgpack(data) 98 | .ok() 99 | .map(|version| (version, OutputFormat::Binary)) 100 | }) 101 | .or_else(|| { 102 | Self::from_toml(data) 103 | .ok() 104 | .map(|version| (version, OutputFormat::TOML)) 105 | }) 106 | } 107 | } 108 | 109 | fn extract_python_version(input: &str) -> Option { 110 | let Ok(re) = Regex::new(r"(\d+)\.(\d+)") else { 111 | return None; 112 | }; 113 | 114 | re.captures(input).map(|caps| { 115 | let major = &caps[1]; 116 | let minor = &caps[2]; 117 | format!("{major}.{minor}") 118 | }) 119 | } 120 | 121 | impl AutoDeserialize for L {} 122 | -------------------------------------------------------------------------------- /src/venv.rs: -------------------------------------------------------------------------------- 1 | use crate::helpers::{PathToString, set_env_var}; 2 | use crate::metadata::venv_path; 3 | use crate::pip::parse_requirement; 4 | use crate::uv::{uv, uv_venv}; 5 | use anyhow::{Context, bail}; 6 | use owo_colors::OwoColorize; 7 | use std::path::{Path, PathBuf}; 8 | use uv_normalize::PackageName; 9 | use uv_pep508::Requirement; 10 | 11 | use uv_python::PythonEnvironment; 12 | 13 | /// Create a new virtualenv via `uv venv` at a Path 14 | pub async fn create_venv_raw( 15 | venv_path: &Path, 16 | python: Option<&str>, 17 | force: bool, 18 | with_pip: bool, 19 | ) -> anyhow::Result<()> { 20 | if !force && venv_path.exists() { 21 | bail!( 22 | "'{}' is already installed.\nUse '{}' to update existing tools or pass '{}' to this command to ignore this message.", 23 | &venv_path.to_str().unwrap_or_default().green(), 24 | "uvenv upgrade".green(), 25 | "--force".blue() 26 | ) 27 | } 28 | 29 | let mut args: Vec<&str> = vec!["venv", venv_path.to_str().unwrap_or_default()]; 30 | 31 | if let Some(py) = python { 32 | args.push("--python"); 33 | args.push(py); 34 | } 35 | if with_pip { 36 | args.push("--seed"); 37 | } 38 | 39 | // using 'uv' via cli might not be ideal, but a lot of work is done in private `venv_impl`: 40 | // https://github.com/astral-sh/uv/blob/main/crates/uv/src/commands/venv.rs#L127 41 | // so using the public api via cli is easiest: 42 | uv(&args).await?; 43 | 44 | Ok(()) 45 | } 46 | 47 | /// Create a new virtualenv from a parsed `PackageName`. 48 | pub async fn create_venv( 49 | package_name: &PackageName, 50 | python: Option<&str>, 51 | force: bool, 52 | with_pip: bool, 53 | custom_prefix: Option, 54 | ) -> anyhow::Result { 55 | let venv_path = custom_prefix.map_or_else( 56 | || venv_path(package_name.as_ref()), 57 | |prefix| PathBuf::from(format!("{prefix}{package_name}")), 58 | ); 59 | 60 | create_venv_raw(&venv_path, python, force, with_pip).await?; 61 | 62 | Ok(venv_path) 63 | } 64 | 65 | /// activate a venv (from Path) by setting the `VIRTUAL_ENV` and loading the `PythonEnvironment`. 66 | pub async fn activate_venv(venv: &Path) -> anyhow::Result { 67 | let venv_str = venv.to_str().unwrap_or_default(); 68 | set_env_var("VIRTUAL_ENV", venv_str); 69 | 70 | uv_venv(None).with_context(|| format!("Could not properly activate venv '{venv_str}'!")) 71 | } 72 | 73 | /// Find the path to an existing venv for an install spec str. 74 | #[expect( 75 | dead_code, 76 | reason = "It can be useful to find a venv for an install spec later." 77 | )] 78 | pub async fn find_venv(install_spec: &str) -> Option { 79 | let (requirement, _) = parse_requirement(install_spec).await.ok()?; 80 | let requirement_name = requirement.name.to_string(); 81 | 82 | Some(venv_path(&requirement_name)) 83 | } 84 | 85 | /// Parse an install spec str into a Requirement and create a new environment for it. 86 | pub async fn setup_environ_from_requirement( 87 | install_spec: &str 88 | ) -> anyhow::Result<(Requirement, PythonEnvironment)> { 89 | let (requirement, _) = parse_requirement(install_spec).await?; 90 | let requirement_name = requirement.name.to_string(); 91 | let venv_dir = venv_path(&requirement_name); 92 | if !venv_dir.exists() { 93 | bail!("No virtualenv for '{}'.", install_spec.green(),); 94 | } 95 | let environ = activate_venv(&venv_dir).await?; 96 | Ok((requirement, environ)) 97 | } 98 | 99 | /// remove a venv directory 100 | pub async fn remove_venv(venv: &PathBuf) -> anyhow::Result<()> { 101 | Ok( 102 | // ? + Ok for anyhow casting 103 | tokio::fs::remove_dir_all(venv).await?, 104 | ) 105 | } 106 | 107 | /// Get the absolute path to a script in a venv. 108 | pub fn venv_script( 109 | venv: &PythonEnvironment, 110 | script: &str, 111 | ) -> String { 112 | let script_path = venv.scripts().join(script); 113 | script_path.to_string() 114 | } 115 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: Build and Upload Wheels 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' # Only run on version tags 7 | workflow_dispatch: 8 | 9 | env: 10 | MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} 11 | UV_SYSTEM_PYTHON: 1 12 | 13 | jobs: 14 | x64-linux-gnu: 15 | runs-on: ubuntu-24.04 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-python@v5 19 | with: 20 | python-version: '3.11' 21 | - uses: astral-sh/setup-uv@v5 22 | - run: uv pip install maturin[zig] 23 | - run: rustup update 24 | - run: maturin build --release --strip --target x86_64-unknown-linux-gnu --zig 25 | - uses: actions/upload-artifact@v4 26 | with: 27 | name: wheels-x64-linux-gnu 28 | path: target/wheels/ 29 | 30 | x64-linux-musl: 31 | runs-on: ubuntu-24.04 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: actions/setup-python@v5 35 | with: 36 | python-version: '3.11' 37 | - uses: astral-sh/setup-uv@v5 38 | - run: uv pip install maturin[zig] 39 | - run: | 40 | rustup target add x86_64-unknown-linux-musl 41 | rustup update 42 | maturin build --release --strip --target x86_64-unknown-linux-musl --zig 43 | - uses: actions/upload-artifact@v4 44 | with: 45 | name: wheels-x64-linux-musl 46 | path: target/wheels/ 47 | 48 | arm64-linux-gnu: 49 | runs-on: ubuntu-24.04-arm 50 | steps: 51 | - uses: actions/checkout@v4 52 | - uses: actions/setup-python@v5 53 | with: 54 | python-version: '3.11' 55 | - uses: astral-sh/setup-uv@v5 56 | - run: uv pip install maturin[zig] 57 | - run: rustup update 58 | - run: maturin build --release --strip --target aarch64-unknown-linux-gnu --zig 59 | - uses: actions/upload-artifact@v4 60 | with: 61 | name: wheels-arm64-linux-gnu 62 | path: target/wheels/ 63 | 64 | arm64-linux-musl: 65 | runs-on: ubuntu-24.04-arm 66 | steps: 67 | - uses: actions/checkout@v4 68 | - uses: actions/setup-python@v5 69 | with: 70 | python-version: '3.11' 71 | - uses: astral-sh/setup-uv@v5 72 | - run: uv pip install maturin[zig] 73 | - run: | 74 | rustup target add aarch64-unknown-linux-musl 75 | rustup update 76 | maturin build --release --strip --target aarch64-unknown-linux-musl --zig 77 | - uses: actions/upload-artifact@v4 78 | with: 79 | name: wheels-arm64-linux-musl 80 | path: target/wheels/ 81 | 82 | arm64-macos: 83 | runs-on: macos-15 84 | steps: 85 | - uses: actions/checkout@v4 86 | - uses: actions/setup-python@v5 87 | with: 88 | python-version: '3.11' 89 | - uses: astral-sh/setup-uv@v5 90 | - run: uv pip install maturin 91 | - run: rustup update 92 | - run: maturin build --release --strip --target aarch64-apple-darwin 93 | - uses: actions/upload-artifact@v4 94 | with: 95 | name: wheels-arm64-macos 96 | path: target/wheels/ 97 | 98 | sdist-and-upload: 99 | runs-on: ubuntu-24.04 100 | needs: 101 | - x64-linux-gnu 102 | - x64-linux-musl 103 | - arm64-linux-gnu 104 | - arm64-linux-musl 105 | - arm64-macos 106 | steps: 107 | - uses: actions/checkout@v4 108 | - uses: actions/setup-python@v5 109 | with: 110 | python-version: '3.11' 111 | - uses: astral-sh/setup-uv@v5 112 | - run: uv pip install maturin 113 | 114 | - name: Build sdist 115 | run: maturin sdist 116 | 117 | - name: Download all wheel artifacts 118 | uses: actions/download-artifact@v4 119 | with: 120 | path: artifacts 121 | 122 | - name: Collect wheels 123 | run: | 124 | mkdir -p target/wheels 125 | find artifacts -name '*.whl' -exec cp {} target/wheels/ \; 126 | cp target/*.tar.gz target/wheels/ || true 127 | 128 | - name: Upload to PyPI 129 | run: maturin upload --skip-existing -u __token__ target/wheels/* 130 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Advanced Installation Options 2 | 3 | Explore multiple alternative ways to install `uvenv` on systems where global pip installs are restricted (e.g., Ubuntu 24.04+). 4 | Each method offers a different approach, with its own benefits and setup steps. 5 | 6 | --- 7 | 8 | ## 1. via `install.sh` 9 | 10 | The easiest way to install `uvenv` is to use the [`install.sh`](https://github.com/robinvandernoord/uvenv/blob/uvenv/install.sh) script. 11 | 12 | **Advantages:** 13 | 14 | * One-liner installation. 15 | * Automatically fetches and installs the latest version. 16 | * Compatible with various shells (`bash`, `sh`, `zsh`, etc.). 17 | 18 | **Considerations:** 19 | 20 | * Executes a remote script directly; review it if you have security concerns. 21 | 22 | **Installation Steps:** 23 | 24 | ```bash 25 | # download/read the script: 26 | curl -fsSL https://raw.githubusercontent.com/robinvandernoord/uvenv/uvenv/install.sh 27 | 28 | # run it: 29 | bash -c "$(curl -fsSL https://raw.githubusercontent.com/robinvandernoord/uvenv/uvenv/install.sh)" 30 | # instead of `bash`, you can also use `sh`, `zsh`, or "$SHELL" 31 | ``` 32 | 33 | --- 34 | 35 | ## 2. via uv 36 | 37 | If you already have [`uv`](https://github.com/astral-sh/uv) installed, you can use it to install `uvenv` as a managed tool. 38 | 39 | **Advantages:** 40 | 41 | * Isolated tool management via `uv`. 42 | * Simplifies updates and uninstalls. 43 | * No impact on system Python packages. 44 | 45 | **Considerations:** 46 | 47 | * Requires `uv` to be installed beforehand. 48 | 49 | **Installation Steps:** 50 | 51 | ```bash 52 | uv tool install uvenv 53 | ``` 54 | 55 | --- 56 | 57 | ## 3. System Package Method 58 | 59 | Install `uvenv` using `pip` with the `--break-system-packages` flag. 60 | 61 | **Advantages:** 62 | 63 | * Quick, no extra tooling required. 64 | * Easy to use on minimal systems. 65 | 66 | **Considerations:** 67 | 68 | * Minor risk of package conflicts, though unlikely with `uvenv`. 69 | 70 | **Installation Steps:** 71 | 72 | ```bash 73 | pip install --break-system-packages uvenv 74 | ``` 75 | 76 | --- 77 | 78 | ## 4. Pipx Installation Method 79 | 80 | Use `pipx` to manage `uvenv` in an isolated environment. 81 | 82 | **Advantages:** 83 | 84 | * Keeps `uvenv` isolated from system packages. 85 | * Easily updatable and removable. 86 | 87 | **Considerations:** 88 | 89 | * Requires `pipx` to be installed (`sudo apt install pipx` or equivalent). 90 | 91 | **Installation Steps:** 92 | 93 | ```bash 94 | pipx install uvenv 95 | ``` 96 | 97 | --- 98 | 99 | ## 5. Virtual Environment Method 100 | 101 | Create a dedicated Python virtual environment and install `uvenv` inside it. 102 | 103 | **Advantages:** 104 | 105 | * Complete isolation from system Python. 106 | * Suitable for users comfortable with virtual environments. 107 | 108 | **Considerations:** 109 | 110 | * Requires familiarity with `venv` and virtual environments. 111 | * Needs activation each time or linking via `uvenv self link`. 112 | 113 | **Installation Steps:** 114 | 115 | ```bash 116 | python -m venv ~/.virtualenvs/uvenv 117 | source ~/.virtualenvs/uvenv/bin/activate 118 | pip install uvenv 119 | uvenv self link # or `uvenv setup` for full integration 120 | ``` 121 | 122 | --- 123 | 124 | ## 6. Self-Managed uvenv Method 125 | 126 | Use `uvenv` to manage and update its own installation. 127 | 128 | **Advantages:** 129 | 130 | * Allows `uvenv` to bootstrap and maintain itself. 131 | * Streamlines long-term tool management. 132 | 133 | **Considerations:** 134 | 135 | * Requires initial manual setup. 136 | * Commands like `uvenv uninstall-all` may remove itself—use with care. 137 | 138 | **Installation Steps:** 139 | 140 | ```bash 141 | python -m venv /tmp/initial-uvenv 142 | source /tmp/initial-uvenv/bin/activate 143 | pip install uvenv 144 | uvenv install uvenv 145 | uvenv ensurepath # or `uvenv setup` for all features 146 | ``` 147 | 148 | --- 149 | 150 | ## 7. via Snap 151 | 152 | Snap installation is also supported. 153 | 154 | **Advantages:** 155 | 156 | * Easy to install and manage on Snap-enabled systems. 157 | * Clean separation from system packages. 158 | 159 | **Considerations:** 160 | 161 | * Snap-specific behavior and confinement apply. 162 | * See [snap installation](./snap.md) for full instructions and caveats. 163 | -------------------------------------------------------------------------------- /src/commands/setup.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::{Process, SetupOptions}; 2 | use crate::commands::activate::install_activate; 3 | use crate::commands::completions::completions; 4 | use crate::commands::ensurepath::ensure_path; 5 | use crate::commands::self_link::self_link; 6 | use crate::helpers::fmt_error; 7 | use crate::metadata::{get_work_dir, load_generic_msgpack, store_generic_msgpack}; 8 | use crate::shell::{SupportedShell, run_if_supported_shell_else_warn_async}; 9 | use anyhow::bail; 10 | use owo_colors::OwoColorize; 11 | use serde::{Deserialize, Serialize}; 12 | use std::path::PathBuf; 13 | 14 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)] 15 | pub struct SetupMetadata { 16 | // order is important, new features should go last!! 17 | #[serde(default)] 18 | pub feature_ensurepath: bool, 19 | #[serde(default)] 20 | pub feature_completions: bool, 21 | #[serde(default)] 22 | pub feature_activate: bool, 23 | } 24 | 25 | impl SetupMetadata { 26 | pub const fn new() -> Self { 27 | Self { 28 | feature_ensurepath: false, 29 | feature_completions: false, 30 | feature_activate: false, 31 | } 32 | } 33 | } 34 | 35 | fn setup_metadata_filename() -> PathBuf { 36 | let workdir = get_work_dir(); 37 | 38 | workdir.join("setup.metadata") 39 | } 40 | 41 | async fn maybe_load_setup_metadata() -> anyhow::Result { 42 | let filename = setup_metadata_filename(); 43 | 44 | let mut buf = Vec::new(); // allocate memory for the object 45 | 46 | // Open the msgpack file 47 | let metadata: SetupMetadata = load_generic_msgpack(&filename, &mut buf).await?; 48 | 49 | Ok(metadata) 50 | } 51 | 52 | pub async fn load_setup_metadata() -> SetupMetadata { 53 | maybe_load_setup_metadata() 54 | .await 55 | .unwrap_or_else(|_| SetupMetadata::new()) 56 | } 57 | 58 | pub async fn store_setup_metadata(metadata: &SetupMetadata) -> anyhow::Result<()> { 59 | let filename = setup_metadata_filename(); 60 | 61 | store_generic_msgpack(&filename, metadata).await 62 | } 63 | 64 | pub async fn setup_for_shell( 65 | do_ensurepath: bool, 66 | do_completions: bool, 67 | do_activate: bool, 68 | force: bool, 69 | ) -> anyhow::Result { 70 | let mut any_warnings = false; 71 | let shell = SupportedShell::detect(); 72 | if !shell.is_supported() { 73 | bail!("Unsupported shell {}!", shell.name()); 74 | } 75 | 76 | let mut metadata = load_setup_metadata().await; 77 | 78 | if do_ensurepath && (!metadata.feature_ensurepath || force) { 79 | if let Err(msg) = ensure_path(force).await { 80 | any_warnings = true; 81 | eprintln!("{}", fmt_error(&msg)); 82 | } 83 | metadata.feature_ensurepath = true; 84 | } 85 | 86 | if do_completions && (!metadata.feature_completions || force) { 87 | if let Err(msg) = completions(true).await { 88 | any_warnings = true; 89 | eprintln!("{}", fmt_error(&msg)); 90 | } 91 | metadata.feature_completions = true; 92 | } 93 | 94 | if do_activate && (!metadata.feature_activate || force) { 95 | if let Err(msg) = install_activate().await { 96 | any_warnings = true; 97 | eprintln!("{}", fmt_error(&msg)); 98 | } 99 | metadata.feature_activate = true; 100 | } 101 | 102 | if let Err(msg) = store_setup_metadata(&metadata).await { 103 | any_warnings = true; 104 | eprintln!("{}", fmt_error(&msg)); 105 | } 106 | 107 | // ignore result/output: 108 | let _ = self_link(false, true).await; 109 | 110 | println!( 111 | "Setup finished, you may want to run `{}` now in order to apply these changes to your shell.", 112 | format!("exec {}", shell.name()).green() 113 | ); 114 | // bool to int 115 | Ok(i32::from(any_warnings)) 116 | } 117 | 118 | impl Process for SetupOptions { 119 | async fn process(self) -> anyhow::Result { 120 | let result: Option = run_if_supported_shell_else_warn_async(async |_| { 121 | setup_for_shell( 122 | !self.skip_ensurepath, 123 | !self.skip_completions, 124 | !self.skip_activate, 125 | self.force, 126 | ) 127 | .await 128 | .ok() 129 | }) 130 | .await; 131 | 132 | Ok(result.unwrap_or(126)) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/commands/list.rs: -------------------------------------------------------------------------------- 1 | use futures::future; 2 | use itertools::Itertools; 3 | use owo_colors::OwoColorize; 4 | use std::fs::ReadDir; 5 | 6 | use crate::cli::{ListOptions, Process}; 7 | use crate::commands::self_info::{CURRENT_UVENV_VERSION, is_latest}; 8 | use crate::metadata::{LoadMetadataConfig, Metadata, get_venv_dir}; 9 | use crate::pypi::get_latest_version; 10 | use crate::uv::uv_search_python; 11 | 12 | async fn read_from_folder_filtered( 13 | metadata_dir: ReadDir, 14 | config: &LoadMetadataConfig, 15 | filter_names: &[String], 16 | ) -> Vec { 17 | let (cap, _) = metadata_dir.size_hint(); // estimate size 18 | let mut promises = Vec::with_capacity(cap); 19 | 20 | for dir in metadata_dir.flatten() { 21 | let venv_name = dir.file_name().into_string().unwrap_or_default(); 22 | 23 | if !filter_names.is_empty() && !filter_names.contains(&venv_name) { 24 | continue; 25 | } 26 | 27 | promises.push( 28 | Metadata::for_owned_dir(dir.path(), config), // no .await so its a future (requires ownership of dir_path) 29 | ); 30 | } 31 | 32 | future::join_all(promises) 33 | .await 34 | .into_iter() 35 | .flatten() 36 | .sorted() 37 | .collect() 38 | } 39 | 40 | impl ListOptions { 41 | pub fn process_json( 42 | self, 43 | items: &[Metadata], 44 | ) -> anyhow::Result { 45 | let json = if self.short { 46 | serde_json::to_string(items)? 47 | } else { 48 | serde_json::to_string_pretty(items)? 49 | }; 50 | 51 | println!("{json}"); 52 | 53 | Ok(0) 54 | } 55 | 56 | pub const fn to_metadataconfig(&self) -> LoadMetadataConfig { 57 | LoadMetadataConfig { 58 | recheck_scripts: true, // always done 59 | updates_check: !self.skip_updates, 60 | updates_prereleases: self.show_prereleases, 61 | updates_ignore_constraints: self.ignore_constraints, 62 | } 63 | } 64 | } 65 | 66 | pub async fn is_uvenv_outdated(silent: bool) -> bool { 67 | if cfg!(feature = "snap") { 68 | // updates managed by snapcraft 69 | return false; 70 | } 71 | 72 | let latest = get_latest_version("uvenv", true, None).await; 73 | 74 | // uvenv version comes from Cargo.toml 75 | let is_outdated = !is_latest(CURRENT_UVENV_VERSION, latest.as_ref()); 76 | 77 | if is_outdated 78 | && !silent 79 | && let Some(latest_version) = latest 80 | { 81 | eprintln!( 82 | "{} ({} < {})", 83 | "uvenv is outdated!".yellow(), 84 | CURRENT_UVENV_VERSION.red(), 85 | latest_version.to_string().green() 86 | ); 87 | } 88 | 89 | is_outdated 90 | } 91 | 92 | pub async fn list_packages( 93 | config: &LoadMetadataConfig, 94 | filter_names: Option<&[String]>, 95 | python: Option<&str>, 96 | ) -> anyhow::Result> { 97 | let venv_dir_path = get_venv_dir(); 98 | 99 | // no tokio::fs because ReadDir.flatten doesn't exist then. 100 | let must_exist = if let Ok(dir) = std::fs::read_dir(&venv_dir_path) { 101 | dir 102 | } else { 103 | std::fs::create_dir_all(&venv_dir_path)?; 104 | std::fs::read_dir(&venv_dir_path)? 105 | }; 106 | 107 | let mut results = 108 | read_from_folder_filtered(must_exist, config, filter_names.unwrap_or(&[])).await; 109 | 110 | if let Some(python_filter) = uv_search_python(python).await { 111 | results.retain(|meta| meta.python_raw == python_filter); 112 | } 113 | Ok(results) 114 | } 115 | 116 | impl Process for ListOptions { 117 | async fn process(self) -> anyhow::Result { 118 | if self.venv_names.is_empty() { 119 | // don't show uvenv version warning if package names were supplied 120 | is_uvenv_outdated(false).await; 121 | } 122 | 123 | let config = self.to_metadataconfig(); 124 | 125 | let items = list_packages(&config, Some(&self.venv_names), self.python.as_deref()).await?; 126 | 127 | if self.json { 128 | return self.process_json(&items); 129 | } 130 | 131 | for metadata in items { 132 | if self.verbose { 133 | // println!("{}", dbg_pls::color(&metadata)); 134 | print!("{}", &metadata.format_debug()); 135 | } else if self.short { 136 | print!("{}", &metadata.format_short()); 137 | } else { 138 | print!("{}", &metadata.format_human()?); 139 | } 140 | } 141 | 142 | Ok(0) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/commands/self_changelog.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::{Process, SelfChangelogOptions}; 2 | use anyhow::{Context, anyhow}; 3 | use owo_colors::OwoColorize; 4 | use regex::Regex; 5 | use std::collections::BTreeMap; 6 | 7 | const CHANGELOG_URL: &str = 8 | "https://raw.githubusercontent.com/robinvandernoord/uvenv/uvenv/CHANGELOG.md"; 9 | 10 | type Changelogs = BTreeMap>>; 11 | 12 | fn parse_changelog(markdown: &str) -> Changelogs { 13 | // BTreeMap is like a HashMap but ordered 14 | let mut changelog: BTreeMap>> = BTreeMap::new(); 15 | let mut current_version = ""; 16 | let mut current_category = ""; 17 | 18 | let version_re = Regex::new("^## v?(.+)").expect("Invalid regex for version"); 19 | let category_re = Regex::new("^### (.+)").expect("Invalid regex for category"); 20 | let feature_re = Regex::new("^[*-] (.+)").expect("Invalid regex for feature"); 21 | 22 | for line in markdown.lines() { 23 | if line.starts_with("# Changelog") { 24 | continue; 25 | } 26 | 27 | if let Some(version_caps) = version_re.captures(line) { 28 | current_version = version_caps 29 | .get(1) 30 | .map_or("", |version_match| version_match.as_str()); 31 | changelog.entry(current_version.to_owned()).or_default(); 32 | current_category = ""; // Reset current category for a new version 33 | continue; 34 | } 35 | 36 | if let Some(category_caps) = category_re.captures(line) { 37 | current_category = category_caps 38 | .get(1) 39 | .map_or("", |cat_match| cat_match.as_str()); 40 | if let Some(map) = changelog.get_mut(current_version) { 41 | map.entry(current_category.to_owned()).or_default(); 42 | } 43 | continue; 44 | } 45 | 46 | if let Some(feature_caps) = feature_re.captures(line) { 47 | let features = feature_caps 48 | .get(1) 49 | .map_or("", |feat_match| feat_match.as_str()); 50 | if let Some(map) = changelog.get_mut(current_version) 51 | && let Some(vec) = map.get_mut(current_category) 52 | { 53 | vec.push(features.to_owned()); 54 | } 55 | } 56 | } 57 | 58 | changelog 59 | } 60 | async fn get_changelog_internal() -> reqwest::Result { 61 | let resp = reqwest::get(CHANGELOG_URL).await?.error_for_status()?; 62 | 63 | let body = resp.text().await?; 64 | 65 | Ok(body) 66 | } 67 | 68 | pub async fn get_changelog() -> anyhow::Result { 69 | get_changelog_internal().await.map_err(|err| anyhow!(err)) // reqwest to anyhow 70 | } 71 | 72 | fn color(category: &str) -> String { 73 | match category.to_lowercase().trim() { 74 | "fix" | "fixes" => category.yellow().to_string(), 75 | "feature" | "feat" | "features" => category.green().to_string(), 76 | "documentation" | "docs" => category.blue().to_string(), 77 | "breaking change" | "break" => category.red().to_string(), 78 | _ => category.white().to_string(), 79 | } 80 | } 81 | 82 | fn colored_markdown(md: &str) -> String { 83 | let bold_re = Regex::new(r"(\*\*.*?\*\*)").expect("Failed to create bold regex"); 84 | let mut final_string = String::new(); 85 | 86 | for part in bold_re.split(md) { 87 | if part.starts_with("**") && part.ends_with("**") { 88 | let bold_text = part.trim_start_matches("**").trim_end_matches("**"); 89 | final_string.push_str(&bold_text.bold().to_string()); 90 | } else { 91 | final_string.push_str(part); 92 | } 93 | } 94 | 95 | final_string 96 | } 97 | 98 | pub fn display_changelog(changelog: &Changelogs) { 99 | for (version, changes) in changelog { 100 | println!("- {}", version.bold()); 101 | for (category, descriptions) in changes { 102 | println!("-- {}", color(category)); 103 | for change in descriptions { 104 | println!("---- {}", colored_markdown(change)); 105 | } 106 | } 107 | } 108 | } 109 | 110 | pub async fn changelog() -> anyhow::Result { 111 | let md = get_changelog().await?; 112 | let parsed = parse_changelog(&md); 113 | 114 | display_changelog(&parsed); 115 | 116 | Ok(0) 117 | } 118 | 119 | impl Process for SelfChangelogOptions { 120 | async fn process(self) -> anyhow::Result { 121 | changelog() 122 | .await 123 | .with_context(|| "Something went wrong while loading the changelog;") 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/commands/run.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, bail}; 2 | use owo_colors::OwoColorize; 3 | use std::path::{Path, PathBuf}; 4 | use uv_pep508::Requirement; 5 | 6 | use uv_python::PythonEnvironment; 7 | 8 | use crate::cli::{Process, RunOptions}; 9 | use crate::commands::install::uv_install_package; 10 | use crate::commands::runpython::process_subprocess; 11 | use crate::helpers::PathAsStr; 12 | use crate::pip::parse_requirement; 13 | use crate::symlinks::find_symlinks; 14 | use crate::uv::uv_get_installed_version; 15 | use crate::venv::{activate_venv, create_venv, remove_venv}; 16 | use core::fmt::Write; 17 | 18 | async fn find_executable_raw( 19 | requirement: &Requirement, 20 | package_spec: &str, 21 | venv: &PythonEnvironment, 22 | ) -> anyhow::Result { 23 | let installed_version = uv_get_installed_version(&requirement.name, Some(venv))?; 24 | let mut symlinks = find_symlinks(requirement, &installed_version, venv).await; 25 | 26 | match symlinks.len() { 27 | 0 => { 28 | // just return the original name just as a last hope: 29 | Ok(requirement.name.to_string()) 30 | }, 31 | 1 => Ok(symlinks 32 | .pop() 33 | .expect("Popping should always work if len == 1!")), 34 | _ => { 35 | // too many choices, user should provide --binary 36 | let mut related = String::new(); 37 | 38 | for option in symlinks { 39 | if package_spec == option { 40 | // exact match -> probably what you want! 41 | return Ok(option); 42 | } 43 | 44 | let code = format!("uvenv run {package_spec} --binary {option} ..."); 45 | // related.push_str(&format!("\t- {} | `{}` \n", option.green(), code.blue())); 46 | writeln!(related, "\t- {} | `{}` ", option.green(), code.blue())?; 47 | } 48 | 49 | bail!( 50 | "'{}' executable not found for install spec '{}'.\nMultiple related scripts were found:\n{}", 51 | requirement.name.to_string().green(), 52 | package_spec.green(), 53 | related, 54 | ) 55 | }, 56 | } 57 | } 58 | 59 | pub async fn find_executable( 60 | requirement: &Requirement, 61 | binary: Option<&str>, 62 | package_spec: &str, 63 | venv: &PythonEnvironment, 64 | venv_path: &Path, 65 | ) -> anyhow::Result { 66 | let executable = match binary { 67 | Some(executable) => executable.to_owned(), 68 | None => find_executable_raw(requirement, package_spec, venv).await?, 69 | }; 70 | 71 | let full_exec_path = venv_path.join("bin").join(executable); 72 | Ok(full_exec_path) 73 | } 74 | 75 | pub async fn run_executable( 76 | requirement: &Requirement, 77 | binary: Option<&str>, 78 | package_spec: &str, 79 | venv: &PythonEnvironment, 80 | venv_path: &Path, 81 | args: &[String], 82 | ) -> anyhow::Result { 83 | let full_exec_path = 84 | find_executable(requirement, binary, package_spec, venv, venv_path).await?; 85 | 86 | process_subprocess(full_exec_path.as_path(), args) 87 | } 88 | pub async fn run_package>( 89 | package_spec: &str, 90 | python: Option<&str>, 91 | keep: bool, 92 | no_cache: bool, 93 | binary: Option<&str>, 94 | inject: &[S], 95 | args: &[String], 96 | ) -> anyhow::Result { 97 | // 1. create a temp venv 98 | // 2. install package 99 | // 3. run 'binary' or find runnable(s) in package 100 | // 4. if not 'keep': remove temp venv 101 | 102 | // ### 1 ### 103 | 104 | let (requirement, _) = parse_requirement(package_spec).await?; 105 | 106 | let venv_path = &create_venv( 107 | &requirement.name, 108 | python, 109 | true, 110 | true, 111 | Some(String::from("/tmp/uvenv-")), 112 | ) 113 | .await?; 114 | 115 | let venv_name = &venv_path.as_str(); 116 | 117 | if keep { 118 | eprintln!("ℹ️ Using virtualenv {}", venv_name.blue()); 119 | } 120 | 121 | // ### 2 ### 122 | let venv = &activate_venv(venv_path).await?; 123 | 124 | // already expects activated venv: 125 | uv_install_package(package_spec, inject, no_cache, false, false).await?; 126 | 127 | // ### 3 ### 128 | let result = run_executable(&requirement, binary, package_spec, venv, venv_path, args).await; 129 | 130 | // ### 4 ### 131 | 132 | if !keep { 133 | // defer! not possible here because of await 134 | remove_venv(venv_path).await?; 135 | } 136 | 137 | result 138 | } 139 | 140 | impl Process for RunOptions { 141 | async fn process(self) -> anyhow::Result { 142 | run_package( 143 | &self.package_name, 144 | self.python.as_deref(), 145 | self.keep, 146 | self.no_cache, 147 | self.binary.as_deref(), 148 | &self.with, 149 | &self.args, 150 | ) 151 | .await 152 | .with_context(|| { 153 | format!( 154 | "Something went wrong while trying to run '{}';", 155 | &self.package_name 156 | ) 157 | }) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/pip.rs: -------------------------------------------------------------------------------- 1 | use crate::animate::{AnimationSettings, show_loading_indicator}; 2 | use crate::cmd::{run, run_get_output}; 3 | use anyhow::bail; 4 | use core::cmp::Ordering; 5 | use core::str::FromStr; 6 | use serde::{Deserialize, Serialize}; 7 | use std::collections::VecDeque; 8 | use std::path::Path; 9 | use tempfile::NamedTempFile; 10 | use uv_pep508::Requirement; 11 | 12 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] 13 | #[expect(clippy::partial_pub_fields, reason = "Only the url should be public.")] 14 | pub struct PipDownloadInfo { 15 | pub url: String, 16 | dir_info: serde_json::Value, // Since dir_info is an empty object, we can use serde_json::Value here 17 | } 18 | 19 | impl PartialOrd for PipDownloadInfo { 20 | fn partial_cmp( 21 | &self, 22 | other: &Self, 23 | ) -> Option { 24 | Some(String::cmp(&self.url, &other.url)) 25 | } 26 | } 27 | 28 | impl Ord for PipDownloadInfo { 29 | fn cmp( 30 | &self, 31 | other: &Self, 32 | ) -> Ordering { 33 | String::cmp(&self.url, &other.url) 34 | } 35 | } 36 | 37 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)] 38 | pub struct PipMetadata { 39 | pub metadata_version: String, 40 | pub name: String, 41 | pub version: String, 42 | pub classifier: Vec, 43 | pub requires_dist: Vec, 44 | pub requires_python: String, 45 | } 46 | 47 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)] 48 | pub struct PipInstallItem { 49 | pub download_info: PipDownloadInfo, 50 | pub is_direct: bool, 51 | pub is_yanked: bool, 52 | pub requested: bool, 53 | pub requested_extras: Option>, 54 | pub metadata: PipMetadata, 55 | } 56 | 57 | // #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)] 58 | // struct Environment { 59 | // implementation_name: String, 60 | // implementation_version: String, 61 | // os_name: String, 62 | // platform_machine: String, 63 | // platform_release: String, 64 | // platform_system: String, 65 | // platform_version: String, 66 | // python_full_version: String, 67 | // platform_python_implementation: String, 68 | // python_version: String, 69 | // sys_platform: String, 70 | // } 71 | 72 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)] 73 | pub struct PipData { 74 | pub version: String, 75 | pub pip_version: String, 76 | // environment: Environment, 77 | pub install: VecDeque, // we want to get .first() but owned -> pop_first 78 | } 79 | 80 | pub async fn pip(args: &[&str]) -> anyhow::Result { 81 | // unwrap_or_default doesn't work on &&str :( 82 | let err_prefix = format!("pip {}", args.first().unwrap_or(&"")); 83 | 84 | run("pip", args, Some(err_prefix)).await 85 | } 86 | 87 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)] 88 | pub struct FakeInstallResult { 89 | pub name: String, 90 | pub file_url: String, 91 | } 92 | 93 | impl FakeInstallResult { 94 | pub fn to_spec(&self) -> String { 95 | format!("{} @ {}", self.name, self.file_url) 96 | } 97 | } 98 | 99 | pub async fn fake_install(install_spec: &str) -> anyhow::Result { 100 | let tempfile = NamedTempFile::new()?; 101 | let Some(tempfile_path) = tempfile.as_ref().to_str() else { 102 | bail!("No temp file could be created for a dry pip install.",) 103 | }; 104 | 105 | // array instead of vec: 106 | let args = [ 107 | "install", 108 | "--no-deps", 109 | "--dry-run", 110 | "--ignore-installed", 111 | "--report", 112 | tempfile_path, 113 | install_spec, 114 | ]; 115 | 116 | pip(&args).await?; 117 | 118 | let json_file = tempfile.reopen()?; 119 | 120 | let mut pip_data: PipData = serde_json::from_reader(json_file)?; 121 | 122 | let Some(install) = pip_data.install.pop_front() else { 123 | bail!("Failed to find package name for local install.",) 124 | }; 125 | 126 | // if extras exist, the full name is name[extras]. Otherwise, it's just the name. 127 | let name = install.requested_extras.as_ref().map_or_else( 128 | || String::from(&install.metadata.name), 129 | |extras| format!("{}[{}]", &install.metadata.name, extras.join(",")), 130 | ); 131 | 132 | let PipDownloadInfo { url: file_url, .. } = install.download_info; 133 | 134 | Ok(FakeInstallResult { name, file_url }) 135 | } 136 | 137 | pub async fn try_parse_local_requirement( 138 | install_spec: &str 139 | ) -> anyhow::Result<(Requirement, String)> { 140 | // fake install and extract the relevant info 141 | let promise = fake_install(install_spec); 142 | 143 | let result = show_loading_indicator( 144 | promise, 145 | format!("Trying to install local package {install_spec}"), 146 | AnimationSettings::default(), 147 | ) 148 | .await?; 149 | 150 | let new_install_spec = result.to_spec(); 151 | let requirement = Requirement::from_str(&new_install_spec)?; 152 | 153 | Ok((requirement, new_install_spec)) 154 | } 155 | 156 | pub async fn parse_requirement(install_spec: &str) -> anyhow::Result<(Requirement, String)> { 157 | match Requirement::from_str(install_spec) { 158 | Ok(requirement) => Ok((requirement, String::from(install_spec))), 159 | Err(_) => try_parse_local_requirement(install_spec).await, 160 | } 161 | } 162 | 163 | pub async fn pip_freeze(python: &Path) -> anyhow::Result { 164 | // uv pip freeze doesn't work for system 165 | run_get_output(python, &["-m", "pip", "freeze"]).await 166 | } 167 | -------------------------------------------------------------------------------- /src/symlinks.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, anyhow, bail}; 2 | use std::path::{Path, PathBuf}; 3 | 4 | use uv_normalize::PackageName; 5 | use uv_pep508::Requirement; 6 | use uv_python::PythonEnvironment; 7 | 8 | use crate::helpers::PathAsStr; 9 | use crate::metadata::ensure_bin_dir; 10 | use configparser::ini::Ini; 11 | use uv_distribution_types::Name; 12 | use uv_installer::SitePackages; 13 | use uv_tool::entrypoint_paths; 14 | 15 | pub async fn console_scripts(entry_points_path: &str) -> anyhow::Result> { 16 | let Ok(ini) = tokio::fs::read_to_string(entry_points_path).await else { 17 | return Ok(Vec::new()); // file missing = empty list 18 | }; 19 | 20 | let entry_points_mapping = Ini::new_cs() 21 | .read(ini) 22 | .map_err(|err| anyhow!("entry_points.txt is invalid: {err}"))?; 23 | 24 | let Some(console_scripts) = entry_points_mapping.get("console_scripts") else { 25 | return Ok(Vec::new()); 26 | }; 27 | 28 | // extract script keys 29 | Ok(console_scripts.keys().map(ToString::to_string).collect()) 30 | } 31 | 32 | /// Source: `https://github.com/astral-sh/uv/blob/ee2bdc21fab077aaef17c94242b4cf6a10c013e1/crates/uv/src/commands/tool/run.rs#L281` 33 | fn get_entrypoints( 34 | from: &PackageName, 35 | site_packages: &SitePackages, 36 | ) -> anyhow::Result> { 37 | let installed = site_packages.get_packages(from); 38 | let Some(installed_dist) = installed.first().copied() else { 39 | bail!("Expected at least one requirement") 40 | }; 41 | 42 | Ok(entrypoint_paths( 43 | site_packages, 44 | installed_dist.name(), 45 | installed_dist.version(), 46 | )?) 47 | } 48 | 49 | fn find_symlinks_uv( 50 | requirement: &Requirement, 51 | venv: &PythonEnvironment, 52 | ) -> anyhow::Result> { 53 | let site_packages = SitePackages::from_environment(venv)?; 54 | let entrypoints = get_entrypoints(&requirement.name, &site_packages)?; 55 | 56 | Ok(entrypoints 57 | .iter() 58 | .map(|(name, _path)| name.to_owned()) 59 | .collect()) 60 | } 61 | 62 | #[expect(clippy::shadow_unrelated, reason = "Both `scripts` are related.")] 63 | pub async fn find_symlinks( 64 | requirement: &Requirement, 65 | installed_version: &str, 66 | venv: &PythonEnvironment, 67 | ) -> Vec { 68 | let dist_info_fname = format!( 69 | "{}-{}.dist-info", 70 | requirement.name.as_dist_info_name(), 71 | installed_version 72 | ); 73 | 74 | // uv: 75 | let scripts = find_symlinks_uv(requirement, venv).unwrap_or_default(); 76 | 77 | if !scripts.is_empty() { 78 | return scripts; 79 | } 80 | 81 | // fallback: 82 | 83 | let entrypoints_ini = venv 84 | .interpreter() 85 | .purelib() 86 | .join(dist_info_fname) 87 | .join("entry_points.txt"); 88 | let entrypoints_path = entrypoints_ini.as_str(); 89 | 90 | let scripts = console_scripts(entrypoints_path).await.unwrap_or_default(); 91 | 92 | if scripts.is_empty() { 93 | // no scripts found, use requirement name as fallback (e.g. for `uv`) 94 | vec![requirement.name.to_string()] 95 | } else { 96 | scripts 97 | } 98 | } 99 | 100 | pub async fn create_symlink( 101 | symlink: &str, 102 | venv: &Path, 103 | force: bool, 104 | binaries: &[&str], 105 | ) -> anyhow::Result { 106 | let bin_dir = ensure_bin_dir().await; 107 | 108 | if !binaries.is_empty() && !binaries.contains(&symlink) { 109 | return Ok(false); 110 | } 111 | 112 | let target_path = bin_dir.join(symlink); 113 | 114 | if target_path.exists() { 115 | if !force { 116 | bail!( 117 | "Script {symlink} already exists in {}. Use --force to ignore this warning.", 118 | bin_dir.display() 119 | ) 120 | } 121 | 122 | tokio::fs::remove_file(&target_path) 123 | .await 124 | .with_context(|| format!("Failed to create symlink {}", target_path.display()))?; 125 | } 126 | 127 | let symlink_path = venv.join("bin").join(symlink); 128 | if !symlink_path.exists() { 129 | bail!( 130 | "Could not symlink {} because the script didn't exist.", 131 | symlink_path.display() 132 | ); 133 | } 134 | 135 | tokio::fs::symlink(&symlink_path, &target_path) 136 | .await 137 | .with_context(|| format!("Failed to create symlink {}", target_path.display()))?; 138 | 139 | Ok(true) 140 | } 141 | 142 | pub fn is_symlink(symlink_path: &Path) -> bool { 143 | symlink_path 144 | .symlink_metadata() 145 | .map(|metadata| metadata.file_type().is_symlink()) 146 | .unwrap_or(false) 147 | } 148 | 149 | pub fn points_to( 150 | symlink_path: &Path, 151 | target_path: &Path, 152 | ) -> bool { 153 | symlink_path 154 | .read_link() 155 | .ok() 156 | .is_some_and(|link| link.starts_with(target_path)) 157 | } 158 | 159 | pub async fn check_symlink( 160 | symlink: &str, 161 | target_path: &Path, 162 | ) -> bool { 163 | let symlink_path = ensure_bin_dir().await.join(symlink); 164 | 165 | is_symlink(&symlink_path) && points_to(&symlink_path, target_path) 166 | } 167 | 168 | pub async fn remove_symlink(symlink: &str) -> anyhow::Result<()> { 169 | let bin_dir = ensure_bin_dir().await; 170 | let target_path = bin_dir.join(symlink); 171 | 172 | if is_symlink(&target_path) { 173 | tokio::fs::remove_file(&target_path).await?; 174 | } 175 | 176 | Ok(()) 177 | } 178 | 179 | pub async fn remove_symlinks(symlinks: &[String]) -> anyhow::Result<()> { 180 | for symlink in symlinks { 181 | remove_symlink(symlink).await?; 182 | } 183 | 184 | Ok(()) 185 | } 186 | -------------------------------------------------------------------------------- /src/shell.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::ensurepath::{append, now}; 2 | use crate::helpers::Touch; 3 | use crate::macros::iterable_enum_macro::iterable_enum; 4 | use crate::metadata::get_home_dir; 5 | use anyhow::{Context, anyhow, bail}; 6 | use core::fmt::{Display, Formatter, Write}; 7 | use owo_colors::OwoColorize; 8 | use std::env; 9 | 10 | iterable_enum! { 11 | #[derive(Debug)] 12 | pub enum SupportedShell { 13 | Bash, 14 | Zsh, 15 | Unsupported, 16 | } 17 | } 18 | 19 | impl SupportedShell { 20 | /// Detect the current shell based on the SHELL environment variable. 21 | pub fn detect() -> Self { 22 | let shell = env::var("SHELL").ok().unwrap_or_default(); 23 | if shell.ends_with("bash") { 24 | Self::Bash 25 | } else if shell.ends_with("zsh") { 26 | Self::Zsh 27 | } else { 28 | Self::Unsupported 29 | } 30 | } 31 | 32 | /// Return the shell name. 33 | pub const fn name(&self) -> &'static str { 34 | match self { 35 | Self::Bash => "bash", 36 | Self::Zsh => "zsh", 37 | Self::Unsupported => "", 38 | } 39 | } 40 | 41 | pub const fn activation_script(&self) -> &'static str { 42 | match self { 43 | Self::Bash => { 44 | include_str!("./shells/bash.sh") 45 | }, 46 | Self::Zsh => { 47 | include_str!("./shells/zsh.sh") 48 | }, 49 | Self::Unsupported => "", 50 | } 51 | } 52 | 53 | /// Check if the shell is supported. 54 | pub const fn is_supported(&self) -> bool { 55 | !matches!(self, Self::Unsupported) 56 | } 57 | 58 | /// Get the appropriate rc file for each shell. 59 | pub const fn rc_file(&self) -> Option<&'static str> { 60 | match self { 61 | Self::Bash => Some(".bashrc"), 62 | Self::Zsh => Some(".zshrc"), 63 | Self::Unsupported => None, 64 | } 65 | } 66 | /// Add a modification to the appropriate rc file. 67 | pub async fn add_to_rcfile( 68 | &self, 69 | text: &str, 70 | with_comment: bool, 71 | ) -> anyhow::Result<()> { 72 | if let Some(rc_file) = self.rc_file() { 73 | add_to_rcfile(text, with_comment, rc_file).await 74 | } else { 75 | Err(anyhow!("Unsupported shell")).with_context(|| String::from(self.name())) 76 | } 77 | } 78 | 79 | /// Add a path modification to the appropriate rc file. 80 | pub async fn add_to_path( 81 | &self, 82 | path_to_add: &str, 83 | with_comment: bool, 84 | ) -> anyhow::Result<()> { 85 | let export_line = format!(r#"export PATH="$PATH:{path_to_add}""#); 86 | self.add_to_rcfile(&export_line, with_comment).await 87 | } 88 | 89 | pub fn list_options() -> Vec<&'static str> { 90 | Self::into_iter() 91 | .filter(Self::is_supported) 92 | .map(|it| it.name()) 93 | .collect() 94 | } 95 | 96 | pub fn list_options_formatted() -> String { 97 | Self::list_options().join(" | ") 98 | } 99 | } 100 | 101 | impl Display for SupportedShell { 102 | fn fmt( 103 | &self, 104 | f: &mut Formatter<'_>, 105 | ) -> core::fmt::Result { 106 | write!(f, "{}", self.name()) 107 | } 108 | } 109 | 110 | pub async fn add_to_rcfile( 111 | text: &str, 112 | with_comment: bool, 113 | filename: &str, 114 | ) -> anyhow::Result<()> { 115 | if cfg!(feature = "snap") { 116 | bail!( 117 | "{} snap-installed {} cannot write directly to `{}`. You can add the following line to make this feature work:\n\n{text}\n", 118 | "Warning:".yellow(), 119 | "`uvenv`".blue(), 120 | filename.blue() 121 | ); 122 | } 123 | 124 | let path = get_home_dir().join(filename); 125 | path.touch()?; // ensure it exists 126 | 127 | let now = now(); 128 | let mut final_text = String::from("\n"); 129 | if with_comment { 130 | // final_text.push_str(&format!("# Added by `uvenv` at {now}\n")); 131 | writeln!(final_text, "# Added by `uvenv` at {now}")?; 132 | } 133 | 134 | final_text.push_str(text); 135 | final_text.push('\n'); 136 | 137 | append(&path, &final_text) 138 | .await 139 | .with_context(|| format!("Trying to append text to your {filename}")) 140 | } 141 | 142 | /// Run a callback function if the shell is supported, 143 | /// or show a message saying the shell is unsupported. 144 | pub fn run_if_supported_shell_else_warn Option>( 145 | if_supported: Y 146 | ) -> Option { 147 | let shell = SupportedShell::detect(); 148 | 149 | if shell.is_supported() { 150 | if_supported(&shell) 151 | } else { 152 | eprintln!( 153 | "Unsupported shell '{}'. Currently, these shells are supported: {}", 154 | shell.name().blue(), 155 | SupportedShell::list_options_formatted(), 156 | ); 157 | None 158 | } 159 | } 160 | 161 | /// Run an async callback function if the shell is supported, 162 | /// or show a message saying the shell is unsupported. 163 | pub async fn run_if_supported_shell_else_warn_async< 164 | T, 165 | Y: AsyncFn(&'_ SupportedShell) -> Option, 166 | >( 167 | if_supported: Y 168 | ) -> Option { 169 | let shell = SupportedShell::detect(); 170 | 171 | if shell.is_supported() { 172 | if_supported(&shell).await 173 | } else { 174 | eprintln!( 175 | "Unsupported shell '{}'. Currently, these shells are supported: {}", 176 | shell.name().blue(), 177 | SupportedShell::list_options_formatted(), 178 | ); 179 | None 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/pypi.rs: -------------------------------------------------------------------------------- 1 | use crate::pip::parse_requirement; 2 | use crate::uv::uv_cache; 3 | use uv_normalize::PackageName; 4 | use uv_pep440::{Version, VersionSpecifier}; 5 | use uv_pep508::Requirement; 6 | use uv_pypi_types::Yanked; 7 | 8 | use std::collections::HashSet; 9 | use tokio::sync::Semaphore; 10 | use uv_client::{ 11 | BaseClientBuilder, MetadataFormat, OwnedArchive, RegistryClient, RegistryClientBuilder, 12 | SimpleDetailMetadata, 13 | }; 14 | use uv_distribution_types::IndexCapabilities; 15 | 16 | /// Shadow `RegistryClient` to hide new complexity of .simple 17 | struct SimplePypi(RegistryClient); 18 | 19 | impl SimplePypi { 20 | /// Use `RegistryClient.package_metadata` to lookup a package on default package index 21 | async fn lookup( 22 | &self, 23 | package_name: &PackageName, 24 | ) -> anyhow::Result>> { 25 | // 1 permit is sufficient 26 | let download_concurrency = Semaphore::new(1); 27 | 28 | let response = self 29 | .0 30 | .simple_detail( 31 | package_name, 32 | None, 33 | &IndexCapabilities::default(), 34 | &download_concurrency, 35 | ) 36 | .await?; 37 | 38 | let mapped: Vec<_> = response 39 | .into_iter() 40 | .filter_map(|(_url, metadata)| match metadata { 41 | MetadataFormat::Simple(data) => Some(data), 42 | MetadataFormat::Flat(_) => None, 43 | }) 44 | .collect(); 45 | 46 | Ok(mapped) 47 | } 48 | } 49 | 50 | impl Default for SimplePypi { 51 | /// Create a (default) Registry 52 | fn default() -> Self { 53 | let cache = uv_cache(); 54 | let base_client = BaseClientBuilder::default(); 55 | let inner = RegistryClientBuilder::new(base_client, cache).build(); 56 | 57 | Self(inner) 58 | } 59 | } 60 | 61 | #[expect( 62 | clippy::borrowed_box, 63 | reason = "If we remove the Box<> then Rust complains that we pass in the wrong type" 64 | )] 65 | fn is_yanked(maybe_yanked_box: Option<&Box>) -> bool { 66 | if let Some(yanked_box) = maybe_yanked_box.as_ref() 67 | && yanked_box.is_yanked() 68 | { 69 | true 70 | } else { 71 | false 72 | } 73 | } 74 | 75 | fn find_non_yanked_versions(metadata: &SimpleDetailMetadata) -> HashSet<&Version> { 76 | let mut valid_versions = HashSet::new(); 77 | 78 | for metadatum in metadata.iter() { 79 | for source_dist in &metadatum.files.source_dists { 80 | if !is_yanked(source_dist.file.yanked.as_ref()) { 81 | valid_versions.insert(&source_dist.name.version); 82 | } 83 | } 84 | 85 | for wheel in &metadatum.files.wheels { 86 | if !is_yanked(wheel.file.yanked.as_ref()) { 87 | valid_versions.insert(&wheel.name.version); 88 | } 89 | } 90 | } 91 | 92 | valid_versions 93 | } 94 | 95 | pub async fn get_versions_for_packagename( 96 | package_name: &PackageName, 97 | stable: bool, 98 | constraint: Option, 99 | ) -> Vec { 100 | let mut versions: Vec = vec![]; 101 | 102 | let client = SimplePypi::default(); 103 | 104 | let data = match client.lookup(package_name).await { 105 | Err(err) => { 106 | eprintln!("Something went wrong: {err};"); 107 | return versions; 108 | }, 109 | Ok(data) => data, 110 | }; 111 | 112 | if let Some(metadata_archived) = data.iter().next_back() { 113 | let metadata = OwnedArchive::deserialize(metadata_archived); 114 | let not_yanked = find_non_yanked_versions(&metadata); 115 | 116 | versions = metadata 117 | .iter() 118 | .filter_map(|metadatum| { 119 | let version = metadatum.version.clone(); 120 | 121 | not_yanked.contains(&version).then_some(version) 122 | }) 123 | .collect(); 124 | } 125 | 126 | if stable { 127 | versions.retain(|version| !version.any_prerelease()); 128 | } 129 | 130 | if let Some(specifier) = constraint { 131 | versions.retain(|version| specifier.contains(version)); 132 | } 133 | 134 | versions 135 | } 136 | 137 | pub async fn get_latest_version_for_packagename( 138 | package_name: &PackageName, 139 | stable: bool, 140 | constraint: Option, 141 | ) -> Option { 142 | let versions = get_versions_for_packagename(package_name, stable, constraint).await; 143 | 144 | versions.last().cloned() 145 | } 146 | #[expect( 147 | dead_code, 148 | reason = "More generic than the used code above (which only looks at version info)" 149 | )] 150 | pub async fn get_pypi_data_for_packagename( 151 | package_name: &PackageName 152 | ) -> Option { 153 | let client = SimplePypi::default(); 154 | 155 | let data = client.lookup(package_name).await.ok()?; 156 | 157 | data.iter().next_back().map_or_else( 158 | || None, 159 | |metadata_archived| { 160 | let metadata = OwnedArchive::deserialize(metadata_archived); 161 | Some(metadata) 162 | }, 163 | ) 164 | } 165 | 166 | pub async fn get_latest_version_for_requirement( 167 | req: &Requirement, 168 | stable: bool, 169 | constraint: Option, 170 | ) -> Option { 171 | get_latest_version_for_packagename(&req.name, stable, constraint).await 172 | } 173 | 174 | pub async fn get_latest_version( 175 | package_spec: &str, 176 | stable: bool, 177 | constraint: Option, 178 | ) -> Option { 179 | let (requirement, _) = parse_requirement(package_spec).await.ok()?; 180 | get_latest_version_for_requirement(&requirement, stable, constraint).await 181 | } 182 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "uvenv" 3 | description = "uvenv: pipx for uv (🦀)" 4 | version = "3.10.14" 5 | rust-version = "1.91" 6 | edition = "2024" 7 | categories = ["development-tools", "development-tools::build-utils", "virtualization", "external-ffi-bindings", "command-line-interface"] 8 | keywords = ["Python", "uv", "pip", "packaging"] 9 | repository = "https://github.com/robinvandernoord/uvenv" 10 | license = "MIT" 11 | 12 | [features] 13 | snap = [] 14 | 15 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 16 | 17 | [profile.release] 18 | # https://stackoverflow.com/questions/29008127/why-are-rust-executables-so-huge 19 | # before: 15 MB 20 | # with opt-level z: 7 MB 21 | # currently: 9 MB 22 | 23 | # opt-level = 'z' # Optimize for size 24 | lto = true # Enable link-time optimization 25 | codegen-units = 1 # Reduce number of codegen units to increase optimizations 26 | panic = 'abort' # Abort on panic 27 | strip = true # Strip symbols from binary* 28 | 29 | [dependencies] 30 | # cli 31 | clap = { version = "4.5", features = ["derive"] } 32 | clap_complete = "4.5" 33 | anyhow = { version = "1.0", features = ["default", "backtrace"] } 34 | 35 | # async 36 | tokio = { version = "1.48", features = ["default", "process", "rt-multi-thread"] } 37 | 38 | # serialize 39 | rmp-serde = "1.3" 40 | serde = { version = "1.0", features = ["derive"] } 41 | serde_json = "1.0" 42 | toml = "0.9" 43 | toml_edit = { version = "0.23", features = ["serde"] } 44 | rkyv = "0.8" 45 | 46 | # helpers 47 | home = "0.5" # ~ resolving 48 | directories = "6.0" 49 | itertools = "0.14" # more .iter magic 50 | configparser = "3.1" 51 | tempfile = "3.23" 52 | chrono = "0.4" 53 | subprocess = "0.2" 54 | reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "brotli", "stream", "rustls-tls", "rustls-tls-native-roots"] } 55 | regex = "1.12" 56 | futures = "0.3" 57 | scopeguard = "1.2" # for defer! 58 | pushd = "0.0.2" 59 | 60 | # fancy 61 | anstyle = "1.0" # color styling for clap 62 | owo-colors = "4.2" # color styling for strings 63 | 64 | # uv 65 | uv = { git = "https://github.com/astral-sh/uv.git", tag = "0.9.18" } 66 | uv-cache = { git = "https://github.com/astral-sh/uv.git", tag = "0.9.18" } 67 | uv-client = { git = "https://github.com/astral-sh/uv.git", tag = "0.9.18" } 68 | uv-configuration = { git = "https://github.com/astral-sh/uv.git", tag = "0.9.18" } 69 | uv-distribution-types = { git = "https://github.com/astral-sh/uv.git", tag = "0.9.18" } 70 | uv-installer = { git = "https://github.com/astral-sh/uv.git", tag = "0.9.18" } 71 | uv-normalize = { git = "https://github.com/astral-sh/uv.git", tag = "0.9.18" } 72 | uv-preview = { git = "https://github.com/astral-sh/uv.git", tag = "0.9.18" } 73 | uv-python = { git = "https://github.com/astral-sh/uv.git", tag = "0.9.18" } 74 | uv-resolver = { git = "https://github.com/astral-sh/uv.git", tag = "0.9.18" } 75 | uv-pep440 = { git = "https://github.com/astral-sh/uv.git", tag = "0.9.18" } 76 | uv-pep508 = { git = "https://github.com/astral-sh/uv.git", tag = "0.9.18" } 77 | uv-pypi-types = { git = "https://github.com/astral-sh/uv.git", tag = "0.9.18" } 78 | uv-requirements = { git = "https://github.com/astral-sh/uv.git", tag = "0.9.18" } 79 | uv-tool = { git = "https://github.com/astral-sh/uv.git", tag = "0.9.18" } 80 | 81 | [patch.crates-io] 82 | # black magic fuckery, see https://github.com/astral-sh/uv/blob/main/Cargo.toml#L300 83 | # reqwest-middleware = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "7650ed76215a962a96d94a79be71c27bffde7ab2" } 84 | # reqwest-retry = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "7650ed76215a962a96d94a79be71c27bffde7ab2" } 85 | 86 | [lints.clippy] 87 | # categories: 88 | correctness = { level = "deny", priority = 1 } 89 | suspicious = { level = "deny", priority = -1 } 90 | complexity = { level = "warn", priority = -1 } 91 | perf = { level = "warn", priority = -1 } 92 | style = { level = "warn", priority = -1 } 93 | pedantic = { level = "warn", priority = -1 } 94 | restriction = { level = "warn", priority = -1 } 95 | nursery = { level = "warn", priority = -1 } 96 | cargo = { level = "warn", priority = -1 } 97 | # specific ones: 98 | ## deny 99 | unwrap_used = "deny" # at least use .expect 100 | implicit_clone = "warn" # good to know when clones are happening 101 | 102 | ## allow 103 | module_name_repetitions = "allow" # using venv_ in functions triggers this, annoying 104 | future_not_send = "allow" # idk how to fix this, tokio goes crazy 105 | struct_excessive_bools = "allow" # Clap args can just get lot of bools 106 | fn_params_excessive_bools = "allow" # Clap args can just get lot of bools 107 | cast_possible_wrap = "allow" # the numbers in this program won't be very large so it's okay 108 | cast_possible_truncation = "allow" # the numbers in this program won't be very large so it's okay 109 | multiple_crate_versions = "allow" # some dependencies make this happen 110 | 111 | blanket_clippy_restriction_lints = "allow" # we'll disable everything we don't want below: 112 | single_call_fn = "allow" # # not everything should be one big function 113 | print_stdout = "allow" # it's a cli tool, printing is fine 114 | print_stderr = "allow" # eprintln is nice even for prd 115 | implicit_return = "allow" # conflicts with needless_return 116 | default_numeric_fallback = "allow" # why do 1_i32 if it's fine to let Rust figure out the types? 117 | question_mark_used = "allow" # it's a nice operator? 118 | missing_docs_in_private_items = "allow" # only docs for pub items is fine for now 119 | pattern_type_mismatch = "allow" # rust understands the pattern match so idk why I would complicate it 120 | absolute_paths = "allow" # it's probably with good reason (e.g. so it's explitly using tokio::fs instead of std) 121 | missing_trait_methods = "allow" # optional default implementations are nice 122 | std_instead_of_alloc = "allow" # false flag for VecDeque 123 | arithmetic_side_effects = "allow" # I trust the math will keep mathing 124 | expect_used = "allow" # expect is only used when absolutely sure panic will not happen! 125 | let_underscore_must_use = "allow" # doing `let _` already says I explicitly don't care about the result! 126 | let_underscore_untyped = "allow" # doing `let _` already says I explicitly don't care about the result! 127 | non_ascii_literal = "allow" # unicode is king 128 | separated_literal_suffix = "allow" # otherwise it will complain about the opposite :( 129 | pub_with_shorthand = "allow" # otherwise it will complain about the opposite :( 130 | mod_module_files = "allow" # grouping mods like tests and 'commands' is useful 131 | unused_trait_names = "allow" # heh? 132 | arbitrary_source_item_ordering = "allow" # I prefer the current ordering over alphabetical. 133 | -------------------------------------------------------------------------------- /src/commands/check.rs: -------------------------------------------------------------------------------- 1 | use owo_colors::OwoColorize; 2 | use std::collections::BTreeMap; 3 | 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use crate::cli::{CheckOptions, Process}; 7 | use crate::commands::ensurepath::{SNAP_ENSUREPATH, check_in_path}; 8 | use crate::commands::list::list_packages; 9 | use crate::helpers::PathAsStr; 10 | use crate::metadata::{LoadMetadataConfig, get_bin_dir}; 11 | use crate::shell::SupportedShell; 12 | 13 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)] 14 | struct Issues<'metadata> { 15 | path_correct: bool, 16 | #[serde(borrow)] 17 | outdated: Vec<&'metadata str>, 18 | #[serde(borrow)] 19 | broken: BTreeMap<&'metadata str, Vec>, 20 | #[serde(borrow)] 21 | scripts: BTreeMap<&'metadata str, Vec>, 22 | } 23 | 24 | impl Issues<'_> { 25 | pub const fn new() -> Self { 26 | Self { 27 | path_correct: false, 28 | outdated: Vec::new(), 29 | broken: BTreeMap::new(), 30 | scripts: BTreeMap::new(), 31 | } 32 | } 33 | 34 | fn check_path(&mut self) { 35 | let bin_dir = get_bin_dir(); 36 | 37 | if cfg!(feature = "snap") { 38 | let shell = SupportedShell::detect(); 39 | 40 | let rcfile = shell.rc_file().unwrap_or("rc"); 41 | eprintln!( 42 | "{}: snap-installed `{}` cannot access $PATH. \ 43 | To ensure '{}' exists in your PATH, you can add `{}` to your {} file.", 44 | "Warning".yellow(), 45 | "uvenv".blue(), 46 | bin_dir.as_str().blue(), 47 | SNAP_ENSUREPATH.green(), 48 | rcfile 49 | ); 50 | self.path_correct = true; 51 | } else { 52 | self.path_correct = check_in_path(bin_dir.as_str()); 53 | } 54 | } 55 | 56 | #[expect(clippy::as_conversions, reason = "The number won't be that big")] 57 | pub const fn count_outdated(&self) -> i32 { 58 | self.outdated.len() as i32 59 | } 60 | #[expect(clippy::as_conversions, reason = "The number won't be that big")] 61 | pub fn count_scripts(&self) -> i32 { 62 | self.scripts 63 | .values() 64 | .fold(0, |acc, vec| acc + vec.len() as i32) 65 | } 66 | 67 | pub fn count(&self) -> i32 { 68 | let mut count = 0; 69 | 70 | count += self.count_outdated(); 71 | count += self.count_scripts(); 72 | 73 | if !self.path_correct { 74 | count += 1; 75 | } 76 | 77 | count 78 | } 79 | 80 | pub fn print_json(&self) -> anyhow::Result { 81 | let json = serde_json::to_string_pretty(self)?; 82 | 83 | eprintln!("{json}"); 84 | 85 | Ok(self.count()) 86 | } 87 | 88 | fn print_human(&self) -> i32 { 89 | let issue_count = self.count(); 90 | 91 | if issue_count == 0 { 92 | println!("{}", "✅ No issues found. Everything is up-to-date and all scripts are properly installed!".green().bold()); 93 | return 0; 94 | } 95 | 96 | println!("{}", "🚨 Issues Overview:".bold().underline()); 97 | 98 | if !self.path_correct { 99 | let bin_dir = get_bin_dir(); 100 | println!( 101 | "{}", 102 | format!(" - {} is not in $PATH", bin_dir.as_str()).red() 103 | ); 104 | 105 | println!( 106 | "{}", 107 | "💡 Tip: you can use `uvenv ensurepath` to fix this.".blue() 108 | ); 109 | } 110 | 111 | // Display outdated issues 112 | if !self.outdated.is_empty() { 113 | println!("{}", "\n🔶 Outdated:".bold().yellow()); 114 | for issue in &self.outdated { 115 | println!(" - {}", issue.red()); 116 | } 117 | 118 | println!( 119 | "{}", 120 | "💡 Tip: you can use `uvenv upgrade ` to update a specific environment." 121 | .blue() 122 | ); 123 | } 124 | 125 | // Display script issues 126 | if !self.scripts.is_empty() { 127 | println!("{}", "\n🔶 Missing Scripts:".bold().yellow()); 128 | for (script, problems) in &self.scripts { 129 | println!(" - {}", format!("{script}:").red().bold()); 130 | for problem in problems { 131 | println!(" - {}", problem.red()); 132 | } 133 | } 134 | 135 | println!("{}", "💡 Tip: you can use `uvenv reinstall ` to reinstall an environment, which might fix the missing scripts.".blue()); 136 | } 137 | 138 | if !self.broken.is_empty() { 139 | println!("{}", "\n🔶 Broken Scripts:".bold().yellow()); 140 | for (script, problems) in &self.broken { 141 | println!(" - {}", format!("{script}:").red().bold()); 142 | for problem in problems { 143 | println!(" - {}", problem.red()); 144 | } 145 | } 146 | 147 | println!("{}", "💡 Tip: you can use `uvenv reinstall ` to reinstall an environment, which might fix the broken scripts.".blue()); 148 | } 149 | 150 | issue_count 151 | } 152 | } 153 | 154 | impl CheckOptions { 155 | const fn to_metadataconfig(&self) -> LoadMetadataConfig { 156 | LoadMetadataConfig { 157 | recheck_scripts: !self.skip_scripts, 158 | updates_check: !self.skip_updates, 159 | updates_prereleases: self.show_prereleases, 160 | updates_ignore_constraints: self.ignore_constraints, 161 | } 162 | } 163 | } 164 | 165 | impl Process for CheckOptions { 166 | async fn process(self) -> anyhow::Result { 167 | let config = self.to_metadataconfig(); 168 | 169 | let items = list_packages(&config, Some(&self.venv_names), None).await?; 170 | 171 | // collect issues: 172 | 173 | let mut issues = Issues::new(); 174 | 175 | issues.check_path(); 176 | 177 | for metadata in &items { 178 | let invalid_scripts = metadata.invalid_scripts(); 179 | if !self.skip_scripts && !invalid_scripts.is_empty() { 180 | issues.scripts.insert(&metadata.name, invalid_scripts); 181 | } 182 | 183 | if !self.skip_updates && metadata.outdated { 184 | issues.outdated.push(&metadata.name); 185 | } 186 | 187 | if !self.skip_broken { 188 | let broken_scripts = metadata.broken_scripts().await; 189 | if !broken_scripts.is_empty() { 190 | issues.broken.insert(&metadata.name, broken_scripts); 191 | } 192 | } 193 | } 194 | 195 | // show issues: 196 | 197 | if self.json { 198 | issues.print_json() 199 | } else { 200 | Ok(issues.print_human()) 201 | } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # uvenv: pipx for uv 2 | 3 | ![pypi wheels](https://github.com/robinvandernoord/uvenv/actions/workflows/pypi.yml/badge.svg) 4 | ![snapcraft](https://github.com/robinvandernoord/uvenv/actions/workflows/snap.yml/badge.svg) 5 | 6 | 7 | Inspired by: 8 | 9 | - [pipx](https://github.com/pypa/pipx) 10 | - [uv](https://github.com/astral-sh/uv) 11 | 12 | ## Installation 13 | 14 | > **New**: `uvenv` is now also installable via the snap store. 15 | > The `pip` method is still recommended, but if you want to use `snap`, please check out [docs/snap.md](./docs/snap.md)! 16 | 17 | 1. Install via pip (or alternatives): 18 | ```bash 19 | pip install uvenv 20 | # or `uv install uvenv`, `pipx install uvenv` 21 | ``` 22 | 23 | > Note: On some systems (e.g., Ubuntu 24.04+), global installation via pip is restricted by default. 24 | > The recommended way to install `uvenv` for these systems is to use the [`install.sh`](https://github.com/robinvandernoord/uvenv/blob/uvenv/install.sh) script: 25 | > ```bash 26 | > $SHELL -c "$(curl -fsSL https://raw.githubusercontent.com/robinvandernoord/uvenv/uvenv/install.sh)" 27 | > # instead of `$SHELL`, you can also use `sh`, `bash`, `zsh` 28 | > > ``` 29 | > For more installation alternatives, see [docs/installation.md](docs/installation.md) if you encounter `externally-managed-environment` errors. 30 | 31 | 2. Optional (for bash users): 32 | ```bash 33 | uvenv setup 34 | ``` 35 | 36 | This installs the following features: 37 | 38 | - Ensures `~/.local/bin/` is added to your PATH, so commands can be found (unless `--skip-ensurepath`). Can also be 39 | activated via `uvenv ensurepath` 40 | - Enables tab completion for `uvenv` (unless `--skip-completions`). Can also be enabled 41 | via `uvenv completions --install`. 42 | - Enables `uvenv activate` (unless `--skip-activate`) to activate uvenv-managed virtualenvs from your shell 43 | 44 | ## Usage 45 | 46 | ```bash 47 | uvenv 48 | ``` 49 | 50 | Most `pipx` commands are supported, such as `install`, `upgrade` `inject`, `run`, `runpip`. 51 | Run `uvenv` without any arguments to see all possible subcommands. 52 | 53 | ### 🆕 Freeze and Thaw 54 | 55 | You can snapshot your current setup into a `uvenv.lock` file using: 56 | 57 | ```bash 58 | uvenv freeze 59 | ``` 60 | 61 | This lock file records all installed applications along with their metadata — including version, Python version, and any injected dependencies. 62 | 63 | Later, you can restore that exact setup using: 64 | 65 | ```bash 66 | uvenv thaw 67 | ``` 68 | 69 | This is useful for replicating the same setup on a different machine, or after a clean install or system update. 70 | 71 | #### Lock file formats 72 | 73 | The `uvenv.lock` file can be saved in one of the following formats: 74 | 75 | - **TOML** (default): human-readable and easy to edit 76 | - **JSON**: more verbose, but script-friendly (e.g. with `jq`) 77 | - **Binary**: compact, but not human-readable 78 | 79 | Choose the format using the `--format` flag: 80 | 81 | ```bash 82 | uvenv freeze --format json 83 | ``` 84 | 85 | See [docs/lockfile_v1.md](./docs/lockfile_v1.md) for details on the file format, including all supported options and examples. 86 | 87 | #### Selective freeze/thaw 88 | 89 | Use `--include` or `--exclude` to control which apps get recorded or restored: 90 | 91 | ```bash 92 | uvenv freeze --exclude some-app 93 | uvenv thaw --include only-this-app 94 | ``` 95 | 96 | For all available options, see: 97 | 98 | ```bash 99 | uvenv freeze --help 100 | uvenv thaw --help 101 | ``` 102 | 103 | ## Migrating from `uvx` and Comparing with `uv tool` 104 | 105 | ### Migrating from `uvx` 106 | 107 | The tool previously named `uvx` is now `uvenv` due to a naming collision with a new `uv` command. The new name better reflects its purpose, combining `uv` with `venv`. 108 | You can run `uvenv self migrate` to move your environments and installed commands from `uvx` to `uvenv`. 109 | 110 | --- 111 | 112 | ### How `uvenv` differs from `uv tool` 113 | 114 | While both `uvenv` and `uv tool` (a subcommand of [`uv`](https://github.com/astral-sh/uv)) offer overlapping functionality for installing and running Python applications, they differ in purpose and approach: 115 | 116 | * **Interface:** `uvenv` is modeled after `pipx`, offering commands like `install`, `inject`, `run`, `upgrade`, and `runpip`. If you're already used to `pipx`, `uvenv` is a near drop-in replacement. 117 | * **Inject support:** `uvenv` supports `pipx`'s `inject` functionality, which lets you add extra packages to an app’s environment — helpful for plugins, linters, or testing tools. `uv tool` does not currently support this. 118 | * **Compatibility:** `uvenv` uses `uv` for dependency resolution and installation, benefiting from its speed and correctness. It also respects `uv`'s configuration files (such as `~/.config/uv/uv.toml` and `/etc/uv/uv.toml`, see [uv config docs](https://docs.astral.sh/uv/configuration/files/)) unless the environment variable `UV_NO_CONFIG=1` is set to ignore them. 119 | 120 | In short: 121 | 122 | * Use **`uvenv`** if you want `pipx`-style workflows with advanced management features. 123 | * Use **`uv tool`** if you prefer a minimal approach for running tools quickly - for most basic use-cases, `uv tool` is probably sufficient. 124 | 125 | 126 | ## Platform Considerations 127 | 128 | - **Rust-Powered Performance (uvenv 2.0):** Starting from version 2.0, `uvenv` leverages Rust for improved performance 129 | and compatibility with `uv`. 130 | - **Prebuilt Binaries:** Currently, prebuilt binaries are available for x86_64 (amd64) and aarch64 (ARM64) on Linux, as well as Intel (x86_64) and Apple Silicon (ARM64) on macOS. 131 | - **Other Platforms:** If you're on a different platform, you can still use `uvx 1.x`, which is written in pure 132 | Python. 133 | Find it at [robinvandernoord/uvx](https://github.com/robinvandernoord/uvx). 134 | - Alternatively, you can **Compile for Your Platform**: 135 | - Install the Rust toolchain: 136 | ```bash 137 | curl https://sh.rustup.rs -sSf | sh 138 | ``` 139 | - Clone the `uvenv` repo and navigate to it: 140 | ```bash 141 | git clone https://github.com/robinvandernoord/uvenv.git 142 | cd uvenv 143 | ``` 144 | - Set up a virtual environment (choose Python or uv): 145 | ```bash 146 | python -m venv venv # or `uv venv venv --seed` 147 | source venv/bin/activate 148 | ``` 149 | - Install Maturin (Python with Rust package builder): 150 | ```bash 151 | pip install maturin # or `uv pip install maturin` 152 | ``` 153 | - Compile and install the `uvenv` binary: 154 | ```bash 155 | maturin develop 156 | ``` 157 | - Now you can use `uvenv`: 158 | ```bash 159 | ./venv/bin/uvenv 160 | ``` 161 | 162 | For additional details on building and distribution, refer to [maturin](https://www.maturin.rs/distribution) 163 | documentation. 164 | 165 | 166 | ## License 167 | 168 | `uvenv` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. 169 | 170 | ## Changelog 171 | 172 | 173 | See `CHANGELOG.md` [on GitHub](https://github.com/robinvandernoord/uvenv/blob/master/CHANGELOG.md) 174 | -------------------------------------------------------------------------------- /src/commands/upgrade.rs: -------------------------------------------------------------------------------- 1 | use anyhow::bail; 2 | use core::fmt::Write; 3 | use itertools::Itertools; 4 | use owo_colors::OwoColorize; 5 | use uv_pep508::Requirement; 6 | use uv_python::PythonEnvironment; 7 | 8 | use crate::commands::list::{is_uvenv_outdated, list_packages}; 9 | use crate::commands::upgrade_all::upgrade_all; 10 | use crate::helpers::StringExt; 11 | use crate::metadata::LoadMetadataConfig; 12 | use crate::venv::setup_environ_from_requirement; 13 | use crate::{ 14 | animate::{AnimationSettings, show_loading_indicator}, 15 | cli::{Process, UpgradeOptions}, 16 | metadata::Metadata, 17 | uv::{ExtractInfo, Helpers, uv, uv_get_installed_version}, 18 | }; 19 | 20 | pub async fn update_metadata( 21 | metadata: &mut Metadata, 22 | requirement: &Requirement, 23 | environ: &PythonEnvironment, 24 | requested_version: String, 25 | ) -> anyhow::Result { 26 | let new_version = uv_get_installed_version(&requirement.name, Some(environ))?; 27 | 28 | metadata.requested_version = requested_version; 29 | metadata.installed_version.clone_from(&new_version); 30 | metadata.save(&environ.to_path_buf()).await?; 31 | 32 | Ok(new_version) 33 | } 34 | 35 | fn build_msg( 36 | old_version: &str, 37 | new_version: &str, 38 | metadata: &Metadata, 39 | ) -> String { 40 | let mut msg = String::new(); 41 | if old_version == new_version { 42 | // msg.push_str(&format!( 43 | // "🌟 '{}' is already up to date at version {}!", 44 | // &metadata.name.green(), 45 | // &new_version.cyan() 46 | // )); 47 | let _ = write!( 48 | msg, 49 | "🌟 '{}' is already up to date at version {}!", 50 | &metadata.name.green(), 51 | &new_version.cyan() 52 | ); 53 | 54 | if !metadata.requested_version.is_empty() { 55 | // msg.push_str(&format!("\n💡 This package was installed with a version constraint ({}). If you want to ignore this constraint, use `{}`.", 56 | // &metadata.requested_version.cyan(), 57 | // format!("uvenv upgrade --force {}", &metadata.name).green() 58 | // )); 59 | let _ = write!( 60 | msg, 61 | "\n💡 This package was installed with a version constraint ({}). If you want to ignore this constraint, use `{}`.", 62 | &metadata.requested_version.cyan(), 63 | format!("uvenv upgrade --force {}", &metadata.name).green() 64 | ); 65 | } 66 | } else { 67 | // msg.push_str(&format!( 68 | // "🚀 Successfully updated '{}' from version {} to version {}!", 69 | // metadata.name.green(), 70 | // old_version.cyan(), 71 | // new_version.cyan() 72 | // )); 73 | let _ = write!( 74 | msg, 75 | "🚀 Successfully updated '{}' from version {} to version {}!", 76 | metadata.name.green(), 77 | old_version.cyan(), 78 | new_version.cyan() 79 | ); 80 | } 81 | 82 | msg 83 | } 84 | 85 | pub async fn upgrade_package_from_requirement( 86 | requirement: &Requirement, 87 | metadata: &mut Metadata, 88 | environ: &PythonEnvironment, 89 | force: bool, 90 | no_cache: bool, 91 | skip_injected: bool, 92 | ) -> anyhow::Result { 93 | let old_version = metadata.installed_version.clone(); 94 | 95 | let mut args = vec!["pip", "install", "--upgrade"]; 96 | 97 | if force || no_cache { 98 | args.push("--no-cache"); 99 | } 100 | 101 | let version = requirement.version().or(if force { 102 | "" 103 | } else { 104 | &metadata.requested_version 105 | }); 106 | 107 | let mut upgrade_spec = metadata.name.clone(); 108 | 109 | let mut extras = metadata.extras.clone(); 110 | extras.extend(requirement.extras()); 111 | 112 | if !extras.is_empty() { 113 | // upgrade_spec.push_str(&format!("[{}]", extras.iter().join(","))); 114 | write!(upgrade_spec, "[{}]", extras.iter().join(","))?; 115 | } 116 | 117 | if !version.is_empty() { 118 | upgrade_spec.push_str(&version); 119 | } 120 | 121 | args.push(&upgrade_spec); 122 | 123 | if !skip_injected { 124 | args.extend(metadata.vec_injected()); 125 | } 126 | 127 | let promise = uv(&args); 128 | 129 | show_loading_indicator( 130 | promise, 131 | format!("upgrading {}", &metadata.name), 132 | AnimationSettings::default(), 133 | ) 134 | .await?; 135 | 136 | let new_version = update_metadata(metadata, requirement, environ, version).await?; 137 | 138 | Ok(build_msg(&old_version, &new_version, metadata)) 139 | } 140 | 141 | pub async fn upgrade_package( 142 | install_spec: &str, 143 | force: bool, 144 | no_cache: bool, 145 | skip_injected: bool, 146 | ) -> anyhow::Result { 147 | // No virtualenv for '{package_name}', stopping. Use 'uvenv install' instead. 148 | let (requirement, environ) = setup_environ_from_requirement(install_spec).await?; 149 | 150 | // = LoadMetadataConfig::default with one change: 151 | let config = LoadMetadataConfig { 152 | updates_check: false, 153 | ..Default::default() 154 | }; 155 | 156 | let mut metadata = Metadata::for_requirement(&requirement, &config).await; 157 | 158 | upgrade_package_from_requirement( 159 | &requirement, 160 | &mut metadata, 161 | &environ, 162 | force, 163 | no_cache, 164 | skip_injected, 165 | ) 166 | .await 167 | } 168 | 169 | async fn find_outdated() -> Vec { 170 | let config = LoadMetadataConfig { 171 | recheck_scripts: false, 172 | updates_check: true, 173 | updates_prereleases: false, 174 | updates_ignore_constraints: false, 175 | }; 176 | 177 | let packages_info = list_packages(&config, None, None).await.unwrap_or_default(); 178 | 179 | packages_info 180 | .into_iter() 181 | .filter_map(|meta| meta.outdated.then_some(meta.name)) 182 | .collect() 183 | } 184 | 185 | impl Process for UpgradeOptions { 186 | async fn process(self) -> anyhow::Result { 187 | let self_outdated = is_uvenv_outdated(true).await; 188 | 189 | let package_names = if self.package_names.is_empty() { 190 | let outdated = find_outdated().await; 191 | 192 | #[expect( 193 | clippy::else_if_without_else, 194 | reason = "If I put the return value in the `else` it still complains about unnecessary `else`" 195 | )] 196 | if self_outdated { 197 | eprintln!( 198 | "{} Use {} to get the latest version.", 199 | "A newer version of uvenv is available.".yellow(), 200 | "uvenv self update".blue() 201 | ); 202 | bail!("{}", "All regular packages are already up to date.".blue()); 203 | } else if outdated.is_empty() { 204 | bail!("{}", "No packages are outdated.".blue()); 205 | } 206 | 207 | outdated 208 | } else { 209 | self.package_names 210 | }; 211 | 212 | upgrade_all( 213 | self.force, 214 | self.no_cache, 215 | self.skip_injected, 216 | &package_names, 217 | ) 218 | .await 219 | .map(|()| 0) 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/commands/self_update.rs: -------------------------------------------------------------------------------- 1 | use crate::animate::{AnimationSettings, show_loading_indicator}; 2 | use crate::cli::{Process, SelfUpdateOptions}; 3 | use crate::cmd::{find_sibling, run}; 4 | use crate::helpers::{PathAsStr, set_env_var}; 5 | use crate::pip::pip_freeze; 6 | use crate::uv::{PythonSpecifier, system_environment, uv_freeze}; 7 | use anyhow::{Context, anyhow, bail}; 8 | use owo_colors::OwoColorize; 9 | use regex::Regex; 10 | use std::path::{Path, PathBuf}; 11 | 12 | pub fn extract_version( 13 | freeze_output: &str, 14 | needle: &str, 15 | ) -> Option { 16 | // (?m) for multi-line 17 | let Ok(re) = Regex::new(&format!("(?m)^({needle}==|{needle} @)(.+)")) else { 18 | return None; 19 | }; 20 | 21 | // for version in re.captures_iter(freeze_output) { 22 | if let Some(version_cap) = re.captures_iter(freeze_output).next() { 23 | let (_, [_, version]) = version_cap.extract(); 24 | // group 1 is {package}== 25 | // group 2 is the version (or path/uri) 26 | return Some(version.to_owned()); 27 | } 28 | 29 | None 30 | } 31 | 32 | pub fn get_package_versions_uv>( 33 | python_path: &Path, 34 | packages: &[S], 35 | default: &str, 36 | ) -> Vec { 37 | let python = PythonSpecifier::Path(python_path); 38 | 39 | let Ok(venv) = python.into_environment() else { 40 | // no venv, return early 41 | return vec![]; 42 | }; 43 | 44 | // raw 'freeze' output: 45 | let output = uv_freeze(&venv).unwrap_or_default(); 46 | 47 | // filtered and parsed: 48 | packages 49 | .iter() 50 | .map(|pkg_name| { 51 | extract_version(&output, pkg_name.as_ref()).unwrap_or_else(|| default.to_owned()) 52 | }) 53 | .collect() 54 | } 55 | 56 | pub async fn get_package_versions_pip>( 57 | python: &Path, 58 | packages: &[S], 59 | default: &str, 60 | ) -> Vec { 61 | let output = pip_freeze(python).await.unwrap_or_default(); 62 | 63 | packages 64 | .iter() 65 | .map(|pkg_name| { 66 | extract_version(&output, pkg_name.as_ref()).unwrap_or_else(|| default.to_owned()) 67 | }) 68 | .collect() 69 | } 70 | 71 | pub fn find_global_python() -> anyhow::Result { 72 | let maybe_environ = system_environment().ok(); 73 | 74 | if let Some(environ) = maybe_environ { 75 | return Ok(environ.python_executable().to_path_buf()); 76 | } 77 | 78 | // naive fallback: 79 | 80 | let fallback = PathBuf::from("/usr/bin/python3"); 81 | if fallback.exists() { 82 | Ok(fallback) 83 | } else { 84 | bail!( 85 | "Python could not be found! Is `{}` installed globally (without a venv)?", 86 | "uvenv".green() 87 | ) 88 | } 89 | } 90 | 91 | pub async fn find_python() -> anyhow::Result { 92 | find_sibling("python") 93 | .await 94 | .map_or_else(find_global_python, Ok) 95 | } 96 | 97 | pub async fn self_update_via_pip( 98 | with_uv: bool, 99 | with_patchelf: bool, 100 | ) -> anyhow::Result { 101 | // fallback for self_update_via_uv 102 | 103 | let exe = find_python().await?; 104 | 105 | let mut args = vec![ 106 | "-m", 107 | "pip", 108 | "install", 109 | "--no-cache-dir", 110 | // "--break-system-packages", 111 | "--upgrade", 112 | "uvenv", 113 | ]; 114 | set_env_var("PIP_BREAK_SYSTEM_PACKAGES", "1"); 115 | 116 | let mut to_track = vec!["uvenv"]; 117 | let mut msg = String::from("uvenv"); 118 | if with_uv { 119 | args.push("uv"); 120 | to_track.push("uv"); 121 | msg.push_str(" and uv"); 122 | } 123 | 124 | if with_patchelf { 125 | args.push("patchelf"); 126 | to_track.push("patchelf"); 127 | msg.push_str(" and patchelf"); 128 | } 129 | 130 | let old = get_package_versions_pip(&exe, &to_track, "?").await; 131 | 132 | let exe_str = exe.as_str(); 133 | let promise = run(&exe_str, &args, None); 134 | 135 | show_loading_indicator( 136 | promise, 137 | format!("updating {msg} through pip"), 138 | AnimationSettings::default(), 139 | ) 140 | .await?; 141 | 142 | let new = get_package_versions_pip(&exe, &to_track, "?").await; 143 | 144 | handle_self_update_result(&to_track, &old, &new); 145 | 146 | Ok(0) 147 | } 148 | 149 | /// currently dead but kept here for documentation 150 | /// (+ so functions like `uv_freeze` don't cound as dead code, it could be useful later.) 151 | #[expect( 152 | dead_code, 153 | reason = "It took so much work to make this kind of work I don't want to remove it now." 154 | )] 155 | pub async fn self_update_via_uv( 156 | with_uv: bool, 157 | with_patchelf: bool, 158 | ) -> anyhow::Result { 159 | let python_exe = find_python().await?; 160 | 161 | let uv = find_sibling("uv") 162 | .await 163 | .ok_or_else(|| anyhow!("Could not find uv!"))?; 164 | 165 | let mut args = vec![ 166 | "pip", 167 | "install", 168 | "--no-cache-dir", 169 | "--python", 170 | &python_exe.as_str(), 171 | "--upgrade", 172 | "uvenv", 173 | ]; 174 | set_env_var("UV_BREAK_SYSTEM_PACKAGES", "1"); 175 | 176 | let mut to_track = vec!["uvenv"]; 177 | let mut msg = String::from("uvenv"); 178 | if with_uv { 179 | args.push("uv"); 180 | to_track.push("uv"); 181 | msg.push_str(" and uv"); 182 | } 183 | 184 | if with_patchelf { 185 | args.push("patchelf"); 186 | to_track.push("patchelf"); 187 | msg.push_str(" and patchelf"); 188 | } 189 | 190 | let old = get_package_versions_uv(&python_exe, &to_track, "?"); 191 | 192 | let promise = run(&uv, &args, None); 193 | 194 | show_loading_indicator( 195 | promise, 196 | format!("updating {msg} through uv"), 197 | AnimationSettings::default(), 198 | ) 199 | .await?; 200 | 201 | let new = get_package_versions_uv(&python_exe, &to_track, "?"); 202 | 203 | handle_self_update_result(&to_track, &old, &new); 204 | 205 | Ok(0) 206 | } 207 | 208 | fn handle_self_update_result( 209 | to_track: &[&str], 210 | old: &[String], 211 | new: &[String], 212 | ) { 213 | for (versions, package) in new.iter().zip(old.iter()).zip(to_track.iter()) { 214 | let (after, before) = versions; 215 | if before == after { 216 | println!( 217 | "🌟 '{}' not updated (version: {})", 218 | package.blue(), 219 | before.green() 220 | ); 221 | } else { 222 | println!( 223 | "🚀 '{}' updated from {} to {}", 224 | package.blue(), 225 | before.red(), 226 | after.green(), 227 | ); 228 | } 229 | } 230 | } 231 | 232 | pub async fn self_update( 233 | with_uv: bool, 234 | with_patchelf: bool, 235 | ) -> anyhow::Result { 236 | if cfg!(feature = "snap") { 237 | bail!( 238 | "`self update` not available if installed through snap. Use `{}` instead.", 239 | "snap refresh uvenv".blue() 240 | ) 241 | } 242 | 243 | // note: uv doesn't work for --user (only --system, which is not allowed on ubuntu 24.04) 244 | // so just using pip is most stable (although a bit slower): 245 | self_update_via_pip(with_uv, with_patchelf).await 246 | } 247 | 248 | impl Process for SelfUpdateOptions { 249 | async fn process(self) -> anyhow::Result { 250 | self_update(!self.without_uv, !self.without_patchelf) 251 | .await 252 | .with_context(|| "Something went wrong trying to update 'uvenv';") 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/commands/install.rs: -------------------------------------------------------------------------------- 1 | use crate::animate::{AnimationSettings, show_loading_indicator}; 2 | use crate::cli::{InstallOptions, Process}; 3 | 4 | use crate::metadata::Metadata; 5 | use crate::pip::parse_requirement; 6 | use crate::symlinks::{create_symlink, find_symlinks}; 7 | use crate::uv::{ExtractInfo, Helpers, uv, uv_get_installed_version}; 8 | use crate::venv::{activate_venv, create_venv, remove_venv}; 9 | 10 | use core::fmt::Display; 11 | use owo_colors::OwoColorize; 12 | use std::collections::BTreeMap; 13 | use uv_pep508::Requirement; 14 | 15 | use crate::helpers::PathToString; 16 | use anyhow::{Context, bail}; 17 | use core::fmt::Write; 18 | use std::path::{Path, PathBuf}; 19 | use uv_python::PythonEnvironment; 20 | 21 | pub async fn uv_install_package>( 22 | package_name: &str, 23 | inject: &[S], 24 | no_cache: bool, 25 | force: bool, 26 | editable: bool, 27 | ) -> anyhow::Result { 28 | let mut args: Vec<&str> = vec!["pip", "install"]; 29 | 30 | if !inject.is_empty() { 31 | args.append(&mut inject.iter().map(AsRef::as_ref).collect()); 32 | } 33 | 34 | if no_cache || force { 35 | args.push("--no-cache"); 36 | } 37 | 38 | if editable { 39 | // -e should go right before package name! 40 | args.push("--editable"); 41 | } 42 | 43 | let mut normalized_package_name = package_name.to_owned(); 44 | 45 | // current dir will be changed in 'uv' so `install .` 46 | // or other relative paths will break, resolve that here 47 | if package_name.starts_with('.') { 48 | let package_path = PathBuf::from(package_name); 49 | 50 | if let Ok(package_path_abs) = std::fs::canonicalize(&package_path) { 51 | normalized_package_name = package_path_abs.to_string(); 52 | } 53 | } 54 | 55 | args.push(&normalized_package_name); 56 | 57 | let promise = uv(&args); 58 | 59 | show_loading_indicator( 60 | promise, 61 | format!("installing {package_name}"), 62 | AnimationSettings::default(), 63 | ) 64 | .await 65 | } 66 | 67 | async fn ensure_venv( 68 | maybe_venv: Option<&Path>, 69 | requirement: &Requirement, 70 | python: Option<&str>, 71 | force: bool, 72 | ) -> anyhow::Result { 73 | match maybe_venv { 74 | Some(venv) => { 75 | let buf = venv.to_path_buf(); 76 | if buf.exists() { 77 | Ok(buf) 78 | } else { 79 | bail!("Package could not be installed because supplied venv was misssing.") 80 | } 81 | }, 82 | None => create_venv(&requirement.name, python, force, true, None).await, 83 | } 84 | } 85 | 86 | async fn store_metadata( 87 | requirement_name: &str, 88 | requirement: &Requirement, 89 | inject: &[S], 90 | editable: bool, 91 | install_spec: &str, 92 | python_spec: Option<&str>, 93 | venv: &PythonEnvironment, 94 | ) -> anyhow::Result { 95 | let mut metadata = Metadata::new(requirement_name); 96 | let _ = metadata.fill(Some(venv)); 97 | 98 | let python_info = venv.interpreter().markers(); 99 | 100 | metadata.editable = editable; 101 | metadata.install_spec = String::from(install_spec); 102 | metadata.python_spec = python_spec.map(String::from); 103 | 104 | metadata.requested_version = { 105 | let requested_version = requirement.version(); 106 | if requested_version.starts_with('~') { 107 | // ignore loosey goosey 108 | String::new() 109 | } else { 110 | requested_version 111 | } 112 | }; 113 | 114 | metadata.python = format!( 115 | "{} {}", 116 | python_info.platform_python_implementation(), 117 | python_info.python_full_version() 118 | ); 119 | metadata.python_raw = venv.stdlib_as_string(); 120 | 121 | metadata.extras = requirement.extras(); 122 | metadata.injected = inject.iter().map(ToString::to_string).collect(); 123 | 124 | if let Ok(version) = uv_get_installed_version(&requirement.name, Some(venv)) { 125 | metadata.installed_version = version; 126 | } 127 | 128 | metadata.save(&venv.to_path_buf()).await?; 129 | Ok(metadata) 130 | } 131 | 132 | pub async fn install_symlinks( 133 | meta: &mut Metadata, 134 | venv: &PythonEnvironment, 135 | requirement: &Requirement, 136 | force: bool, 137 | binaries: &[&str], 138 | ) -> anyhow::Result> { 139 | let venv_root = venv.root(); 140 | 141 | let symlinks = find_symlinks(requirement, &meta.installed_version, venv).await; 142 | 143 | let mut results = BTreeMap::new(); 144 | for symlink in symlinks { 145 | let result = create_symlink(&symlink, venv_root, force, binaries).await; 146 | 147 | let success = result.unwrap_or_else(|msg| { 148 | eprintln!("⚠️ {}", msg.yellow()); 149 | false 150 | }); 151 | 152 | results.insert(symlink, success); 153 | } 154 | 155 | meta.scripts = results.clone(); 156 | meta.save(venv_root).await?; 157 | 158 | Ok(results) 159 | } 160 | 161 | pub async fn install_package + Display>( 162 | install_spec: &str, 163 | maybe_venv: Option<&Path>, 164 | python: Option<&str>, 165 | force: bool, 166 | inject: &[S], 167 | no_cache: bool, 168 | editable: bool, 169 | ) -> anyhow::Result { 170 | let (requirement, mut resolved_install_spec) = parse_requirement(install_spec).await?; 171 | 172 | if resolved_install_spec.contains("~=") { 173 | // remove loosey goosey version: `x~=1.1.1` -> `x` 174 | let (name, _version) = &resolved_install_spec.split_once("~=").unwrap_or_default(); 175 | resolved_install_spec = String::from(*name); 176 | } 177 | 178 | let venv_path = ensure_venv(maybe_venv, &requirement, python, force).await?; 179 | let uv_venv = activate_venv(&venv_path).await?; 180 | 181 | if let Err(err) = uv_install_package(install_spec, inject, no_cache, force, editable).await { 182 | let _ = remove_venv(&venv_path).await; 183 | 184 | return Err(err); 185 | } 186 | 187 | let requirement_name = requirement.name.to_string(); 188 | 189 | let mut metadata = store_metadata( 190 | &requirement_name, 191 | &requirement, 192 | inject, 193 | editable, 194 | &resolved_install_spec, 195 | python, 196 | &uv_venv, 197 | ) 198 | .await?; 199 | 200 | let symlink_results = 201 | install_symlinks(&mut metadata, &uv_venv, &requirement, force, &[]).await?; 202 | 203 | let mut feedback = format!( 204 | "📦 {} ({}) installed:", // :package: 205 | requirement_name, 206 | metadata.installed_version.cyan() 207 | ); 208 | 209 | for (script, success) in symlink_results { 210 | let text = if success { 211 | format!("- {}", script.green()) 212 | } else { 213 | format!("- {}", script.red()) 214 | }; 215 | 216 | let _ = write!(feedback, "\n {text}",); 217 | } 218 | 219 | Ok(feedback) 220 | } 221 | 222 | impl Process for InstallOptions { 223 | async fn process(self) -> anyhow::Result { 224 | match install_package( 225 | &self.package_name, 226 | None, 227 | self.python.as_deref(), 228 | self.force, 229 | &self.with, 230 | self.no_cache, 231 | self.editable, 232 | ) 233 | .await 234 | { 235 | Ok(msg) => { 236 | println!("{msg}"); 237 | Ok(0) 238 | }, 239 | Err(msg) => Err(msg).with_context(|| { 240 | format!( 241 | "Something went wrong trying to install '{}';", 242 | self.package_name 243 | ) 244 | }), 245 | } 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/lockfile/v1.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::{FreezeOptions, OutputFormat, ThawOptions}; 2 | use crate::commands::freeze::Freeze; 3 | use crate::commands::install::install_package; 4 | use crate::commands::list::list_packages; 5 | use crate::commands::thaw::Thaw; 6 | use crate::helpers::{PathAsStr, ResultToString}; 7 | use crate::lockfile::{AutoDeserialize, Lockfile, PackageMap, PackageSpec, extract_python_version}; 8 | use crate::metadata::{LoadMetadataConfig, Metadata, get_venv_dir, serialize_msgpack, venv_path}; 9 | use crate::venv::remove_venv; 10 | use anyhow::{Context, anyhow}; 11 | use core::fmt::Debug; 12 | use itertools::Itertools; 13 | use owo_colors::OwoColorize; 14 | use serde::de::DeserializeOwned; 15 | use serde::{Deserialize, Serialize}; 16 | 17 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)] 18 | pub struct LockfileV1 { 19 | version: i8, 20 | packages: PackageMap, 21 | } 22 | 23 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)] 24 | struct PackageSpecV1 { 25 | spec: String, 26 | #[serde(default)] 27 | version: String, 28 | #[serde(default)] 29 | python: Option, 30 | #[serde(default)] 31 | injected: Vec, 32 | #[serde(default)] 33 | editable: bool, 34 | } 35 | 36 | impl Lockfile<'_, PackageSpecV1> for LockfileV1 { 37 | fn new(packages: PackageMap) -> Self { 38 | Self { 39 | version: 1, 40 | packages, 41 | } 42 | } 43 | 44 | async fn serialize_and_patch( 45 | &self, 46 | options: &FreezeOptions, 47 | ) -> anyhow::Result> { 48 | let serialized = match options.format { 49 | OutputFormat::TOML => { 50 | // this `to_document` converts everything to inline tables: 51 | let mut doc = toml_edit::ser::to_document(self)?; 52 | 53 | // now convert all top-level tables from inline to regular: 54 | for (_, item) in doc.iter_mut() { 55 | // Attempt to convert the inline table into a normal table. 56 | // Here we use as_inline_table_mut; if the packages field is indeed an inline table, 57 | // we can take it out and call .into_table() to convert it. 58 | if let Some(inline_table) = item.as_inline_table_mut() { 59 | // Replace the inline table with a block table. 60 | // Note: std::mem::take clears the inline table, leaving an empty one behind. 61 | let table = core::mem::take(inline_table).into_table(); 62 | *item = toml_edit::Item::Table(table); 63 | } 64 | } 65 | 66 | doc.to_string().into_bytes() 67 | }, 68 | OutputFormat::JSON => serde_json::to_string_pretty(self)?.into_bytes(), 69 | OutputFormat::Binary => serialize_msgpack(self).await?, 70 | }; 71 | 72 | Ok(serialized) 73 | } 74 | } 75 | 76 | impl From for PackageSpecV1 { 77 | fn from(value: Metadata) -> Self { 78 | let version = if value.requested_version.is_empty() { 79 | format!("~={}", value.installed_version) 80 | } else { 81 | value.requested_version 82 | }; 83 | 84 | let python = extract_python_version(&value.python); 85 | 86 | let injected = value.injected.into_iter().collect(); 87 | 88 | Self { 89 | spec: value.install_spec, 90 | editable: value.editable, 91 | version, 92 | python, 93 | injected, 94 | } 95 | } 96 | } 97 | 98 | impl PackageSpec for PackageSpecV1 {} 99 | 100 | impl Freeze for LockfileV1 { 101 | async fn freeze(options: &FreezeOptions) -> anyhow::Result 102 | where 103 | Self: Sized + Debug + Serialize, 104 | { 105 | let pkg_metadata = list_packages(&LoadMetadataConfig::none(), None, None).await?; 106 | 107 | let packages: PackageMap = if !options.include.is_empty() { 108 | // --include passed 109 | pkg_metadata 110 | .into_iter() 111 | .filter_map(|meta| { 112 | options 113 | .include 114 | .contains(&meta.name) 115 | .then(|| (meta.name.clone(), meta.into())) 116 | }) 117 | .collect() 118 | } else if !options.exclude.is_empty() { 119 | // --exclude passed 120 | pkg_metadata 121 | .into_iter() 122 | .filter_map(|meta| { 123 | if options.exclude.contains(&meta.name) { 124 | None 125 | } else { 126 | Some((meta.name.clone(), meta.into())) 127 | } 128 | }) 129 | .collect() 130 | } else { 131 | // just do all 132 | pkg_metadata 133 | .into_iter() 134 | .map(|meta| (meta.name.clone(), meta.into())) 135 | .collect() 136 | }; 137 | 138 | Ok(Self::write(packages, options).await?.into()) 139 | } 140 | } 141 | 142 | impl Thaw for LockfileV1 { 143 | async fn thaw( 144 | options: &ThawOptions, 145 | data: &[u8], 146 | format: OutputFormat, 147 | ) -> anyhow::Result 148 | where 149 | Self: Sized + Debug + DeserializeOwned, 150 | { 151 | let instance = match Self::from_format(data, format) { 152 | Err(err) => return Err(err).with_context(|| "Could not thaw data."), 153 | Ok(instance) => instance, 154 | }; 155 | 156 | let mut possible_errors: Vec> = Vec::new(); 157 | 158 | if options.remove_current { 159 | let venvs_dir = get_venv_dir(); 160 | possible_errors.push( 161 | tokio::fs::remove_dir_all(&venvs_dir) 162 | .await 163 | .with_context(|| { 164 | format!("Trying to remove all venvs at {}", venvs_dir.as_str().red()) 165 | }) 166 | .map_err_to_string(), 167 | ); 168 | } 169 | 170 | let to_install = if !options.include.is_empty() { 171 | instance 172 | .packages 173 | .into_iter() 174 | .filter(|(name, _)| options.include.contains(name)) 175 | .collect() 176 | } else if !options.exclude.is_empty() { 177 | instance 178 | .packages 179 | .into_iter() 180 | .filter(|(name, _)| !options.exclude.contains(name)) 181 | .collect() 182 | } else { 183 | instance.packages 184 | }; 185 | 186 | for (name, pkg) in to_install { 187 | let python_lower = options.python.to_lowercase(); 188 | let python: Option<&str> = match python_lower.as_ref() { 189 | "frozen" => { 190 | // default: use the python version from the lockfile: 191 | pkg.python.as_deref() // Option -> Option<&str> 192 | }, 193 | "ignore" => { 194 | // use (system) default: 195 | None 196 | }, 197 | specific => { 198 | // use specific one: 199 | Some(specific) 200 | }, 201 | }; 202 | 203 | let venv_path = venv_path(&name); 204 | 205 | if venv_path.exists() { 206 | if options.skip_current { 207 | continue; 208 | } 209 | possible_errors.push( 210 | remove_venv(&venv_path) 211 | .await 212 | .with_context(|| { 213 | format!("Trying to remove venv {}", venv_path.as_str().red()) 214 | }) 215 | .map_err_to_string(), 216 | ); 217 | } 218 | 219 | let spec = if pkg.version.starts_with('~') & !pkg.spec.contains('@') { 220 | // soft versioned spec: 221 | format!("{}{}", pkg.spec, pkg.version) 222 | } else { 223 | // hard versioned spec: 224 | pkg.spec 225 | }; 226 | 227 | possible_errors.push( 228 | install_package( 229 | &spec, 230 | None, 231 | python, 232 | true, 233 | &pkg.injected, 234 | false, 235 | pkg.editable, 236 | ) 237 | .await 238 | .map(|feedback| println!("{feedback}")) 239 | .with_context(|| format!("Trying to install {}", name.red())) 240 | .map_err_to_string(), 241 | ); 242 | } 243 | 244 | let errors = possible_errors 245 | .into_iter() 246 | .filter_map(Result::err) 247 | .join("\n"); 248 | 249 | if errors.is_empty() { 250 | Ok(0) 251 | } else { 252 | Err(anyhow!(errors)).with_context(|| "Not everything went as expected.") 253 | } 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /src/uv.rs: -------------------------------------------------------------------------------- 1 | use crate::cmd::{find_sibling, run, run_print_output}; 2 | use anyhow::{Context, anyhow, bail}; 3 | use core::fmt::Write; 4 | use directories::ProjectDirs; 5 | use itertools::Itertools; 6 | use owo_colors::OwoColorize; 7 | use pushd::Pushd; 8 | use std::ffi::OsStr; 9 | use std::path::Path; 10 | use std::{collections::HashSet, path::PathBuf}; 11 | use uv_cache::Cache; 12 | use uv_client::{BaseClientBuilder, Connectivity}; 13 | use uv_distribution_types::{InstalledDistKind, Name}; 14 | use uv_installer::SitePackages; 15 | use uv_normalize::PackageName; 16 | use uv_pep508::Requirement; 17 | use uv_preview::Preview; 18 | use uv_python::{ 19 | EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation, 20 | PythonPreference, PythonRequest, 21 | }; 22 | 23 | use uv_pep508::VersionOrUrl::VersionSpecifier; 24 | 25 | use crate::helpers::{PathToString, set_env_var}; 26 | use crate::metadata::get_work_dir; 27 | 28 | pub async fn maybe_get_uv_binary() -> Option { 29 | find_sibling("uv").await.map(PathToString::to_string) 30 | } 31 | 32 | pub async fn get_uv_binary() -> String { 33 | maybe_get_uv_binary().await.unwrap_or_else( 34 | // fallback, hope 'uv' is available in global scope: 35 | || String::from("uv"), 36 | ) 37 | } 38 | 39 | fn apply_uv_env_settings() { 40 | if cfg!(feature = "snap") { 41 | let work_dir = get_work_dir(); 42 | let python_dir = work_dir.join("python"); 43 | 44 | // by default, uv in snap would install at 45 | // ~/snap/uvenv//.local/share/uv/python/ 46 | // meaning it would be moved after each update; 47 | // leading to longer update times and breaking symlinks. 48 | // so, we set the install dir to a fixed location (~/snap/uvenv/common/python) 49 | set_env_var("UV_PYTHON_INSTALL_DIR", python_dir.to_string()); 50 | } 51 | } 52 | 53 | /// Start `uv` in a subprocess and handle its output 54 | /// Note: while `uv::main` exists, it's not recommended to use as an entrypoint. 55 | /// It also calls `exit`, stopping `uvenv` instead of returning an exit code. 56 | pub async fn uv>(args: &[S]) -> anyhow::Result { 57 | // venv could be unavailable, use 'uv' from this library's requirement 58 | let script = get_uv_binary().await; 59 | apply_uv_env_settings(); 60 | 61 | let subcommand = args 62 | .first() 63 | .ok_or_else(|| anyhow!("No subcommand passed"))? 64 | .as_ref() 65 | .to_str() 66 | .unwrap_or_default(); // cursed but makes it work with both &str and String 67 | let err_prefix = format!("uv {subcommand}"); 68 | 69 | // temporarily (until end of scope) change cwd 70 | // to `~/.config/uvenv` to prevent reading local pyproject.toml. 71 | // this replaces `set_env_var("UV_NO_CONFIG", "1")`, 72 | // which would also ignore ~/.config/uv/uv.toml 73 | let _pd = Pushd::new(get_work_dir()); 74 | 75 | run(&script, args, Some(err_prefix)).await 76 | // _pd is dropped, original cwd is restored 77 | } 78 | 79 | pub async fn uv_with_output>(args: &[S]) -> anyhow::Result { 80 | let script = get_uv_binary().await; 81 | run_print_output(script, args).await 82 | } 83 | 84 | pub fn uv_cache() -> Cache { 85 | ProjectDirs::from("", "", "uv").map_or_else( 86 | || Cache::from_path(".uv_cache"), 87 | |project_dirs| Cache::from_path(project_dirs.cache_dir()), 88 | ) 89 | } 90 | 91 | fn uv_featureflags() -> Preview { 92 | Preview::default() 93 | } 94 | 95 | /// try to find a `PythonEnvironment` based on Cache or currently active virtualenv (`VIRTUAL_ENV`). 96 | pub fn uv_venv(maybe_cache: Option) -> anyhow::Result { 97 | let cache = maybe_cache.unwrap_or_else(uv_cache); 98 | cache.venv_dir()?; // set up the cache 99 | 100 | let environ = PythonEnvironment::find( 101 | &PythonRequest::Any, // just find me a python 102 | EnvironmentPreference::OnlyVirtual, // venv is always virtual 103 | PythonPreference::Managed, 104 | &cache, 105 | uv_featureflags(), 106 | )?; 107 | 108 | Ok(environ) 109 | } 110 | 111 | /// try to find a `PythonEnvironment` based on a specific Python path (as str) 112 | pub fn environment_from_path_str(path: &str) -> anyhow::Result { 113 | let cache = uv_cache(); 114 | 115 | Ok(PythonEnvironment::find( 116 | &PythonRequest::parse(path), 117 | EnvironmentPreference::ExplicitSystem, // based on above python wishes 118 | PythonPreference::Managed, 119 | &cache, 120 | uv_featureflags(), 121 | )?) 122 | } 123 | 124 | /// try to find a `PythonEnvironment` based on a specific Python path (as Path) 125 | pub fn environment_from_path(path: &Path) -> anyhow::Result { 126 | environment_from_path_str(path.to_str().unwrap_or_default()) 127 | } 128 | 129 | /// try to find a `PythonEnvironment` based on the System python 130 | pub fn system_environment() -> anyhow::Result { 131 | let cache = uv_cache(); 132 | 133 | Ok(PythonEnvironment::find( 134 | &PythonRequest::Any, // just find me a python 135 | EnvironmentPreference::OnlySystem, 136 | PythonPreference::OnlySystem, 137 | &cache, 138 | uv_featureflags(), 139 | )?) 140 | } 141 | 142 | fn uv_offline_client() -> BaseClientBuilder<'static> { 143 | BaseClientBuilder::default() 144 | .connectivity(Connectivity::Offline) 145 | .native_tls(false) 146 | } 147 | 148 | /// e.g. 3.12 -> /usr/lib/python3.12, to match with `metadata.python_raw` 149 | pub async fn uv_search_python(python: Option<&str>) -> Option { 150 | let interpreter_request = python.map(PythonRequest::parse); 151 | 152 | let python_request = interpreter_request.as_ref()?; // exit early 153 | 154 | let cache = uv_cache(); 155 | let client = uv_offline_client(); 156 | 157 | // Locate the Python interpreter to use in the environment 158 | let python_installation = PythonInstallation::find_or_download( 159 | Some(python_request), 160 | EnvironmentPreference::OnlySystem, 161 | PythonPreference::OnlySystem, 162 | PythonDownloads::Never, 163 | &client, 164 | &cache, 165 | None, 166 | None, 167 | None, 168 | None, 169 | uv_featureflags(), 170 | ) 171 | .await 172 | .ok()?; 173 | 174 | let interpreter = python_installation.into_interpreter(); 175 | 176 | Some(interpreter.stdlib_as_string()) 177 | } 178 | 179 | pub fn uv_get_installed_version( 180 | package_name: &PackageName, 181 | maybe_venv: Option<&PythonEnvironment>, 182 | ) -> anyhow::Result { 183 | let environment: PythonEnvironment; // lifetime for if maybe_venv is None 184 | 185 | let site_packages = if let Some(venv) = maybe_venv { 186 | SitePackages::from_environment(venv) 187 | } else { 188 | environment = 189 | uv_venv(None).with_context(|| format!("{}", "Failed to set up venv!".red()))?; 190 | SitePackages::from_environment(&environment) 191 | } 192 | .ok(); 193 | 194 | if let Some(pkgs) = site_packages { 195 | // for result in pkgs.get_packages(package_name) { 196 | if let Some(result) = pkgs.get_packages(package_name).into_iter().next() { 197 | return Ok(result.version().to_string()); 198 | } 199 | } 200 | 201 | bail!( 202 | "No version found for '{}'.", 203 | package_name.to_string().yellow() 204 | ) 205 | } 206 | 207 | pub fn uv_freeze(python: &PythonEnvironment) -> anyhow::Result { 208 | // variant with BTree return type is also possible, but everything is currently built on 209 | // the `pip freeze` output string format, so use that for now: 210 | let mut result = String::new(); 211 | 212 | // logic below was copied from the `uv pip freeze` command source code: 213 | // https://github.com/astral-sh/uv/blob/c9787f9fd80c58f1242bee5123919eb16f4b05c1/crates/uv/src/commands/pip/freeze.rs 214 | 215 | // Build the installed index. 216 | let site_packages = SitePackages::from_environment(python)?; 217 | for installed_dist in site_packages 218 | .iter() 219 | // .filter() ? 220 | .sorted_unstable_by(|one, two| one.name().cmp(two.name()).then(one.version().cmp(two.version()))) 221 | { 222 | match &installed_dist.kind { 223 | InstalledDistKind::Registry(dist) => { 224 | // result.push_str(&format!("{}=={}\n", dist.name(), dist.version)); 225 | writeln!(result, "{}=={}", dist.name(), dist.version)?; 226 | }, 227 | InstalledDistKind::Url(dist) => { 228 | if dist.editable { 229 | // result.push_str(&format!("-e {}\n", dist.url)); 230 | writeln!(result, "-e {}", dist.url)?; 231 | } else { 232 | // result.push_str(&format!("{} @ {}\n", dist.name(), dist.url)); 233 | writeln!(result, "{} @ {}", dist.name(), dist.url)?; 234 | } 235 | }, 236 | InstalledDistKind::EggInfoFile(dist) => { 237 | // result.push_str(&format!("{}=={}\n", dist.name(), dist.version)); 238 | writeln!(result, "{}=={}", dist.name(), dist.version)?; 239 | }, 240 | InstalledDistKind::EggInfoDirectory(dist) => { 241 | // result.push_str(&format!("{}=={}\n", dist.name(), dist.version)); 242 | writeln!(result, "{}=={}", dist.name(), dist.version)?; 243 | }, 244 | InstalledDistKind::LegacyEditable(dist) => { 245 | // result.push_str(&format!("-e {}\n", dist.target.display())); 246 | writeln!(result, "-e {}", dist.target.display())?; 247 | }, 248 | } 249 | } 250 | 251 | Ok(result) 252 | } 253 | 254 | #[expect( 255 | dead_code, 256 | reason = "Required for `uv_freeze` (but that function is currently unused)" 257 | )] 258 | #[derive(Debug, Clone)] 259 | pub enum PythonSpecifier<'src> { 260 | Path(&'src Path), 261 | PathBuf(&'src PathBuf), 262 | Str(&'src str), 263 | String(String), 264 | Environ(PythonEnvironment), 265 | } 266 | 267 | impl PythonSpecifier<'_> { 268 | pub fn into_environment(self) -> anyhow::Result { 269 | match self { 270 | PythonSpecifier::Path(path) => environment_from_path(path), 271 | PythonSpecifier::PathBuf(buf) => environment_from_path(buf.as_path()), 272 | PythonSpecifier::Str(string) => environment_from_path_str(string), 273 | PythonSpecifier::String(string) => environment_from_path_str(&string), 274 | PythonSpecifier::Environ(env) => Ok(env), 275 | } 276 | } 277 | } 278 | 279 | pub trait Helpers { 280 | fn to_path_buf(&self) -> PathBuf; 281 | fn stdlib_as_string(&self) -> String; 282 | } 283 | 284 | impl Helpers for PythonEnvironment { 285 | fn to_path_buf(&self) -> PathBuf { 286 | self.root().to_path_buf() 287 | } 288 | 289 | fn stdlib_as_string(&self) -> String { 290 | self.interpreter().stdlib_as_string() 291 | } 292 | } 293 | 294 | impl Helpers for Interpreter { 295 | fn to_path_buf(&self) -> PathBuf { 296 | self.stdlib().to_path_buf() 297 | } 298 | 299 | fn stdlib_as_string(&self) -> String { 300 | let stdlib = self.stdlib().to_str(); 301 | stdlib.unwrap_or_default().to_owned() 302 | } 303 | } 304 | 305 | pub trait ExtractInfo { 306 | fn version(&self) -> String; 307 | fn extras(&self) -> HashSet; 308 | } 309 | 310 | impl ExtractInfo for Requirement { 311 | fn version(&self) -> String { 312 | match &self.version_or_url { 313 | Some(VersionSpecifier(version_specifier)) => version_specifier.to_string(), 314 | _ => String::new(), 315 | } 316 | } 317 | 318 | fn extras(&self) -> HashSet { 319 | self.extras.iter().map(ToString::to_string).collect() 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /src/commands/self_info.rs: -------------------------------------------------------------------------------- 1 | use anyhow::bail; 2 | use core::fmt::Write as _; 3 | use core::str::FromStr; 4 | use futures::future; 5 | use owo_colors::OwoColorize; 6 | use std::collections::BTreeMap; 7 | use std::env; 8 | use std::os::unix::fs::PermissionsExt; 9 | use std::path::{Path, PathBuf}; 10 | use uv_pep440::Version; 11 | 12 | use crate::cli::{Process, SelfInfoOptions}; 13 | use crate::cmd::run_get_output; 14 | use crate::commands::self_update::{find_python, get_package_versions_pip}; 15 | use crate::helpers::{PathAsStr, PathToString}; 16 | use crate::metadata::{get_bin_dir, get_work_dir}; 17 | use crate::pypi::get_latest_version; 18 | use crate::uv::get_uv_binary; 19 | 20 | pub const CURRENT_UVENV_VERSION: &str = env!("CARGO_PKG_VERSION"); 21 | 22 | #[expect(dead_code, reason = "Could still be useful in the future.")] 23 | async fn get_latest_versions(package_names: Vec<&str>) -> BTreeMap<&str, Option> { 24 | let promises: Vec<_> = package_names 25 | .iter() 26 | .map(|it| get_latest_version(it, true, None)) 27 | .collect(); 28 | let resolved = future::join_all(promises).await; 29 | 30 | let mut result = BTreeMap::new(); 31 | for (package, version) in package_names.into_iter().zip(resolved.into_iter()) { 32 | result.insert(package, version); 33 | } 34 | 35 | result 36 | } 37 | 38 | fn red_or_green( 39 | text: &str, 40 | ok: bool, 41 | ) -> String { 42 | if ok { 43 | format!("{}", text.green()) 44 | } else { 45 | format!("{}", text.red()) 46 | } 47 | } 48 | 49 | // separate, public function for testing 50 | pub fn compare_versions( 51 | current: &str, 52 | latest: &str, 53 | ) -> bool { 54 | if latest == "?" || current == "?" { 55 | return false; 56 | } 57 | 58 | // should compare uv_pep440::version::Version instead of str: 59 | 60 | let Ok(current_version) = Version::from_str(current) else { 61 | // if this fails, it's probably not up to date -> 62 | return false; 63 | }; 64 | 65 | let Ok(latest_version) = Version::from_str(latest) else { 66 | // if this fails, we can't know if it's up to date -> 67 | return true; 68 | }; 69 | 70 | current_version.ge(&latest_version) 71 | } 72 | 73 | pub fn is_latest( 74 | current: &str, 75 | latest: Option<&Version>, 76 | ) -> bool { 77 | let Some(version) = latest else { return false }; 78 | 79 | compare_versions(current, &version.to_string()) 80 | } 81 | 82 | /// Check if path exists and is executable (Result variant) 83 | fn try_is_executable(path: &Path) -> anyhow::Result { 84 | if !path.try_exists()? { 85 | bail!("path doesn't exist"); 86 | } 87 | if path.is_dir() { 88 | bail!("path is a directory"); 89 | } 90 | 91 | let metadata = path.metadata()?; 92 | let permissions = metadata.permissions(); 93 | // check chmod: 94 | let is_executable = permissions.mode() & 0o111 != 0; 95 | 96 | Ok(is_executable) 97 | } 98 | 99 | /// Check if path exists and is executable (bool variant) 100 | fn is_executable(path: &Path) -> bool { 101 | try_is_executable(path).unwrap_or_default() 102 | } 103 | 104 | /// Check if path exists and is writable (Result variant) 105 | async fn try_dir_is_writable(path: &Path) -> anyhow::Result { 106 | if !path.is_dir() { 107 | bail!("path is not a directory"); 108 | } 109 | 110 | // temporarily create file to test write: 111 | let file_path = path.join("._uvenv_test_file_"); 112 | let file = tokio::fs::File::create(&file_path).await?; 113 | drop(file); 114 | tokio::fs::remove_file(&file_path).await?; 115 | 116 | Ok(true) 117 | } 118 | 119 | /// Check if path exists and is writable (bool variant) 120 | async fn dir_is_writable(path: &Path) -> bool { 121 | try_dir_is_writable(path).await.unwrap_or_default() 122 | } 123 | 124 | #[derive( 125 | Debug, Default, serde::Serialize, serde::Deserialize, Hash, Ord, PartialOrd, Eq, PartialEq, 126 | )] 127 | struct PackageVersionInfo { 128 | current: String, 129 | latest: Option, 130 | is_latest: bool, 131 | } 132 | 133 | #[derive( 134 | Debug, Default, serde::Serialize, serde::Deserialize, Hash, Ord, PartialOrd, Eq, PartialEq, 135 | )] 136 | struct PackageVersions { 137 | uvenv: PackageVersionInfo, 138 | uv: PackageVersionInfo, 139 | patchelf: PackageVersionInfo, 140 | } 141 | 142 | #[derive( 143 | Debug, Default, serde::Serialize, serde::Deserialize, Hash, Ord, PartialOrd, Eq, PartialEq, 144 | )] 145 | struct Python { 146 | version: String, 147 | path: String, 148 | is_executable: bool, 149 | } 150 | 151 | #[derive( 152 | Debug, Default, serde::Serialize, serde::Deserialize, Hash, Ord, PartialOrd, Eq, PartialEq, 153 | )] 154 | struct EnvironmentPaths { 155 | snap: bool, 156 | uvenv: String, 157 | uvenv_ok: bool, 158 | uv: String, 159 | uv_ok: bool, 160 | bin_dir: String, 161 | bin_dir_ok: bool, 162 | work_dir: String, 163 | work_dir_ok: bool, 164 | } 165 | 166 | #[derive( 167 | Debug, Default, serde::Serialize, serde::Deserialize, Hash, Ord, PartialOrd, Eq, PartialEq, 168 | )] 169 | pub struct SelfInfo { 170 | package_versions: PackageVersions, 171 | python: Python, 172 | environment: EnvironmentPaths, 173 | } 174 | 175 | async fn uvenv_version_info(_: &Path) -> PackageVersionInfo { 176 | let latest = get_latest_version("uvenv", true, None).await; 177 | 178 | PackageVersionInfo { 179 | is_latest: is_latest(CURRENT_UVENV_VERSION, latest.as_ref()), 180 | current: CURRENT_UVENV_VERSION.to_owned(), 181 | latest, 182 | } 183 | } 184 | 185 | async fn patchelf_version_info(python_exe: &Path) -> PackageVersionInfo { 186 | let current = get_package_versions_pip(python_exe, &["patchelf"], "?") 187 | .await 188 | .pop() 189 | .unwrap_or_default(); 190 | let latest = get_latest_version("patchelf", true, None).await; 191 | 192 | PackageVersionInfo { 193 | is_latest: is_latest(¤t, latest.as_ref()), 194 | current, 195 | latest, 196 | } 197 | } 198 | 199 | async fn uv_version_info(_: &Path) -> PackageVersionInfo { 200 | let uv = get_uv_binary().await; 201 | let output = run_get_output(uv, &["--version"]).await.unwrap_or_default(); 202 | let (_, version) = output.trim().split_once(' ').unwrap_or_default(); 203 | 204 | let current = version.to_owned(); 205 | let latest = get_latest_version("uv", true, None).await; 206 | 207 | PackageVersionInfo { 208 | is_latest: is_latest(¤t, latest.as_ref()), 209 | current, 210 | latest, 211 | } 212 | } 213 | 214 | async fn package_version_info(python_exe: &Path) -> PackageVersions { 215 | PackageVersions { 216 | uvenv: uvenv_version_info(python_exe).await, 217 | uv: uv_version_info(python_exe).await, 218 | patchelf: patchelf_version_info(python_exe).await, 219 | } 220 | } 221 | 222 | pub async fn collect_self_info() -> anyhow::Result { 223 | // Find Python and get its version 224 | let python_exe = find_python().await?; 225 | let python_version = run_get_output(&python_exe, &["--version"]) 226 | .await 227 | .unwrap_or_else(|_| "Unknown".to_owned()) 228 | .trim() 229 | .to_owned(); 230 | let python_is_executable = is_executable(&python_exe); 231 | 232 | // Environment paths 233 | let me = env::current_exe().unwrap_or_default(); 234 | let uv_path = PathBuf::from(get_uv_binary().await); 235 | let bin_dir = get_bin_dir(); 236 | let work_dir = get_work_dir(); 237 | 238 | let uvenv_ok = is_executable(&me); 239 | let uv_ok = is_executable(&uv_path); 240 | let bin_ok = dir_is_writable(&bin_dir).await; 241 | let work_ok = dir_is_writable(&work_dir).await; 242 | 243 | let info = SelfInfo { 244 | package_versions: package_version_info(&python_exe).await, 245 | python: Python { 246 | version: python_version, 247 | path: python_exe.to_string(), 248 | is_executable: python_is_executable, 249 | }, 250 | environment: EnvironmentPaths { 251 | snap: cfg!(feature = "snap"), 252 | uvenv: me.to_string(), 253 | uvenv_ok, 254 | uv: uv_path.to_string(), 255 | uv_ok, 256 | bin_dir: format!("{}/", bin_dir.as_str()), 257 | bin_dir_ok: bin_ok, 258 | work_dir: format!("{}/", work_dir.as_str()), 259 | work_dir_ok: work_ok, 260 | }, 261 | }; 262 | 263 | Ok(info) 264 | } 265 | 266 | pub fn fancy_self_info(info: &SelfInfo) -> anyhow::Result { 267 | // ANSI escape codes for colors add to the string length. 268 | // A capacity of 1024 is a safer bet for colored output. 269 | let mut output = String::with_capacity(1024); 270 | 271 | // Header 272 | writeln!(output, "{}", "[uvenv Self Information]".bright_magenta())?; 273 | 274 | // Package section 275 | writeln!(output, "\n{}", "[Package Versions]".bright_blue())?; 276 | writeln!( 277 | output, 278 | "├─ uvenv: {}", 279 | if info.package_versions.uvenv.is_latest { 280 | info.package_versions.uvenv.current.green().to_string() 281 | } else { 282 | info.package_versions.uvenv.current.red().to_string() 283 | } 284 | )?; 285 | 286 | // Display uv version 287 | let uv_display = if info.package_versions.uv.is_latest { 288 | info.package_versions.uv.current.green().to_string() 289 | } else if let Some(latest) = &info.package_versions.uv.latest { 290 | format!( 291 | "{} < {}", 292 | info.package_versions.uv.current.red(), 293 | latest.yellow() // .to_string() is not strictly needed here if latest is String 294 | ) 295 | } else { 296 | info.package_versions.uv.current.yellow().to_string() 297 | }; 298 | writeln!(output, "├─ uv: {uv_display}")?; 299 | 300 | // Display patchelf version 301 | let patchelf_display = if info.package_versions.patchelf.is_latest { 302 | info.package_versions.patchelf.current.green().to_string() 303 | } else if let Some(latest) = &info.package_versions.patchelf.latest { 304 | format!( 305 | "{} < {}", 306 | info.package_versions.patchelf.current.red(), 307 | latest.yellow() // .to_string() is not strictly needed here if latest is String 308 | ) 309 | } else { 310 | info.package_versions.patchelf.current.yellow().to_string() 311 | }; 312 | writeln!(output, "└─ patchelf: {patchelf_display}")?; 313 | 314 | // Python info section 315 | writeln!(output, "\n{}", "[Python]".bright_blue())?; 316 | writeln!(output, "├─ Version: {}", info.python.version)?; 317 | writeln!( 318 | output, 319 | "└─ Path: {}", 320 | red_or_green(&info.python.path, info.python.is_executable) 321 | )?; 322 | 323 | // Environment section with combined paths 324 | writeln!(output, "\n{}", "[Paths & Environment]".bright_blue())?; 325 | if info.environment.snap { 326 | writeln!(output, "├─ Installation: {}", "snap".cyan())?; 327 | } 328 | 329 | // Note: The alignment with spaces ("├─ uvenv: {}") will be preserved. 330 | // If info.environment.snap is false, the first item in this section will use '├─'. 331 | // If you want the first item to always be '├─' and subsequent ones '├─' or '└─' 332 | // dynamically, more complex logic would be needed, or ensure the snap line 333 | // is always followed by other lines that adjust their prefix. 334 | // For simplicity, assuming the current structure is desired. 335 | // If snap is false, the "├─ uvenv:" line will be the first under [Paths & Environment]. 336 | 337 | writeln!( 338 | output, 339 | "├─ uvenv: {}", 340 | red_or_green(&info.environment.uvenv, info.environment.uvenv_ok) 341 | )?; 342 | writeln!( 343 | output, 344 | "├─ uv: {}", 345 | red_or_green(&info.environment.uv, info.environment.uv_ok) 346 | )?; 347 | writeln!( 348 | output, 349 | "├─ Binaries Dir: {}", 350 | red_or_green(&info.environment.bin_dir, info.environment.bin_dir_ok) 351 | )?; 352 | writeln!( 353 | output, 354 | "└─ Working Dir: {}", 355 | red_or_green(&info.environment.work_dir, info.environment.work_dir_ok) 356 | )?; 357 | writeln!(output)?; // For the final empty line 358 | 359 | Ok(output) 360 | } 361 | pub fn simple_self_info(info: &SelfInfo) -> anyhow::Result { 362 | // A capacity of 512 is a reasonable starting point. 363 | // If paths are consistently very long, 768 or 1024 might be better. 364 | let mut output = String::with_capacity(512); 365 | 366 | // Header 367 | writeln!(output, "[uvenv Self Information]")?; // ? is fine here as writing to String shouldn't fail 368 | 369 | // Package section 370 | writeln!(output, "\n[Package Versions]")?; 371 | writeln!( 372 | output, 373 | "- uvenv: {} {}", 374 | info.package_versions.uvenv.current, 375 | if info.package_versions.uvenv.is_latest { 376 | "(latest)" 377 | } else { 378 | "(outdated)" 379 | } 380 | )?; 381 | 382 | // Display uv version 383 | let uv_status = if info.package_versions.uv.is_latest { 384 | "(latest)".to_owned() 385 | } else if let Some(latest) = &info.package_versions.uv.latest { 386 | format!("(latest: {latest})") 387 | } else { 388 | "(status unknown)".to_owned() 389 | }; 390 | writeln!( 391 | output, 392 | "- uv: {} {}", 393 | info.package_versions.uv.current, uv_status 394 | )?; 395 | 396 | // Display patchelf version 397 | let patchelf_status = if info.package_versions.patchelf.is_latest { 398 | "(latest)".to_owned() 399 | } else if let Some(latest) = &info.package_versions.patchelf.latest { 400 | format!("(latest: {latest})") 401 | } else { 402 | "(status unknown)".to_owned() 403 | }; 404 | writeln!( 405 | output, 406 | "- patchelf: {} {}", 407 | info.package_versions.patchelf.current, patchelf_status 408 | )?; 409 | 410 | // Python info section 411 | writeln!(output, "\n[Python]")?; 412 | writeln!(output, "- Version: {}", info.python.version)?; 413 | writeln!( 414 | output, 415 | "- Path: {} {}", 416 | info.python.path, 417 | if info.python.is_executable { 418 | "(OK)" 419 | } else { 420 | "(NOT EXECUTABLE)" 421 | } 422 | )?; 423 | 424 | // Environment section 425 | writeln!(output, "\n[Paths & Environment]")?; 426 | if info.environment.snap { 427 | writeln!(output, "- Installation: snap")?; 428 | } 429 | writeln!( 430 | output, 431 | "- uvenv: {} {}", 432 | info.environment.uvenv, 433 | if info.environment.uvenv_ok { 434 | "(OK)" 435 | } else { 436 | "(NOT EXECUTABLE)" 437 | } 438 | )?; 439 | writeln!( 440 | output, 441 | "- uv: {} {}", 442 | info.environment.uv, 443 | if info.environment.uv_ok { 444 | "(OK)" 445 | } else { 446 | "(NOT EXECUTABLE)" 447 | } 448 | )?; 449 | writeln!( 450 | output, 451 | "- Binaries Dir: {} {}", 452 | info.environment.bin_dir, 453 | if info.environment.bin_dir_ok { 454 | "(OK)" 455 | } else { 456 | "(NOT WRITABLE)" 457 | } 458 | )?; 459 | writeln!( 460 | output, 461 | "- Working Dir: {} {}", 462 | info.environment.work_dir, 463 | if info.environment.work_dir_ok { 464 | "(OK)" 465 | } else { 466 | "(NOT WRITABLE)" 467 | } 468 | )?; 469 | writeln!(output)?; // For the final empty line 470 | 471 | Ok(output) 472 | } 473 | 474 | /// Basic variant for use by `self_version` 475 | pub async fn self_info() -> anyhow::Result { 476 | let info = collect_self_info().await?; 477 | print!("{}", simple_self_info(&info)?); 478 | 479 | Ok(0) 480 | } 481 | 482 | impl Process for SelfInfoOptions { 483 | async fn process(self) -> anyhow::Result { 484 | let info = collect_self_info().await?; 485 | let to_print = if self.simple { 486 | simple_self_info(&info)? 487 | } else if self.json { 488 | serde_json::to_string_pretty(&info)? 489 | } else if self.toml { 490 | toml::to_string_pretty(&info)? 491 | } else if self.fancy { 492 | fancy_self_info(&info)? 493 | } else { 494 | bail!("Unexpected format.") 495 | }; 496 | 497 | print!("{to_print}"); 498 | 499 | Ok(0) 500 | } 501 | } 502 | --------------------------------------------------------------------------------