Repository: pex-tool/pex

Issue:
Write a proper commonprefix / nearest ancestor path function.
As discussed in this thread, the stdlib is lacking here: https://github.com/pantsbuild/pex/pull/1914#discussion_r974846450


Discussion:
This is the ticket: https://docs.python.org/3/library/os.path.html#os.path.commonpath
Present in 3.5+ which just leaves compat for 2.7.

Resolving patch:
diff --git a/pex/compatibility.py b/pex/compatibility.py
--- a/pex/compatibility.py
+++ b/pex/compatibility.py
@@ -15,7 +15,19 @@
 from pex.typing import TYPE_CHECKING, cast
 
 if TYPE_CHECKING:
-    from typing import IO, AnyStr, BinaryIO, Callable, Optional, Text, Tuple, Type
+    from typing import (
+        IO,
+        AnyStr,
+        BinaryIO,
+        Callable,
+        Deque,
+        List,
+        Optional,
+        Sequence,
+        Text,
+        Tuple,
+        Type,
+    )
 
 
 try:
@@ -37,6 +49,7 @@
 if PY2:
     from collections import Iterable as Iterable
     from collections import MutableSet as MutableSet
+    from collections import deque
 else:
     from collections.abc import Iterable as Iterable
     from collections.abc import MutableSet as MutableSet
@@ -199,3 +212,50 @@ def indent(
 
 else:
     from textwrap import indent as indent
+
+
+if PY3:
+    from os.path import commonpath as commonpath
+else:
+
+    def commonpath(paths):
+        # type: (Sequence[Text]) -> Text
+        if not paths:
+            raise ValueError("The paths given must be a non-empty sequence")
+        if len(paths) == 1:
+            return paths[0]
+        if len({os.path.isabs(path) for path in paths}) > 1:
+            raise ValueError(
+                "Can't mix absolute and relative paths, given:\n{paths}".format(
+                    paths="\n".join(paths)
+                )
+            )
+
+        def components(path):
+            # type: (Text) -> Iterable[Text]
+
+            pieces = deque()  # type: Deque[Text]
+
+            def append(piece):
+                if piece and piece != ".":
+                    pieces.appendleft(piece)
+
+            head, tail = os.path.split(path)
+            append(tail)
+            while head:
+                if "/" == head:
+                    append(head)
+                    break
+                head, tail = os.path.split(head)
+                append(tail)
+            return pieces
+
+        prefix = []  # type: List[Text]
+        for atoms in zip(*(components(path) for path in paths)):
+            if len(set(atoms)) == 1:
+                prefix.append(atoms[0])
+            else:
+                break
+        if not prefix:
+            return ""
+        return os.path.join(*prefix)
diff --git a/pex/tools/main.py b/pex/tools/main.py
--- a/pex/tools/main.py
+++ b/pex/tools/main.py
@@ -8,6 +8,7 @@
 
 from pex import pex_bootstrapper
 from pex.commands.command import GlobalConfigurationError, Main
+from pex.compatibility import commonpath
 from pex.pex import PEX
 from pex.pex_bootstrapper import InterpreterTest
 from pex.pex_info import PexInfo
@@ -28,7 +29,7 @@ def simplify_pex_path(pex_path):
     # Generate the most concise path possible that is still cut/paste-able to the command line.
     pex_path = os.path.abspath(pex_path)
     cwd = os.getcwd()
-    if os.path.commonprefix((pex_path, cwd)) == cwd:
+    if commonpath((pex_path, cwd)) == cwd:
         pex_path = os.path.relpath(pex_path, cwd)
         # Handle users that do not have . as a PATH entry.
         if not os.path.dirname(pex_path) and os.curdir not in os.environ.get("PATH", "").split(


Test patch:
diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py
--- a/tests/integration/test_integration.py
+++ b/tests/integration/test_integration.py
@@ -17,7 +17,7 @@
 import pytest
 
 from pex.common import safe_mkdir, safe_open, safe_rmtree, temporary_dir, touch
-from pex.compatibility import WINDOWS, to_bytes
+from pex.compatibility import WINDOWS, commonpath, to_bytes
 from pex.dist_metadata import Distribution, Requirement
 from pex.fetcher import URLFetcher
 from pex.interpreter import PythonInterpreter
@@ -1435,7 +1435,7 @@ def run_isort_pex(pex_python=None):
                 pex_hash=pex_hash,
                 has_interpreter_constraints=False,
             )
-        assert expected_venv_home == os.path.commonprefix([pex_interpreter, expected_venv_home])
+        assert expected_venv_home == commonpath([pex_interpreter, expected_venv_home])
         return pex_interpreter
 
     isort_pex_interpreter1 = run_isort_pex()
diff --git a/tests/integration/test_pex_bootstrapper.py b/tests/integration/test_pex_bootstrapper.py
--- a/tests/integration/test_pex_bootstrapper.py
+++ b/tests/integration/test_pex_bootstrapper.py
@@ -10,6 +10,7 @@
 import pytest
 
 from pex.common import safe_open
+from pex.compatibility import commonpath
 from pex.interpreter import PythonInterpreter
 from pex.pex import PEX
 from pex.pex_bootstrapper import ensure_venv
@@ -213,7 +214,7 @@ def find_package_paths(venv):
         os.path.dirname(os.path.dirname(p)) for p in symlink_package_paths
     }
     assert os.path.realpath(os.path.join(pex_root, PexInfo.INSTALL_CACHE)) == os.path.realpath(
-        os.path.commonprefix(list(package_file_installed_wheel_dirs))
+        commonpath(list(package_file_installed_wheel_dirs))
     ), "Expected contributing wheel content to be symlinked from the installed wheel cache."
     assert {
         "twitter.common.{package}-0.3.11-py{py_major}-none-any.whl".format(
diff --git a/tests/test_compatibility.py b/tests/test_compatibility.py
--- a/tests/test_compatibility.py
+++ b/tests/test_compatibility.py
@@ -3,7 +3,7 @@
 
 import pytest
 
-from pex.compatibility import PY3, indent, to_bytes, to_unicode
+from pex.compatibility import PY3, commonpath, indent, to_bytes, to_unicode
 
 unicode_string = (str,) if PY3 else (unicode,)  # type: ignore[name-defined]
 
@@ -44,3 +44,61 @@ def test_indent():
     assert "  line1\n\n  line3" == indent("line1\n\nline3", "  ")
     assert "  line1\n \n  line3" == indent("line1\n \nline3", "  ")
     assert "  line1\n  \n  line3" == indent("line1\n\nline3", "  ", lambda line: True)
+
+
+def test_commonpath_invalid():
+    # type: () -> None
+
+    with pytest.raises(ValueError):
+        commonpath([])
+
+    with pytest.raises(ValueError):
+        commonpath(["a", "/a"])
+
+
+def test_commonpath_single():
+    # type: () -> None
+
+    assert "" == commonpath([""])
+    assert "/" == commonpath(["/"])
+    assert "a" == commonpath(["a"])
+    assert "/a" == commonpath(["/a"])
+
+
+def test_commonpath_common():
+    # type: () -> None
+
+    assert "a" == commonpath(["a", "a"])
+    assert "a" == commonpath(["a", "a/"])
+    assert "a" == commonpath(["a", "a/b"])
+    assert "a" == commonpath(["a/", "a/b"])
+    assert "a" == commonpath(["a/c", "a/b"])
+
+    assert "/a" == commonpath(["/a", "/a"])
+    assert "/a" == commonpath(["/a", "/a/"])
+    assert "/a" == commonpath(["/a", "/a/b"])
+    assert "/a" == commonpath(["/a/", "/a/b"])
+    assert "/a" == commonpath(["/a/c", "/a/b"])
+
+    assert "a" == commonpath(["./a", "./a"])
+    assert "a" == commonpath(["./a", "./a/"])
+    assert "a" == commonpath(["./a", "./a/b"])
+    assert "a" == commonpath(["./a/", "./a/b"])
+    assert "a" == commonpath(["./a/c", "./a/b"])
+
+    assert "../a" == commonpath(["../a", "../a"])
+    assert "../a" == commonpath(["../a", "../a/"])
+    assert "../a" == commonpath(["../a", "../a/b"])
+    assert "../a" == commonpath(["../a/", "../a/b"])
+    assert "../a" == commonpath(["../a/c", "../a/b"])
+
+    assert "a/b" == commonpath(["./a/./b", "./a/b"])
+    assert "a/../b" == commonpath(["./a/.././b", "a/../b/c"])
+
+
+def test_commonpath_none():
+    # type: () -> None
+
+    assert "" == commonpath(["a", "b"])
+    assert "" == commonpath(["bad", "b"])
+    assert "" == commonpath(["a", "a", "b"])
diff --git a/tests/test_pex_builder.py b/tests/test_pex_builder.py
--- a/tests/test_pex_builder.py
+++ b/tests/test_pex_builder.py
@@ -11,7 +11,7 @@
 import pytest
 
 from pex.common import open_zip, safe_open, temporary_dir, touch
-from pex.compatibility import WINDOWS
+from pex.compatibility import WINDOWS, commonpath
 from pex.executor import Executor
 from pex.layout import Layout
 from pex.pex import PEX
@@ -309,7 +309,7 @@ def test_pex_builder_packed(tmpdir):
     for root, dirs, files in os.walk(pex_app, followlinks=False):
         for f in files:
             path = os.path.join(root, f)
-            assert not os.path.islink(path) or pex_app == os.path.commonprefix(
+            assert not os.path.islink(path) or pex_app == commonpath(
                 [pex_app, os.path.realpath(path)]
             ), (
                 "All packed layout files should be real files inside the packed layout root that "
diff --git a/tests/test_third_party.py b/tests/test_third_party.py
--- a/tests/test_third_party.py
+++ b/tests/test_third_party.py
@@ -8,6 +8,7 @@
 
 from pex import third_party
 from pex.common import temporary_dir
+from pex.compatibility import commonpath
 from pex.typing import TYPE_CHECKING
 from pex.variables import ENV
 
@@ -31,7 +32,7 @@ def test_isolated_pex_root():
     # type: () -> None
     with temporary_pex_root() as (pex_root, _):
         devendored_chroot = os.path.realpath(third_party.isolated().chroot_path)
-        assert pex_root == os.path.commonprefix([pex_root, devendored_chroot])
+        assert pex_root == commonpath([pex_root, devendored_chroot])
 
 
 def test_isolated_vendoring_constraints_omitted():