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**



Discussion:
@obestwalter yes it will be helpful to add the --prompt option as well. It is straight forward, since we pass all these to `venv` directly. Only the reading pyvenv.cfg would require more code 

Resolving patch:
diff --git a/xonsh/prompt/env.py b/xonsh/prompt/env.py
--- 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


Test patch:
diff --git a/tests/prompt/test_base.py b/tests/prompt/test_base.py
--- 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