Repository: pex-tool/pex

Issue:
Cannot build PEX with relative path for --sources-directory.
Discovered this trying to upgrade Pants to 2.1.26. Looks like:
```
$ mkdir src/
$ echo 'print("Hello World!")' > src/main.py
$ python -m pex -D src -otest.pex -e main
Traceback (most recent call last):
  File "/usr/lib/python3.9/runpy.py", line 197, in _run_module_as_main
    return _run_code(code, main_globals, None,
  File "/usr/lib/python3.9/runpy.py", line 87, in _run_code
    exec(code, run_globals)
  File "/home/jsirois/dev/pantsbuild/jsirois-pex/pex/__main__.py", line 8, in <module>
    __name__ == "__main__" and pex.main()
  File "/home/jsirois/dev/pantsbuild/jsirois-pex/pex/bin/pex.py", line 1070, in main
    pex_builder.freeze(bytecode_compile=options.compile)
  File "/home/jsirois/dev/pantsbuild/jsirois-pex/pex/pex_builder.py", line 578, in freeze
    self._prepare_code()
  File "/home/jsirois/dev/pantsbuild/jsirois-pex/pex/pex_builder.py", line 506, in _prepare_code
    self._pex_info.code_hash = CacheHelper.pex_code_hash(self._chroot.path())
  File "/home/jsirois/dev/pantsbuild/jsirois-pex/pex/util.py", line 154, in pex_code_hash
    return cls._compute_hash(names, stream_factory)
  File "/home/jsirois/dev/pantsbuild/jsirois-pex/pex/util.py", line 131, in _compute_hash
    with contextlib.closing(stream_factory(name)) as fp:
  File "/home/jsirois/dev/pantsbuild/jsirois-pex/pex/util.py", line 152, in stream_factory
    return open(os.path.join(d, name), "rb")  # noqa: T802
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/tmpcb782oi_/main.py'
```

Everything works fine if the sources directory is an absolute path though:
```
$ python -m pex -D $PWD/src -otest.pex -e main
$ ./test.pex 
Hello World!
```


Discussion:

Resolving patch:
diff --git a/pex/common.py b/pex/common.py
--- a/pex/common.py
+++ b/pex/common.py
@@ -604,7 +604,7 @@ def symlink(
         dst = self._normalize(dst)
         self._tag(dst, label)
         self._ensure_parent(dst)
-        abs_src = src
+        abs_src = os.path.abspath(src)
         abs_dst = os.path.join(self.chroot, dst)
         os.symlink(abs_src, abs_dst)
 


Test patch:
diff --git a/tests/test_common.py b/tests/test_common.py
--- a/tests/test_common.py
+++ b/tests/test_common.py
@@ -231,6 +231,17 @@ def test_chroot_zip_symlink():
             os.path.join(chroot.path(), "directory/subdirectory/file"),
             "directory/subdirectory/symlinked",
         )
+
+        cwd = os.getcwd()
+        try:
+            os.chdir(os.path.join(chroot.path(), "directory/subdirectory"))
+            chroot.symlink(
+                "file",
+                "directory/subdirectory/rel-symlinked",
+            )
+        finally:
+            os.chdir(cwd)
+
         chroot.symlink(os.path.join(chroot.path(), "directory"), "symlinked")
         zip_dst = os.path.join(tmp, "chroot.zip")
         chroot.zip(zip_dst)
@@ -239,19 +250,23 @@ def test_chroot_zip_symlink():
                 "directory/",
                 "directory/subdirectory/",
                 "directory/subdirectory/file",
+                "directory/subdirectory/rel-symlinked",
                 "directory/subdirectory/symlinked",
                 "symlinked/",
                 "symlinked/subdirectory/",
                 "symlinked/subdirectory/file",
+                "symlinked/subdirectory/rel-symlinked",
                 "symlinked/subdirectory/symlinked",
             ] == sorted(zip.namelist())
             assert b"" == zip.read("directory/")
             assert b"" == zip.read("directory/subdirectory/")
             assert b"data" == zip.read("directory/subdirectory/file")
+            assert b"data" == zip.read("directory/subdirectory/rel-symlinked")
             assert b"data" == zip.read("directory/subdirectory/symlinked")
             assert b"" == zip.read("symlinked/")
             assert b"" == zip.read("symlinked/subdirectory/")
             assert b"data" == zip.read("symlinked/subdirectory/file")
+            assert b"data" == zip.read("symlinked/subdirectory/rel-symlinked")
             assert b"data" == zip.read("symlinked/subdirectory/symlinked")
 
 
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
@@ -7,8 +7,9 @@
 
 import pytest
 
-from pex.common import temporary_dir
+from pex.common import safe_open, temporary_dir
 from pex.compatibility import WINDOWS, nested
+from pex.executor import Executor
 from pex.pex import PEX
 from pex.pex_builder import BOOTSTRAP_DIR, CopyMode, PEXBuilder
 from pex.testing import make_bdist
@@ -16,7 +17,7 @@
 from pex.typing import TYPE_CHECKING
 
 if TYPE_CHECKING:
-    from typing import List
+    from typing import Any, Iterator, List
 
 exe_main = """
 import sys
@@ -179,7 +180,12 @@ def build_and_check(path, copy_mode):
                 is_link = (s1[stat.ST_INO], s1[stat.ST_DEV]) == (s2[stat.ST_INO], s2[stat.ST_DEV])
                 if copy_mode == CopyMode.COPY:
                     assert not is_link
-                elif copy_mode == CopyMode.LINK:
+                else:
+                    # Since os.stat follows symlinks; so in CopyMode.SYMLINK, this just proves the
+                    # symlink points to the original file. Going further and checking path and
+                    # path_clone for the presence of a symlink (an os.islink test) is trickier since
+                    # a Linux hardlink of a symlink produces a symlink whereas a maxOS hardlink of a
+                    # symlink produces a hardlink.
                     assert is_link
 
         build_and_check(td2, CopyMode.LINK)
@@ -187,6 +193,38 @@ def build_and_check(path, copy_mode):
         build_and_check(td4, CopyMode.SYMLINK)
 
 
+@pytest.fixture
+def tmp_chroot(tmpdir):
+    # type: (Any) -> Iterator[str]
+    tmp_chroot = str(tmpdir)
+    cwd = os.getcwd()
+    try:
+        os.chdir(tmp_chroot)
+        yield tmp_chroot
+    finally:
+        os.chdir(cwd)
+
+
+@pytest.mark.parametrize(
+    "copy_mode", [pytest.param(copy_mode, id=copy_mode.value) for copy_mode in CopyMode.values]
+)
+def test_pex_builder_add_source_relpath_issues_1192(
+    tmp_chroot,  # type: str
+    copy_mode,  # type: CopyMode.Value
+):
+    # type: (...) -> None
+    pb = PEXBuilder(copy_mode=copy_mode)
+    with safe_open("src/main.py", "w") as fp:
+        fp.write("import sys; sys.exit(42)")
+    pb.add_source("src/main.py", "main.py")
+    pb.set_entry_point("main")
+    pb.build("test.pex")
+
+    process = Executor.open_process(cmd=[os.path.abspath("test.pex")])
+    process.wait()
+    assert 42 == process.returncode
+
+
 def test_pex_builder_deterministic_timestamp():
     # type: () -> None
     pb = PEXBuilder()