Repository: xonsh/xonsh

Issue:
make promp.env.env_name venv --prompt aware (set in pyvenv.cfg)
## xonfig

<details>

```
> xonfig
<xonsh-code>:1:0 - xonfig
<xonsh-code>:1:0 + ![xonfig]
+------------------+----------------------+
| xonsh            | 0.11.0               |
| Git SHA          | adfa60ea             |
| Commit Date      | Feb 11 14:53:00 2022 |
| Python           | 3.9.7                |
| PLY              | 3.11                 |
| have readline    | True                 |
| prompt toolkit   | None                 |
| shell type       | readline             |
| history backend  | json                 |
| pygments         | 2.11.2               |
| on posix         | True                 |
| on linux         | True                 |
| distro           | unknown              |
| on wsl           | False                |
| on darwin        | False                |
| on windows       | False                |
| on cygwin        | False                |
| on msys2         | False                |
| is superuser     | False                |
| default encoding | utf-8                |
| xonsh encoding   | utf-8                |
| encoding errors  | surrogateescape      |
| on jupyter       | False                |
| jupyter kernel   | None                 |
| xontrib 1        | coreutils            |
| xontrib 2        | vox                  |
| xontrib 3        | voxapi               |
| RC file 1        | /home/ob/.xonshrc    |
+------------------+----------------------+
```

</details>

## Expected Behavior

When activating a venv via `vox activate` the name should be set to the value of `prompt` key in `pyvenv.cfg` if present (file and key) - see https://docs.python.org/3/library/venv.html.

## Current Behavior

The prompt is always set to name of venv directory independent of prompt settings.

## Steps to Reproduce

```shell
$ python -m venv --prompt "MY SPECIAL PROMPT" .venv
$ cat .venv/pyvenv.cfg | grep prompt
prompt = 'MY SPECIAL PROMPT'
vox activate ./.venv
```

new prompt looks like this:
`[17:58:10] (.venv)  ob@ob1 ~/oss/xonsh dropthedot|✓`

but should look like this:
`[17:58:10] (MY SPECIAL PROMPT)  ob@ob1 ~/oss/xonsh dropthedot|✓`

## Also add `--prompt` to `vox new`?
If this is done, maybe a good idea would be to also add `--prompt` to `vox new` to make this possible on creation (also in the interest of symmetry), but it seems like the common pattern for many xonsh users is to have virtualenvs in `~/.virtualenv` with the venv folder name being the same as the project, therefore automatically having a meaningful prompt, so this does not seem to be an urgently wanted feature for most.

I want to prepare a PR for the "passive" part of the functionality, but could also have a stab at adding `--prompt`  to `vox new` at a later date. My main itch would be to respect `prompt` from already existing venvs first.

## For community
⬇️  **Please click the 👍 reaction instead of leaving a `+1` or 👍  comment**


Comments:

Patch:
diff --git a/news/vox-add-prompt-arg.rst b/news/vox-add-prompt-arg.rst
new file mode 100644
index 00000000..e811eb6b
--- /dev/null
+++ b/news/vox-add-prompt-arg.rst
@@ -0,0 +1,23 @@
+**Added:**
+
+* vox new/create accepts a new ``--prompt`` argument, which is passed through to ``python -m venv``
+
+**Changed:**
+
+* <news item>
+
+**Deprecated:**
+
+* <news item>
+
+**Removed:**
+
+* <news item>
+
+**Fixed:**
+
+* <news item>
+
+**Security:**
+
+* <news item>
diff --git a/news/vox-respect-prompt.rst b/news/vox-respect-prompt.rst
new file mode 100644
index 00000000..8837b7f9
--- /dev/null
+++ b/news/vox-respect-prompt.rst
@@ -0,0 +1,23 @@
+**Added:**
+
+* ``prompt.env.env_name`` is now aware of the "prompt" key in ``pyvenv.cfg`` - search order from first to last is: ``$VIRTUAL_ENV_PROMPT``, ``pyvenv.cfg``, ``$VIRTUAL_ENV``, $``CONDA_DEFAULT_ENV``
+
+**Changed:**
+
+* <news item>
+
+**Deprecated:**
+
+* <news item>
+
+**Removed:**
+
+* <news item>
+
+**Fixed:**
+
+* <news item>
+
+**Security:**
+
+* <news item>
diff --git a/xonsh/prompt/env.py b/xonsh/prompt/env.py
index fa6a9142..2edfd17c 100644
--- a/xonsh/prompt/env.py
+++ b/xonsh/prompt/env.py
@@ -1,42 +1,70 @@
 """Prompt formatter for virtualenv and others"""
-
-import os
+import functools
+import re
+from pathlib import Path
+from typing import Optional
 
 from xonsh.built_ins import XSH
 
 
-def find_env_name():
-    """Finds the current environment name from $VIRTUAL_ENV or
-    $CONDA_DEFAULT_ENV if that is set.
+def find_env_name() -> Optional[str]:
+    """Find current environment name from available sources.
+
+    If ``$VIRTUAL_ENV`` is set, it is determined from the prompt setting in
+    ``<venv>/pyvenv.cfg`` or from the folder name of the environment.
+
+    Otherwise - if it is set - from ``$CONDA_DEFAULT_ENV``.
+    """
+    virtual_env = XSH.env.get("VIRTUAL_ENV")
+    if virtual_env:
+        name = _determine_env_name(virtual_env)
+        if name:
+            return name
+    conda_default_env = XSH.env.get("CONDA_DEFAULT_ENV")
+    if conda_default_env:
+        return conda_default_env
+
+
+def env_name() -> str:
+    """Build env_name based on different sources. Respect order of precedence.
+
+    Name from VIRTUAL_ENV_PROMPT will be used as-is.
+    Names from other sources are surrounded with ``{env_prefix}`` and
+    ``{env_postfix}`` fields.
     """
-    env_path = XSH.env.get("VIRTUAL_ENV", "")
-    if env_path:
-        env_name = os.path.basename(env_path)
-    else:
-        env_name = XSH.env.get("CONDA_DEFAULT_ENV", "")
-    return env_name
+    if XSH.env.get("VIRTUAL_ENV_DISABLE_PROMPT"):
+        return ""
+    virtual_env_prompt = XSH.env.get("VIRTUAL_ENV_PROMPT")
+    if virtual_env_prompt:
+        return virtual_env_prompt
+    found_envname = find_env_name()
+    return _surround_env_name(found_envname) if found_envname else ""
+
 
+@functools.lru_cache(maxsize=5)
+def _determine_env_name(virtual_env: str) -> str:
+    """Use prompt setting from pyvenv.cfg or basename of virtual_env.
 
-def env_name():
-    """Returns the current env_name if it non-empty, surrounded by the
-    ``{env_prefix}`` and ``{env_postfix}`` fields.
+    Tries to be resilient to subtle changes in whitespace and quoting in the
+    configuration file format as it adheres to no clear standard.
     """
-    env_name = find_env_name()
-    if XSH.env.get("VIRTUAL_ENV_DISABLE_PROMPT") or not env_name:
-        # env name prompt printing disabled, or no environment; just return
-        return
-
-    venv_prompt = XSH.env.get("VIRTUAL_ENV_PROMPT")
-    if venv_prompt is not None:
-        return venv_prompt
-    else:
-        pf = XSH.shell.prompt_formatter
-        pre = pf._get_field_value("env_prefix")
-        post = pf._get_field_value("env_postfix")
-        return pre + env_name + post
-
-
-def vte_new_tab_cwd():
+    venv_path = Path(virtual_env)
+    pyvenv_cfg = venv_path / "pyvenv.cfg"
+    if pyvenv_cfg.is_file():
+        match = re.search(r"prompt\s*=\s*(.*)", pyvenv_cfg.read_text())
+        if match:
+            return match.group(1).strip().lstrip("'\"").rstrip("'\"")
+    return venv_path.name
+
+
+def _surround_env_name(name: str) -> str:
+    pf = XSH.shell.prompt_formatter
+    pre = pf._get_field_value("env_prefix")
+    post = pf._get_field_value("env_postfix")
+    return f"{pre}{name}{post}"
+
+
+def vte_new_tab_cwd() -> None:
     """This prints an escape sequence that tells VTE terminals the hostname
     and pwd. This should not be needed in most cases, but sometimes is for
     certain Linux terminals that do not read the PWD from the environment
diff --git a/xontrib/vox.py b/xontrib/vox.py
index 3d5dac12..2a994f59 100644
--- a/xontrib/vox.py
+++ b/xontrib/vox.py
@@ -87,6 +87,7 @@ class VoxHandler(xcli.ArgParserAlias):
         packages: xcli.Annotated[tp.Sequence[str], xcli.Arg(nargs="*")] = (),
         requirements: xcli.Annotated[tp.Sequence[str], xcli.Arg(action="append")] = (),
         link_project_dir=False,
+        prompt: "str|None" = None,
     ):
         """Create a virtual environment in $VIRTUALENV_HOME with python3's ``venv``.
 
@@ -114,6 +115,8 @@ class VoxHandler(xcli.ArgParserAlias):
             The argument value is passed to ``pip -r`` to be installed.
         link_project_dir: -l, --link, --link-project
             Associate the current directory with the new environment.
+        prompt: --prompt
+            Provides an alternative prompt prefix for this environment.
         """
 
         print("Creating environment...")
@@ -128,6 +131,7 @@ class VoxHandler(xcli.ArgParserAlias):
             symlinks=symlinks,
             with_pip=(not without_pip),
             interpreter=interpreter,
+            prompt=prompt,
         )
         if link_project_dir:
             self.project_set(name)
diff --git a/xontrib/voxapi.py b/xontrib/voxapi.py
index b74dd1b7..c844c91e 100644
--- a/xontrib/voxapi.py
+++ b/xontrib/voxapi.py
@@ -139,6 +139,7 @@ class Vox(collections.abc.Mapping):
         system_site_packages=False,
         symlinks=False,
         with_pip=True,
+        prompt=None,
     ):
         """Create a virtual environment in $VIRTUALENV_HOME with python3's ``venv``.
 
@@ -157,8 +158,9 @@ class Vox(collections.abc.Mapping):
             environment.
         with_pip : bool
             If True, ensure pip is installed in the virtual environment. (Default is True)
+        prompt: str
+            Provides an alternative prompt prefix for this environment.
         """
-
         if interpreter is None:
             interpreter = _get_vox_default_interpreter()
             print(f"Using Interpreter: {interpreter}")
@@ -176,7 +178,14 @@ class Vox(collections.abc.Mapping):
                 )
             )
 
-        self._create(env_path, interpreter, system_site_packages, symlinks, with_pip)
+        self._create(
+            env_path,
+            interpreter,
+            system_site_packages,
+            symlinks,
+            with_pip,
+            prompt=prompt,
+        )
         events.vox_on_create.fire(name=name)
 
     def upgrade(self, name, symlinks=False, with_pip=True, interpreter=None):
@@ -219,6 +228,9 @@ class Vox(collections.abc.Mapping):
             "symlinks": symlinks,
             "with_pip": with_pip,
         }
+        prompt = cfgops.get("prompt")
+        if prompt:
+            flags["prompt"] = prompt.lstrip("'\"").rstrip("'\"")
         # END things we shouldn't be doing.
 
         # Ok, do what we came here to do.
@@ -233,6 +245,7 @@ class Vox(collections.abc.Mapping):
         symlinks=False,
         with_pip=True,
         upgrade=False,
+        prompt=None,
     ):
         version_output = sp.check_output(
             [interpreter, "--version"], stderr=sp.STDOUT, text=True
@@ -255,8 +268,10 @@ class Vox(collections.abc.Mapping):
             with_pip,
             upgrade,
         ]
-        cmd = [arg for arg in cmd if arg]  # remove empty args
+        if prompt and module == "venv":
+            cmd.extend(["--prompt", prompt])
 
+        cmd = [arg for arg in cmd if arg]  # remove empty args
         logging.debug(cmd)
 
         return_code = sp.call(cmd)


Test patch:
diff --git a/tests/prompt/test_base.py b/tests/prompt/test_base.py
index 40976a8c..a9994aeb 100644
--- a/tests/prompt/test_base.py
+++ b/tests/prompt/test_base.py
@@ -1,7 +1,9 @@
+import functools
 from unittest.mock import Mock
 
 import pytest
 
+from xonsh.prompt import env as prompt_env
 from xonsh.prompt.base import PROMPT_FIELDS, PromptFormatter
 
 
@@ -118,10 +120,10 @@ def test_format_prompt_with_various_prepost(formatter, xession, live_fields, pre
 
     xession.env["VIRTUAL_ENV"] = "env"
 
-    live_fields.update({"env_prefix": pre, "env_postfix": post})
-
+    lf_copy = dict(live_fields)  # live_fields fixture is not idempotent!
+    lf_copy.update({"env_prefix": pre, "env_postfix": post})
     exp = pre + "env" + post
-    assert formatter("{env_name}", fields=live_fields) == exp
+    assert formatter("{env_name}", fields=lf_copy) == exp
 
 
 def test_noenv_with_disable_set(formatter, xession, live_fields):
@@ -132,6 +134,98 @@ def test_noenv_with_disable_set(formatter, xession, live_fields):
     assert formatter("{env_name}", fields=live_fields) == exp
 
 
+class TestPromptFromVenvCfg:
+    WANTED = "wanted"
+    CONFIGS = [
+        f"prompt = '{WANTED}'",
+        f'prompt = "{WANTED}"',
+        f'\t prompt =  "{WANTED}"   ',
+        f"prompt \t=    {WANTED}   ",
+        "nothing = here",
+    ]
+    CONFIGS.extend([f"other = fluff\n{t}\nmore = fluff" for t in CONFIGS])
+
+    @pytest.mark.parametrize("text", CONFIGS)
+    def test_determine_env_name_from_cfg(self, monkeypatch, tmp_path, text):
+        monkeypatch.setattr(prompt_env, "_surround_env_name", lambda x: x)
+        (tmp_path / "pyvenv.cfg").write_text(text)
+        wanted = self.WANTED if self.WANTED in text else tmp_path.name
+        assert prompt_env._determine_env_name(tmp_path) == wanted
+
+
+class TestEnvNamePrompt:
+    def test_no_prompt(self, formatter, live_fields):
+        assert formatter("{env_name}", fields=live_fields) == ""
+
+    def test_search_order(self, monkeypatch, tmp_path, formatter, xession, live_fields):
+        xession.shell.prompt_formatter = formatter
+
+        first = "first"
+        second = "second"
+        third = "third"
+        fourth = "fourth"
+
+        pyvenv_cfg = tmp_path / third / "pyvenv.cfg"
+        pyvenv_cfg.parent.mkdir()
+        pyvenv_cfg.write_text(f"prompt={second}")
+
+        fmt = functools.partial(formatter, "{env_name}", fields=live_fields)
+        xession.env.update(
+            dict(
+                VIRTUAL_ENV_PROMPT=first,
+                VIRTUAL_ENV=str(pyvenv_cfg.parent),
+                CONDA_DEFAULT_ENV=fourth,
+            )
+        )
+
+        xession.env["VIRTUAL_ENV_DISABLE_PROMPT"] = 0
+        assert fmt() == first
+
+        xession.env["VIRTUAL_ENV_DISABLE_PROMPT"] = 1
+        assert fmt() == ""
+
+        del xession.env["VIRTUAL_ENV_PROMPT"]
+        xession.env["VIRTUAL_ENV_DISABLE_PROMPT"] = 0
+        assert fmt() == f"({second}) "
+
+        xession.env["VIRTUAL_ENV_DISABLE_PROMPT"] = 1
+        assert fmt() == ""
+
+        xession.env["VIRTUAL_ENV_DISABLE_PROMPT"] = 0
+        pyvenv_cfg.unlink()
+        # In the interest of speed the calls are cached, but if the user
+        # fiddles with environments this will bite them. I will not do anythin
+        prompt_env._determine_env_name.cache_clear()
+        assert fmt() == f"({third}) "
+
+        xession.env["VIRTUAL_ENV_DISABLE_PROMPT"] = 1
+        assert fmt() == ""
+
+        xession.env["VIRTUAL_ENV_DISABLE_PROMPT"] = 0
+        del xession.env["VIRTUAL_ENV"]
+        assert fmt() == f"({fourth}) "
+
+        xession.env["VIRTUAL_ENV_DISABLE_PROMPT"] = 1
+        assert fmt() == ""
+
+        xession.env["VIRTUAL_ENV_DISABLE_PROMPT"] = 0
+        del xession.env["CONDA_DEFAULT_ENV"]
+        assert fmt() == ""
+
+    @pytest.mark.xfail(reason="caching introduces stale values")
+    def test_env_name_updates_on_filesystem_change(self, tmp_path):
+        """Due to cache, user might get stale value.
+
+        if user fiddles with env folder or the config, they might get a stale
+        value from the cache.
+        """
+        cfg = tmp_path / "pyvenv.cfg"
+        cfg.write_text("prompt=fromfile")
+        assert prompt_env._determine_env_name(cfg.parent) == "fromfile"
+        cfg.unlink()
+        assert prompt_env._determine_env_name(cfg.parent) == cfg.parent.name
+
+
 @pytest.mark.parametrize("disable", [0, 1])
 def test_custom_env_overrides_default(formatter, xession, live_fields, disable):
     xession.shell.prompt_formatter = formatter
diff --git a/tests/test_vox.py b/tests/test_vox.py
index 741e170c..7379d2d4 100644
--- a/tests/test_vox.py
+++ b/tests/test_vox.py
@@ -368,6 +368,7 @@ _VOX_RM_OPTS = {"-f", "--force"}.union(_HELP_OPTS)
                     "--requirements",
                     "-t",
                     "--temp",
+                    "--prompt",
                 }
             ),
         ),