Repository: jaraco/pip-run

Issue:
Improve support for standalone scripts with shebang lines and possible "reinvocation"
I've been trying to upgrade some scripts in my `~/bin` to use `pip-run`, and I'd like them to bootstrap themselves so that they can
- be fully self-contained
- have a valid shebang line

So an example of what I have today would be
```python
#!/usr/bin/env python
# Requirements:
# requests
try:
    import requests
except ImportError:
    import os
    import shutil
    import sys

    path = shutil.which("pip-run")
    if not path:
        print("pip-run not found", file=sys.stderr)
        sys.exit(1)
    os.execv(path, [path, "--", sys.argv[0]])

print(requests.get("some-url").content)
```

This is a little brittle but it seems to work.

The next thing I wanted was to catch the case in which the `pip-run` invocation has happened, but some requirement was not satisfied (e.g. because there's a typo in the requirements data). To do this, I need to pass some additional information to the `pip-run` invocation to indicate that it's been called already. I seem to get what I want by injecting an environment variable and switching to `execve`:
```python
try:
    import requests
except ImportError:
    if os.getenv("PIP_RUN_INVOKED_ONCE") == "1":
        print("pip-run did not satisfy requirements", file=sys.stderr)
        sys.exit(1)

    path = shutil.which("pip-run")
    if not path:
        print("pip-run not found", file=sys.stderr)
        sys.exit(1)
    env = os.environ.copy()
    env["PIP_RUN_INVOKED_ONCE"] = "1"
    os.execve(path, [path, "--", sys.argv[0]], env)
```

All of this is great! It's a relatively small bit of boilerplate to add to my scripts to get "self-invoking" `pip-run` usage in my personal `bin/` dir. But it seems like there's room for improvement. The first thing that comes to mind is that it would be nice if `PIP_RUN_INVOKED_ONCE` was set for me by `pip-run`.
The other thing which would be awesome is if I could switch to using `#!/usr/bin/env pip-run` and have that work. Unfortunately, that doesn't play nicely if the script name isn't `.py` (and mine are generally not).

There might be other ways to improve things in `#!/usr/bin/env pip-run` is supported as a usage pattern, so I do not want to limit this feature request to an env var or shebang-style usage.

Discussion:

Patch:
diff --git a/README.rst b/README.rst
index 5f7655f..ab1f750 100644
--- a/README.rst
+++ b/README.rst
@@ -75,7 +75,7 @@ Usage
 - as runtime dependency context manager
 - as interactive interpreter in dependency context
 - as module launcher (akin to `python -m`)
-- as a shell shebang (``#!/usr/bin/env -S pip-run --``), to create single-file Python tools
+- as a shell shebang (``#!/usr/bin/env pip-run``), to create single-file Python tools
 
 Invoke ``pip-run`` from the command-line using the console entry
 script (simply ``pip-run``) or using the module executable (
@@ -234,7 +234,7 @@ as ``pip-run`` is installed on the system ``PATH``.
 
 .. code-block:: shell
 
-    #!/usr/bin/env -S pip-run --
+    #!/usr/bin/env pip-run
     __requires__ = ['requests', 'beautifulsoup4', 'cowsay']
     import requests
     from bs4 import BeautifulSoup as BS
@@ -244,8 +244,6 @@ as ``pip-run`` is installed on the system ``PATH``.
     cowsay.dragon(b.find("div", class_="introduction").get_text())
 
 Executing this script, when saved as ``myscript``, is equivalent to ``pip-run -- myscript``.
-``-S`` and ``--`` ensure that extension-less scripts (like ``myscript`` rather than ``myscript.py``)
-will be correctly identified as a script (rather than as a dependency), so don't forget them.
 
 By default, ``pip-run`` will re-install dependencies every time a script runs.
 This silently adds a variable amount of startup time depending on how many dependencies
@@ -253,7 +251,7 @@ there are (use ``pip-run -v -- myscript`` to see the list). A script may cache t
 installs via `Environment Persistence <#Environment-Persistence>`_ by setting
 ``PIP_RUN_MODE=persist``::
 
-    #!/usr/bin/env -S PIP_RUN_MODE=persist pip-run --
+    #!/usr/bin/env PIP_RUN_MODE=persist pip-run
     ...
 
 Other Script Directives
diff --git a/newsfragments/78.feature.rst b/newsfragments/78.feature.rst
new file mode 100644
index 0000000..3d1942b
--- /dev/null
+++ b/newsfragments/78.feature.rst
@@ -0,0 +1,1 @@
+Presence of Python script parameters now honors files with a shebang even if no Python extension is present.
\ No newline at end of file
diff --git a/pip_run/commands.py b/pip_run/commands.py
index f2fda47..20498a2 100644
--- a/pip_run/commands.py
+++ b/pip_run/commands.py
@@ -5,6 +5,7 @@ import argparse
 from more_itertools import locate, split_at
 from jaraco.functools import bypass_when
 from jaraco import env  # type: ignore # (python/mypy#15970)
+from jaraco.context import suppress
 
 from ._py38compat import files
 
@@ -15,7 +16,14 @@ def _is_python_arg(item: str):
     to Python and not to pip install.
     """
     path = pathlib.Path(item)
-    return path.is_file() and path.suffix == '.py'
+    return path.is_file() and (path.suffix == '.py' or _has_shebang(path))
+
+
+@suppress(UnicodeDecodeError)
+def _has_shebang(path: pathlib.Path) -> bool:
+    with path.open(encoding='utf-8-sig') as fp:
+        first_line = fp.readline()
+    return first_line.startswith("#!")
 
 
 def _separate_script(args):


Test patch:
diff --git a/tests/test_commands.py b/tests/test_commands.py
new file mode 100644
index 0000000..3b877b7
--- /dev/null
+++ b/tests/test_commands.py
@@ -0,0 +1,41 @@
+import pytest
+
+from pip_run import commands
+
+
+valid_shebangs = [
+    '#!/usr/bin/env python',
+    '#!/usr/bin/env -S pip-run',
+    '#!/usr/bin/python -W error',
+    # with BOM
+    '#!/usr/bin/env -S python'.encode('utf-8-sig').decode('utf-8'),
+]
+
+invalid_shebangs = [
+    '#/usr/bin/env python',
+    '!/usr/bin/env python',
+]
+
+
+@pytest.mark.parametrize('shebang', valid_shebangs)
+def test_shebang_detected(tmp_path, shebang):
+    script = tmp_path / 'script'
+    script.write_text(f'{shebang}\nprint("Hello world!")', encoding='utf-8')
+    assert commands._has_shebang(script)
+
+
+@pytest.mark.parametrize('shebang', invalid_shebangs)
+def test_shebang_not_detected(tmp_path, shebang):
+    script = tmp_path / 'script'
+    script.write_text(f'{shebang}\nprint("Hello world!")', encoding='utf-8')
+    assert not commands._has_shebang(script)
+
+
+def test_shebang_invalid_encoding(tmp_path):
+    """
+    If the script cannot be decoded in UTF-8, value should be false.
+    """
+    script = tmp_path / 'script'
+    shebang = '\xf1#!/usr/bin/env -S python'
+    script.write_text(f'{shebang}\nprint("Hello world!")', encoding='latin-1')
+    assert not commands._has_shebang(script)
diff --git a/tests/test_scripts.py b/tests/test_scripts.py
index 4bae8c0..c610955 100644
--- a/tests/test_scripts.py
+++ b/tests/test_scripts.py
@@ -1,3 +1,4 @@
+import itertools
 import textwrap
 import sys
 import subprocess
@@ -14,14 +15,21 @@ def DALS(str):
 
 
 @pytest.mark.network
-def test_pkg_imported(tmp_path):
+@pytest.mark.parametrize(
+    'shebang, extension, dash',
+    itertools.product(('', '#!/usr/bin/env python'), ('', '.py'), ([], ['--'])),
+)
+def test_pkg_imported(tmp_path, shebang, extension, dash):
     """
     Create a script that loads a package and ensure it runs.
     """
+    shebang or extension or dash or pytest.skip('Unable to infer script')
+    scriptname = 'script' + extension
     jaraco.path.build(
         {
-            'script': DALS(
-                """
+            scriptname: DALS(
+                f"""
+                {shebang}
                 import sample
                 print("Import succeeded")
                 """
@@ -29,9 +37,10 @@ def test_pkg_imported(tmp_path):
         },
         tmp_path,
     )
-    script = tmp_path / 'script'
+    script = tmp_path / scriptname
     pip_args = ['sampleproject']
-    cmd = [sys.executable, '-m', 'pip-run'] + pip_args + ['--', str(script)]
+    script_args = dash + [str(script)]
+    cmd = [sys.executable, '-m', 'pip-run'] + pip_args + script_args
 
     out = subprocess.check_output(cmd, text=True, encoding='utf-8')
     assert 'Import succeeded' in out