diff --git a/crates/uv-virtualenv/src/activator/activate.xsh b/crates/uv-virtualenv/src/activator/activate.xsh new file mode 100644 index 00000000000..af007fc9867 --- /dev/null +++ b/crates/uv-virtualenv/src/activator/activate.xsh @@ -0,0 +1,102 @@ +# Copyright (c) 2020-202x The virtualenv developers +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# This file must be used with "source bin/activate.xsh" *from xonsh*. +# You cannot run it directly. + + +class _VirtualEnvActivator: + """xonsh activation for virtualenv.""" + + # Stashed in _OLD_VIRTUAL_{name} when the variable was not set before + # activation. deactivate treats this as "unset" rather than "restore to + # this string". + _UNSET_SENTINEL = "__virtualenv_was_not_set__" + + def __init__(self): + from os.path import dirname, realpath + + self.env = __xonsh__.env + self.embedded_virtual_env = dirname(dirname(realpath(__file__))) + self.embedded_virtual_prompt = {{ VIRTUAL_PROMPT }} + self.embedded_bin_name = {{ BIN_NAME }} + self.managed_vars = ("PATH", "PYTHONHOME") + + def _backup_name(self, name): + return f"_OLD_VIRTUAL_{name}" + + def _save(self, name): + backup = self._backup_name(name) + self.env[backup] = self.env[name] if name in self.env else self._UNSET_SENTINEL + + def _override(self, name, value): + self._save(name) + self.env[name] = value + + def _drop(self, name): + self._save(name) + self.env.pop(name, None) + + def register_pydoc(self): + aliases["pydoc"] = ["python", "-m", "pydoc"] + + def unregister_pydoc(self): + aliases.pop("pydoc", None) + + def activate(self): + from os.path import basename, join + + aliases["deactivate"] = self.deactivate + self.deactivate(["nondestructive"]) # wipe any stale state from a prior activation + + $VIRTUAL_ENV = self.embedded_virtual_env + $VIRTUAL_ENV_PROMPT = self.embedded_virtual_prompt or basename($VIRTUAL_ENV) + + self._override("PATH", [join($VIRTUAL_ENV, self.embedded_bin_name), *$PATH]) + self._drop("PYTHONHOME") + self.register_pydoc() + + def deactivate(self, args=None): + for name in self.managed_vars: + backup = self._backup_name(name) + if backup not in self.env: + continue + previous = self.env[backup] + del self.env[backup] + if previous == self._UNSET_SENTINEL: + self.env.pop(name, None) + else: + self.env[name] = previous + for name in ("VIRTUAL_ENV", "VIRTUAL_ENV_PROMPT"): + self.env.pop(name, None) + self.unregister_pydoc() + if args is None or "nondestructive" not in args: + del aliases["deactivate"] + try: + del __xonsh__.xontrib.virtualenv + except AttributeError: + pass + + +if not hasattr(__xonsh__, "xontrib"): + __xonsh__.xontrib = __xonsh__.imp.types.SimpleNamespace() +__xonsh__.xontrib.virtualenv = _VirtualEnvActivator() +__xonsh__.xontrib.virtualenv.activate() diff --git a/crates/uv-virtualenv/src/virtualenv.rs b/crates/uv-virtualenv/src/virtualenv.rs index 922d620785b..b821b39f860 100644 --- a/crates/uv-virtualenv/src/virtualenv.rs +++ b/crates/uv-virtualenv/src/virtualenv.rs @@ -15,7 +15,7 @@ use owo_colors::OwoColorize; use tracing::{debug, trace}; use crate::{Error, Prompt}; -use uv_fs::{CWD, Simplified, cachedir}; +use uv_fs::{CWD, PythonExt, Simplified, cachedir}; use uv_platform_tags::Os; use uv_preview::PreviewFeature; use uv_pypi_types::Scheme; @@ -33,6 +33,7 @@ const ACTIVATE_TEMPLATES: &[(&str, &str)] = &[ ("activate.csh", include_str!("activator/activate.csh")), ("activate.fish", include_str!("activator/activate.fish")), ("activate.nu", include_str!("activator/activate.nu")), + ("activate.xsh", include_str!("activator/activate.xsh")), ("activate.ps1", include_str!("activator/activate.ps1")), ("activate.bat", include_str!("activator/activate.bat")), ("deactivate.bat", include_str!("activator/deactivate.bat")), @@ -508,13 +509,24 @@ pub(crate) fn create( _ => escape_posix_for_single_quotes(location_string), }; + let virtual_prompt = prompt.as_deref().unwrap_or_default(); + let virtual_prompt = match *name { + "activate.xsh" => Cow::Owned(format!( + r#"b"{}".decode("utf-8")"#, + virtual_prompt.as_bytes().escape_ascii(), + )), + _ => Cow::Borrowed(virtual_prompt), + }; + + let bin_name = match *name { + "activate.xsh" => Cow::Owned(bin_name.escape_for_python()), + _ => Cow::Borrowed(bin_name), + }; + let activator = template .replace("{{ VIRTUAL_ENV_DIR }}", &virtual_env_dir) - .replace("{{ BIN_NAME }}", bin_name) - .replace( - "{{ VIRTUAL_PROMPT }}", - prompt.as_deref().unwrap_or_default(), - ) + .replace("{{ BIN_NAME }}", &bin_name) + .replace("{{ VIRTUAL_PROMPT }}", &virtual_prompt) .replace("{{ PATH_SEP }}", path_sep) .replace("{{ RELATIVE_SITE_PACKAGES }}", &relative_site_packages); fs_err::write(scripts.join(name), activator)?; diff --git a/crates/uv/tests/python/venv.rs b/crates/uv/tests/python/venv.rs index d36126d278a..09310d0392e 100644 --- a/crates/uv/tests/python/venv.rs +++ b/crates/uv/tests/python/venv.rs @@ -1687,6 +1687,7 @@ fn verify_pyvenv_cfg() { #[test] fn verify_pyvenv_cfg_relocatable() { let context = uv_test::test_context!("3.12"); + let prompt = "résumé \"quoted\"\\path\n"; // Create a virtual environment at `.venv`. context @@ -1695,6 +1696,8 @@ fn verify_pyvenv_cfg_relocatable() { .arg("--clear") .arg("--python") .arg("3.12") + .arg("--prompt") + .arg(prompt) .arg("--relocatable") .assert() .success(); @@ -1750,6 +1753,15 @@ fn verify_pyvenv_cfg_relocatable() { // be generated when --relocatable is used. let activate_csh = scripts.child("activate.csh"); activate_csh.assert(predicates::path::missing()); + + let activate_xsh = scripts.child("activate.xsh"); + activate_xsh.assert(predicates::path::is_file()); + activate_xsh.assert(predicates::str::contains( + r"dirname(dirname(realpath(__file__)))", + )); + activate_xsh.assert(predicates::str::contains( + r#"self.embedded_virtual_prompt = b"r\xc3\xa9sum\xc3\xa9 \"quoted\"\\path\n".decode("utf-8")"#, + )); } /// With `UV_VENV_RELOCATABLE=1`, the virtual environment is relocatable.