Repository: pypa/pipx (python)

Issue:
Globbing for PIPX_BIN_DIR
I set `$PIPX_BIN_DIR` to `~/bin` and it created a directory called `~` in my home directory.

```console
% pipx list                 
venvs are in /Users/bjorn/.local/pipx/venvs
binaries are exposed on your $PATH at /Users/bjorn/~/bin
   package pipsi 0.9, Python 3.7.2
    - pipsi
```

Pull request:
Resolve home path in dir env vars
<!-- add an 'x' in the brackets below -->

- [x] I have added a news fragment under `changelog.d/` (if the patch affects the end users)

## Summary of changes

Fixes #94. Implemented the original suggestion, which seems to perfectly fix the described problem. 

## Test plan

<!-- provide evidence of testing, preferably with command(s) that can be copy+pasted by others -->


Tested by running

```
export PIPX_BIN_DIR="~/test"
pipx environment
```

-> 
```
Environment variables (set by user):

PIPX_HOME=
PIPX_BIN_DIR=~/test
PIPX_MAN_DIR=
PIPX_SHARED_LIBS=
PIPX_DEFAULT_PYTHON=
USE_EMOJI=

Derived values (computed by pipx):

PIPX_HOME=/home/robert/.local/pipx
PIPX_BIN_DIR=/home/robert/test
PIPX_MAN_DIR=/home/robert/.local/share/man
PIPX_SHARED_LIBS=/home/robert/.local/pipx/shared
PIPX_LOCAL_VENVS=/home/robert/.local/pipx/venvs
PIPX_LOG_DIR=/home/robert/.local/pipx/logs
PIPX_TRASH_DIR=/home/robert/.local/pipx/.trash
PIPX_VENV_CACHEDIR=/home/robert/.local/pipx/.cache
PIPX_DEFAULT_PYTHON=/home/robert/private/code/pipx/.nox/develop-3-12/bin/python
USE_EMOJI=true
```


Patch:
diff --git a/changelog.d/1191.fix.md b/changelog.d/1191.bugfix.md
similarity index 100%
rename from changelog.d/1191.fix.md
rename to changelog.d/1191.bugfix.md
diff --git a/changelog.d/94.bugfix.md b/changelog.d/94.bugfix.md
new file mode 100644
index 0000000..2d15433
--- /dev/null
+++ b/changelog.d/94.bugfix.md
@@ -0,0 +1,1 @@
+Correctly resolve home directory in pipx directory environment variables.
diff --git a/src/pipx/constants.py b/src/pipx/constants.py
index 46a063c..2498790 100644
--- a/src/pipx/constants.py
+++ b/src/pipx/constants.py
@@ -7,6 +7,12 @@ from typing import NewType, Optional
 
 from platformdirs import user_cache_path, user_data_path, user_log_path
 
+
+def load_dir_from_environ(dir_name: str, default: Path) -> Path:
+    env = os.environ.get(dir_name, default)
+    return Path(os.path.expanduser(env)).resolve()
+
+
 DEFAULT_PIPX_HOME = user_data_path("pipx")
 FALLBACK_PIPX_HOME = Path.home() / ".local/pipx"
 DEFAULT_PIPX_BIN_DIR = Path.home() / ".local/bin"
@@ -14,7 +20,7 @@ DEFAULT_PIPX_MAN_DIR = Path.home() / ".local/share/man"
 MAN_SECTIONS = ["man%d" % i for i in range(1, 10)]
 
 if FALLBACK_PIPX_HOME.exists() or os.environ.get("PIPX_HOME") is not None:
-    PIPX_HOME = Path(os.environ.get("PIPX_HOME", FALLBACK_PIPX_HOME)).resolve()
+    PIPX_HOME = load_dir_from_environ("PIPX_HOME", FALLBACK_PIPX_HOME)
     PIPX_LOCAL_VENVS = PIPX_HOME / "venvs"
     PIPX_STANDALONE_PYTHON_CACHEDIR = PIPX_HOME / "py"
     PIPX_LOG_DIR = PIPX_HOME / "logs"
@@ -30,10 +36,10 @@ else:
     PIPX_TRASH_DIR = PIPX_HOME / "trash"
     PIPX_VENV_CACHEDIR = user_cache_path("pipx")
 
-PIPX_SHARED_LIBS = Path(os.environ.get("PIPX_SHARED_LIBS", DEFAULT_PIPX_SHARED_LIBS)).resolve()
+PIPX_SHARED_LIBS = load_dir_from_environ("PIPX_SHARED_LIBS", DEFAULT_PIPX_SHARED_LIBS)
 PIPX_SHARED_PTH = "pipx_shared.pth"
-LOCAL_BIN_DIR = Path(os.environ.get("PIPX_BIN_DIR", DEFAULT_PIPX_BIN_DIR)).resolve()
-LOCAL_MAN_DIR = Path(os.environ.get("PIPX_MAN_DIR", DEFAULT_PIPX_MAN_DIR)).resolve()
+LOCAL_BIN_DIR = load_dir_from_environ("PIPX_BIN_DIR", DEFAULT_PIPX_BIN_DIR)
+LOCAL_MAN_DIR = load_dir_from_environ("PIPX_MAN_DIR", DEFAULT_PIPX_MAN_DIR)
 FETCH_MISSING_PYTHON = os.environ.get("PIPX_FETCH_MISSING_PYTHON", False)
 TEMP_VENV_EXPIRATION_THRESHOLD_DAYS = 14
 MINIMUM_PYTHON_VERSION = "3.8"


Test patch:
diff --git a/tests/test_environment.py b/tests/test_environment.py
index 8ddf60b..e12d0e3 100644
--- a/tests/test_environment.py
+++ b/tests/test_environment.py
@@ -1,4 +1,7 @@
+from pathlib import Path
+
 from helpers import run_pipx_cli
+from pipx.constants import load_dir_from_environ
 
 
 def test_cli(monkeypatch, capsys):
@@ -32,3 +35,17 @@ def test_cli_with_args(monkeypatch, capsys):
     assert run_pipx_cli(["environment", "--value", "SSS"])
     captured = capsys.readouterr()
     assert "Variable not found." in captured.err
+
+
+def test_resolve_user_dir_in_env_paths(monkeypatch):
+    monkeypatch.setenv("TEST_DIR", "~/test")
+    home = Path.home()
+    env_dir = load_dir_from_environ("TEST_DIR", Path.home())
+    assert "~" not in str(env_dir)
+    assert env_dir == home / "test"
+
+
+def test_resolve_user_dir_in_env_paths_env_not_set(monkeypatch):
+    home = Path.home()
+    env_dir = load_dir_from_environ("TEST_DIR", Path.home())
+    assert env_dir == home