Repository: pipxproject/pipx
Pull request #401 (2020-05-05 20:42:08)

Use symlinks on Windows if they are available
This checks if symlinks are usable on Windows (they are on Windows 10, with "developer mode" enabled, on Python 3.8 and later) and uses them if they are. The main advantage is that with symlinks, there's no problem with overwriting the command executables when upgrading - so it's possible to use pipx to manage itself :-)

The checking approach is basically taken from virtualenv, so it's had some real-world use in that.

cs01: This is cool, thanks!

There are some other places that will need to be updated
* _get_exposed_app_paths_for_package
* uninstall
pfmoore: > There are some other places that will need to be updated

I'll take a look - thanks for the review.

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index cc726ef..d3fcc65 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,11 +1,15 @@
 dev
 
-- Improved error reporting during venv metadata inspection.
+0.15.2.0
+
+- [feature] Use symlinks on Windows when symlinks are available
+- [bugfix] Improved error reporting during venv metadata inspection.
 - [bugfix] Fixed incompatibility with pypy as venv interpreter (#372).
 - [bugfix] Replaced implicit dependency on setuptools with an explicit dependency on packaging (#339).
-- [refactor] Moved all commands to separate files within the commands module (#255).
 - [bugfix] Continue reinstalling packages after failure
 - [bugfix] Hide cursor while pipx runs
+- [feature] Add environment variable `USE_EMOJI` to allow enabling/disabling emojies (#376)
+- [refactor] Moved all commands to separate files within the commands module (#255).
 
 0.15.1.3
 
diff --git a/docs/docs.md b/docs/docs.md
index 1b9b6a1..bc5759f 100644
--- a/docs/docs.md
+++ b/docs/docs.md
@@ -17,9 +17,13 @@ or run directly in an temporary Virtual Environment.
 
 Virtual Environment location is ~/.local/pipx/venvs.
 Symlinks to apps are placed in ~/.local/bin.
-These locations can be overridden with the environment variables
-PIPX_HOME and PIPX_BIN_DIR, respectively. (Virtual Environments will
-be installed to $PIPX_HOME/venvs)
+
+Optional Environment Variables:
+PIPX_HOME: Overrides default pipx location. Virtual Environments
+will be installed to $PIPX_HOME/venvs.
+PIPX_BIN_DIR: Overrides location of app installations. Apps are symlinked
+or copied here.
+USE_EMOJI: Override emoji behavior. Default value varies based on platform.
 
 optional arguments:
   -h, --help            show this help message and exit
diff --git a/src/pipx/commands/common.py b/src/pipx/commands/common.py
index edd7caa..4eea7a5 100644
--- a/src/pipx/commands/common.py
+++ b/src/pipx/commands/common.py
@@ -8,7 +8,8 @@ import userpath  # type: ignore
 
 from pathlib import Path
 from shutil import which
-from typing import List
+from tempfile import TemporaryDirectory
+from typing import Dict, List
 
 from pipx import constants
 from pipx.colors import bold, red
@@ -20,12 +21,36 @@ from pipx.venv import Venv
 def expose_apps_globally(
     local_bin_dir: Path, app_paths: List[Path], package: str, *, force: bool
 ):
-    if WINDOWS:
+    if not _can_symlink(local_bin_dir):
         _copy_package_apps(local_bin_dir, app_paths, package)
     else:
         _symlink_package_apps(local_bin_dir, app_paths, package, force=force)
 
 
+_can_symlink_cache: Dict[Path, bool] = {}
+
+
+def _can_symlink(local_bin_dir: Path) -> bool:
+
+    if not WINDOWS:
+        # Technically, even on Unix this depends on the filesystem
+        return True
+
+    if local_bin_dir not in _can_symlink_cache:
+        with TemporaryDirectory(dir=local_bin_dir) as d:
+            p = Path(d)
+            target = p / "a"
+            target.touch()
+            lnk = p / "b"
+            try:
+                lnk.symlink_to(target)
+                _can_symlink_cache[local_bin_dir] = True
+            except (OSError, NotImplementedError):
+                _can_symlink_cache[local_bin_dir] = False
+
+    return _can_symlink_cache[local_bin_dir]
+
+
 def _copy_package_apps(local_bin_dir: Path, app_paths: List[Path], package: str):
     for src_unresolved in app_paths:
         src = src_unresolved.resolve()
@@ -137,11 +162,13 @@ def _get_exposed_app_paths_for_package(
             # sometimes symlinks can resolve to a file of a different name
             # (in the case of ansible for example) so checking the resolved paths
             # is not a reliable way to determine if the symlink exists.
-            # windows doesn't use symlinks, so the check is less strict.
-            if WINDOWS and b.name in package_binary_names:
-                is_same_file = True
-            else:
+            # We always use the stricter check on non-Windows systems. On
+            # Windows, we use a less strict check if we don't have a symlink.
+            if not WINDOWS or b.is_symlink():
                 is_same_file = b.resolve().parent.samefile(venv_bin_path)
+            elif WINDOWS and b.name in package_binary_names:
+                is_same_file = True
+
             if is_same_file:
                 bin_symlinks.add(b)
 
diff --git a/src/pipx/commands/uninstall.py b/src/pipx/commands/uninstall.py
index c59a0a9..e76a2ff 100644
--- a/src/pipx/commands/uninstall.py
+++ b/src/pipx/commands/uninstall.py
@@ -6,7 +6,7 @@ from pipx.util import WINDOWS, rmdir
 
 
 from pipx import constants
-from pipx.emojies import hazard, stars
+from pipx.emojies import hazard, stars, sleep
 from pipx.venv import Venv, VenvContainer
 
 
@@ -15,7 +15,7 @@ def uninstall(venv_dir: Path, package: str, local_bin_dir: Path, verbose: bool):
     packages.
     """
     if not venv_dir.exists():
-        print(f"Nothing to uninstall for {package} 😴")
+        print(f"Nothing to uninstall for {package} {sleep}")
         app = which(package)
         if app:
             print(
diff --git a/src/pipx/constants.py b/src/pipx/constants.py
index 861090d..90fc652 100644
--- a/src/pipx/constants.py
+++ b/src/pipx/constants.py
@@ -16,14 +16,36 @@ PIPX_SHARED_PTH = "pipx_shared.pth"
 LOCAL_BIN_DIR = Path(os.environ.get("PIPX_BIN_DIR", DEFAULT_PIPX_BIN_DIR)).resolve()
 PIPX_VENV_CACHEDIR = PIPX_HOME / ".cache"
 TEMP_VENV_EXPIRATION_THRESHOLD_DAYS = 14
-try:
-    WindowsError
-except NameError:
-    WINDOWS = False
-else:
-    WINDOWS = True
-
-emoji_support = not WINDOWS and sys.getdefaultencoding() == "utf-8"
+
+
+def is_windows() -> bool:
+    try:
+        WindowsError  # noqa
+    except NameError:
+        return False
+    else:
+        return True
+
+
+WINDOWS: bool = is_windows()
+
+
+def strtobool(val: str) -> bool:
+    val = val.lower()
+    if val in ("y", "yes", "t", "true", "on", "1"):
+        return True
+    elif val in ("n", "no", "f", "false", "off", "0"):
+        return False
+    else:
+        return False
+
+
+def use_emjois():
+    platform_emoji_support = not is_windows() and sys.getdefaultencoding() == "utf-8"
+    return strtobool(str(os.getenv("USE_EMOJI", platform_emoji_support)))
+
+
+emoji_support = use_emjois()
 
 completion_instructions = dedent(
     """
diff --git a/src/pipx/main.py b/src/pipx/main.py
index 95989f1..a7a97de 100644
--- a/src/pipx/main.py
+++ b/src/pipx/main.py
@@ -42,9 +42,13 @@ or run directly in an temporary Virtual Environment.
 
 Virtual Environment location is {str(constants.PIPX_LOCAL_VENVS)}.
 Symlinks to apps are placed in {str(constants.LOCAL_BIN_DIR)}.
-These locations can be overridden with the environment variables
-PIPX_HOME and PIPX_BIN_DIR, respectively. (Virtual Environments will
-be installed to $PIPX_HOME/venvs)
+
+Optional Environment Variables:
+PIPX_HOME: Overrides default pipx location. Virtual Environments
+will be installed to $PIPX_HOME/venvs.
+PIPX_BIN_DIR: Overrides location of app installations. Apps are symlinked
+or copied here.
+USE_EMOJI: Override emoji behavior. Default value varies based on platform.
 """
 )
 
diff --git a/src/pipx/version.py b/src/pipx/version.py
index 25ef789..39de15e 100644
--- a/src/pipx/version.py
+++ b/src/pipx/version.py
@@ -1,2 +1,2 @@
-__version_info__ = (0, 15, 1, 3)
+__version_info__ = (0, 15, 2, 0)
 __version__ = ".".join(str(i) for i in __version_info__)


Test patch:
diff --git a/tests/test_constants.py b/tests/test_constants.py
new file mode 100644
index 0000000..d6bdb1c
--- /dev/null
+++ b/tests/test_constants.py
@@ -0,0 +1,42 @@
+from pipx.constants import use_emjois
+from pipx import constants
+from unittest import mock
+import sys
+import pytest  # type: ignore
+
+
+@pytest.mark.parametrize(
+    "windows, USE_EMOJI, encoding, expected",
+    [
+        # windows
+        (True, None, "utf-8", False),
+        (True, "", "utf-8", False),
+        (True, "0", "utf-8", False),
+        (True, "1", "utf-8", True),
+        (True, "true", "utf-8", True),
+        (True, "tru", "utf-8", False),
+        (True, "True", "utf-8", True),
+        (True, "false", "utf-8", False),
+        # unix
+        (False, None, "utf-8", True),
+        (False, "", "utf-8", False),
+        (False, "0", "utf-8", False),
+        (False, "1", "utf-8", True),
+        (False, "true", "utf-8", True),
+        (False, "tru", "utf-8", False),
+        (False, "True", "utf-8", True),
+        (False, "false", "utf-8", False),
+        # encoding
+        (False, None, "utf-8", True),
+        (False, None, "", False),
+    ],
+)
+def test_use_emjois(monkeypatch, windows, USE_EMOJI, encoding, expected):
+    with mock.patch.object(
+        constants,
+        "is_windows",
+        mock.create_autospec(constants.is_windows, return_value=windows),
+    ), mock.patch.object(sys, "getdefaultencoding", return_value=encoding):
+        if USE_EMOJI is not None:
+            monkeypatch.setenv("USE_EMOJI", USE_EMOJI)
+        assert use_emjois() is expected