diff --git a/news/5294.feature.rst b/news/5294.feature.rst new file mode 100644 index 0000000000..cf4e7eefb1 --- /dev/null +++ b/news/5294.feature.rst @@ -0,0 +1 @@ +Add ability for callable scripts in Pipfile under [scripts]. Callables can now be added like: ``:`` and can also take arguments. For exaple: ``func = {call = "package.module:func('arg1', 'arg2')"}`` then this can be activated in the shell with ``pipenv run func`` diff --git a/pipenv/cmdparse.py b/pipenv/cmdparse.py index 673cf80cda..0056a09cd1 100644 --- a/pipenv/cmdparse.py +++ b/pipenv/cmdparse.py @@ -2,23 +2,50 @@ import re import shlex +from pipenv.vendor import tomlkit + class ScriptEmptyError(ValueError): pass +class ScriptParseError(ValueError): + pass + + def _quote_if_contains(value, pattern): if next(iter(re.finditer(pattern, value)), None): return '"{0}"'.format(re.sub(r'(\\*)"', r'\1\1\\"', value)) return value +def _parse_toml_inline_table(value: tomlkit.items.InlineTable) -> str: + """parses the [scripts] in pipfile and converts: `{call = "package.module:func('arg')"}` into an executable command""" + keys_list = list(value.keys()) + if len(keys_list) > 1: + raise ScriptParseError("More than 1 key in toml script line") + cmd_key = keys_list[0] + if cmd_key not in Script.script_types: + raise ScriptParseError( + f"Not an accepted script callabale, options are: {Script.script_types}" + ) + if cmd_key == "call": + module, _, func = str(value["call"]).partition(":") + if not module or not func: + raise ScriptParseError("Callable must be like: :") + if re.search(r"\(.*?\)", func) is None: + func += "()" + return f'python -c "import {module} as _m; _m.{func}"' + + class Script(object): """Parse a script line (in Pipfile's [scripts] section). This always works in POSIX mode, even on Windows. """ + script_types = ["call"] + def __init__(self, command, args=None): self._parts = [command] if args: @@ -26,7 +53,10 @@ def __init__(self, command, args=None): @classmethod def parse(cls, value): - if isinstance(value, str): + if isinstance(value, tomlkit.items.InlineTable): + cmd_string = _parse_toml_inline_table(value) + value = shlex.split(cmd_string) + elif isinstance(value, str): value = shlex.split(value) if not value: raise ScriptEmptyError(value) diff --git a/tests/integration/test_run.py b/tests/integration/test_run.py index d09a027b5d..e5c890f798 100644 --- a/tests/integration/test_run.py +++ b/tests/integration/test_run.py @@ -4,6 +4,7 @@ from pipenv.project import Project from pipenv.utils.shell import subprocess_run, temp_environ +from pipenv.utils.shell import mkdir_p @pytest.mark.run @@ -63,6 +64,36 @@ def test_scripts(pipenv_instance_pypi): assert c.stdout.strip() == "WORLD" +@pytest.mark.run +def test_scripts_with_package_functions(pipenv_instance_pypi): + with pipenv_instance_pypi(chdir=True) as p: + p.pipenv('install') + pkg_path = os.path.join(p.path, "pkg") + mkdir_p(pkg_path) + file_path = os.path.join(pkg_path, "mod.py") + with open(file_path, "w+") as f: + f.write(""" +def test_func(): + print("success") + +def arg_func(s, i): + print(f"{s.upper()}. Easy as {i}") +""") + + with open(p.pipfile_path, 'w') as f: + f.write(r""" +[scripts] +pkgfunc = {call = "pkg.mod:test_func"} +argfunc = {call = "pkg.mod:arg_func('abc', 123)"} + """) + + c = p.pipenv('run pkgfunc') + assert c.stdout.strip() == "success" + + c = p.pipenv('run argfunc') + assert c.stdout.strip() == "ABC. Easy as 123" + + @pytest.mark.run @pytest.mark.skip_windows def test_run_with_usr_env_shebang(pipenv_instance_pypi): diff --git a/tests/unit/test_vendor.py b/tests/unit/test_vendor.py index ee25045260..32e594b3b1 100644 --- a/tests/unit/test_vendor.py +++ b/tests/unit/test_vendor.py @@ -7,7 +7,7 @@ import pytest import pytz -import tomlkit +from pipenv.vendor import tomlkit @pytest.mark.parametrize('dt, content', [