Skip to content

Commit

Permalink
Merge pull request #81 from sirosen/detect-script-shebangs
Browse files Browse the repository at this point in the history
Add detection for scripts by shebang line
  • Loading branch information
jaraco committed Sep 8, 2023
2 parents 1eaf4da + 33d78db commit 4579808
Show file tree
Hide file tree
Showing 5 changed files with 68 additions and 11 deletions.
8 changes: 3 additions & 5 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -244,16 +244,14 @@ 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
there are (use ``pip-run -v -- myscript`` to see the list). A script may cache those
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
Expand Down
1 change: 1 addition & 0 deletions newsfragments/78.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Presence of Python script parameters now honors files with a shebang even if no Python extension is present.
10 changes: 9 additions & 1 deletion pip_run/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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

Expand All @@ -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):
Expand Down
41 changes: 41 additions & 0 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
@@ -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)
19 changes: 14 additions & 5 deletions tests/test_scripts.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import itertools
import textwrap
import sys
import subprocess
Expand All @@ -14,24 +15,32 @@ 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")
"""
)
},
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
Expand Down

0 comments on commit 4579808

Please sign in to comment.