Repository: pex-tool/pex

Issue:
Support for generating non-hermetic console scripts in `venv`
The `venv` command rewrites console script shebangs in the generated virtualenv [here](https://github.com/pantsbuild/pex/blob/632a6ba3867d520e2721bf70fa1e7f114050c78b/pex/venv/pex.py#L276-L278), adding `-sE` in the process. Those added flags make it more difficult to use scripts that rely on `PYTHONPATH` (i.e. `pylint` trying to load in-repo plugins). It would be nice if `venv` supported an option to disable the behavior.


Discussion:
Yeah, this sounds sane and useful @danxmoran.

Resolving patch:
diff --git a/pex/tools/commands/venv.py b/pex/tools/commands/venv.py
--- a/pex/tools/commands/venv.py
+++ b/pex/tools/commands/venv.py
@@ -164,6 +164,16 @@ def add_arguments(cls, parser):
                 )
             ),
         )
+        parser.add_argument(
+            "--non-hermetic-scripts",
+            dest="hermetic_scripts",
+            action="store_false",
+            default=True,
+            help=(
+                "Don't rewrite console script shebangs in the venv to pass `-sE` to the interpreter; "
+                "for example, to enable running venv scripts with a custom `PYTHONPATH`."
+            ),
+        )
         cls.register_global_arguments(parser, include_verbosity=False)
 
     def run(self, pex):
@@ -196,6 +206,7 @@ def run(self, pex):
             collisions_ok=self.options.collisions_ok,
             symlink=False,
             scope=self.options.scope,
+            hermetic_scripts=self.options.hermetic_scripts,
         )
         if self.options.pip:
             try:
diff --git a/pex/venv/pex.py b/pex/venv/pex.py
--- a/pex/venv/pex.py
+++ b/pex/venv/pex.py
@@ -103,6 +103,7 @@ def populate_venv(
     collisions_ok=True,  # type: bool
     symlink=False,  # type: bool
     scope=InstallScope.ALL,  # type: InstallScope.Value
+    hermetic_scripts=True,  # type: bool
 ):
     # type: (...) -> str
 
@@ -117,7 +118,7 @@ def record_provenance(src_to_dst):
             provenance[dst].append(src)
 
     if scope in (InstallScope.ALL, InstallScope.DEPS_ONLY):
-        record_provenance(_populate_deps(venv, pex, venv_python, symlink))
+        record_provenance(_populate_deps(venv, pex, venv_python, symlink, hermetic_scripts))
 
     if scope in (InstallScope.ALL, InstallScope.SOURCE_ONLY):
         record_provenance(_populate_sources(venv, pex, shebang, venv_python, bin_path))
@@ -195,6 +196,7 @@ def _populate_deps(
     pex,  # type: PEX
     venv_python,  # type: str
     symlink=False,  # type: bool
+    hermetic_scripts=True,  # type: bool
 ):
     # type: (...) -> Iterator[Tuple[str, str]]
 
@@ -274,7 +276,8 @@ def _populate_deps(
                     print(rel_extra_path, file=fp)
 
     # 3. Re-write any (console) scripts to use the venv Python.
-    for script in venv.rewrite_scripts(python=venv_python, python_args="-sE"):
+    script_python_args = "-sE" if hermetic_scripts else None
+    for script in venv.rewrite_scripts(python=venv_python, python_args=script_python_args):
         TRACER.log("Re-writing {}".format(script))
 
 


Test patch:
diff --git a/tests/integration/tools/commands/test_venv.py b/tests/integration/tools/commands/test_venv.py
--- a/tests/integration/tools/commands/test_venv.py
+++ b/tests/integration/tools/commands/test_venv.py
@@ -265,3 +265,80 @@ def colors_package_path(venv_dir):
     assert canonical_venv_listing == recursive_listing(venv_directory)
     venv = assert_app(venv_directory)
     assert canonical_venv_hash == CacheHelper.dir_hash(venv.site_packages_dir)
+
+
+def test_non_hermetic_issue_2004(
+    tmpdir,  # type: Any
+    pex_bdist,  # type: str
+):
+    # type: (...) -> None
+
+    src = os.path.join(str(tmpdir), "src")
+    with safe_open(os.path.join(src, "check_hermetic.py"), "w") as fp:
+        fp.write(
+            dedent(
+                """\
+                import os
+                import sys
+
+                def check():
+                    if os.environ["PYTHONPATH"] in sys.path:
+                        print("Not hermetic")
+                    else:
+                        print("Hermetic")
+                """
+            )
+        )
+    with safe_open(os.path.join(src, "setup.cfg"), "w") as fp:
+        fp.write(
+            dedent(
+                """\
+                [metadata]
+                name = hermeticity-checker
+                version = 0.0.1
+
+                [options]
+                py_modules =
+                    check_hermetic
+
+                [options.entry_points]
+                console_scripts =
+                    check-hermetic = check_hermetic:check
+                """
+            )
+        )
+    with safe_open(os.path.join(src, "setup.py"), "w") as fp:
+        fp.write("from setuptools import setup; setup()")
+
+    pex_root = os.path.join(str(tmpdir), "pex_root")
+    check_pex = os.path.join(str(tmpdir), "check.pex")
+    run_pex_command(
+        args=[
+            pex_bdist,
+            src,
+            "-o",
+            check_pex,
+            "--pex-root",
+            pex_root,
+            "--runtime-pex-root",
+            pex_root,
+        ]
+    ).assert_success()
+
+    hermetic_venv = os.path.join(str(tmpdir), "hermetic")
+    non_hermetic_venv = os.path.join(str(tmpdir), "non-hermetic")
+
+    run_pex_tools(check_pex, "venv", hermetic_venv).assert_success()
+    run_pex_tools(check_pex, "venv", "--non-hermetic-scripts", non_hermetic_venv).assert_success()
+
+    hermetic_check = subprocess.check_output(
+        args=[os.path.join(hermetic_venv, "bin", "check-hermetic")],
+        env=make_env(PYTHONPATH=src),
+    )
+    non_hermetic_check = subprocess.check_output(
+        args=[os.path.join(non_hermetic_venv, "bin", "check-hermetic")],
+        env=make_env(PYTHONPATH=src),
+    )
+
+    assert "Hermetic" in str(hermetic_check)
+    assert "Not hermetic" in str(non_hermetic_check)