diff --git a/poetry/core/json/schemas/poetry-schema.json b/poetry/core/json/schemas/poetry-schema.json index 3773e49b3..16c81bc76 100644 --- a/poetry/core/json/schemas/poetry-schema.json +++ b/poetry/core/json/schemas/poetry-schema.json @@ -163,8 +163,17 @@ "scripts": { "type": "object", "description": "A hash of scripts to be installed.", - "items": { - "type": "string" + "patternProperties": { + "^[a-zA-Z-_.0-9]+$": { + "oneOf": [ + { + "$ref": "#/definitions/script-legacy" + }, + { + "$ref": "#/definitions/script-table" + } + ] + } } }, "plugins": { @@ -513,32 +522,59 @@ ] } }, - "scripts": { + "script-table": { "type": "object", - "patternProperties": { - "^[a-zA-Z-_.0-9]+$": { - "oneOf": [ - { - "$ref": "#/definitions/script" - }, - { - "$ref": "#/definitions/extra-script" - } - ] + "oneOf": [ + { + "$ref": "#/definitions/extra-script-legacy" + }, + { + "$ref": "#/definitions/extra-scripts" } - } + ] }, - "script": { + "script-legacy": { "type": "string", "description": "A simple script pointing to a callable object." }, - "extra-script": { + "extra-scripts": { + "type": "object", + "description": "Either a console entry point or a script file that'll be included in the distribution package.", + "additionalProperties": false, + "properties": { + "reference": { + "type": "string", + "description": "If type is file this is the relative path of the script file, if console it is the module name." + }, + "type": { + "description": "Value can be either file or console.", + "type": "string", + "enum": [ + "file", + "console" + ] + }, + "extras": { + "type": "array", + "description": "The required extras for this script. Only applicable if type is console.", + "items": { + "type": "string" + } + } + }, + "required": [ + "reference", + "type" + ] + }, + "extra-script-legacy": { "type": "object", "description": "A script that should be installed only if extras are activated.", "additionalProperties": false, "properties": { "callable": { - "$ref": "#/definitions/script" + "$ref": "#/definitions/script-legacy", + "description": "The entry point of the script. Deprecated in favour of reference." }, "extras": { "type": "array", diff --git a/poetry/core/masonry/builders/builder.py b/poetry/core/masonry/builders/builder.py index 513c26960..7aa2dfd7c 100644 --- a/poetry/core/masonry/builders/builder.py +++ b/poetry/core/masonry/builders/builder.py @@ -3,6 +3,7 @@ import shutil import sys import tempfile +import warnings from collections import defaultdict from contextlib import contextmanager @@ -285,12 +286,43 @@ def convert_entry_points(self) -> Dict[str, List[str]]: # Scripts -> Entry points for name, ep in self._poetry.local_config.get("scripts", {}).items(): - extras = "" - if isinstance(ep, dict): + extras: str = "" + module_path: str = "" + + # Currently we support 2 legacy and 1 new format: + # (legacy) my_script = 'my_package.main:entry' + # (legacy) my_script = { callable = 'my_package.main:entry' } + # (supported) my_script = { reference = 'my_package.main:entry', type = "console" } + + if isinstance(ep, str): + warnings.warn( + "This way of declaring console scripts is deprecated and will be removed in a future version. " + 'Use reference = "{}", type = "console" instead.'.format(ep), + DeprecationWarning, + ) + extras = "" + module_path = ep + elif isinstance(ep, dict) and ( + ep.get("type") == "console" + or "callable" in ep # Supporting both new and legacy format for now + ): + if "callable" in ep: + warnings.warn( + "Using the keyword callable is deprecated and will be removed in a future version. " + 'Use reference = "{}", type = "console" instead.'.format( + ep["callable"] + ), + DeprecationWarning, + ) + extras = "[{}]".format(", ".join(ep["extras"])) - ep = ep["callable"] + module_path = ep.get("reference", ep.get("callable")) + else: + continue - result["console_scripts"].append("{} = {}{}".format(name, ep, extras)) + result["console_scripts"].append( + "{} = {}{}".format(name, module_path, extras) + ) # Plugins -> entry points plugins = self._poetry.local_config.get("plugins", {}) @@ -303,6 +335,29 @@ def convert_entry_points(self) -> Dict[str, List[str]]: return dict(result) + def convert_script_files(self) -> List[Path]: + script_files: List[Path] = [] + + for _, ep in self._poetry.local_config.get("scripts", {}).items(): + if isinstance(ep, dict) and ep.get("type") == "file": + source = ep["reference"] + + if Path(source).is_absolute(): + raise RuntimeError( + "{} is an absolute path. Expected relative path.".format(source) + ) + + abs_path = Path.joinpath(self._path, source) + + if not abs_path.exists(): + raise RuntimeError("{} file-script is not found.".format(abs_path)) + if not abs_path.is_file(): + raise RuntimeError("{} file-script is not a file.".format(abs_path)) + + script_files.append(abs_path) + + return script_files + @classmethod def convert_author(cls, author: str) -> Dict[str, str]: m = AUTHOR_REGEX.match(author) diff --git a/poetry/core/masonry/builders/sdist.py b/poetry/core/masonry/builders/sdist.py index e0438cd10..752cbb700 100644 --- a/poetry/core/masonry/builders/sdist.py +++ b/poetry/core/masonry/builders/sdist.py @@ -188,6 +188,12 @@ def build_setup(self) -> bytes: before.append("entry_points = \\\n{}\n".format(pformat(entry_points))) extra.append("'entry_points': entry_points,") + script_files = self.convert_script_files() + if script_files: + rel_paths = [str(p.relative_to(self._path)) for p in script_files] + before.append('scripts = \\\n["{}"]\n'.format('", "'.join(rel_paths))) + extra.append("'scripts': scripts,") + if self._package.python_versions != "*": python_requires = self._meta.requires_python @@ -314,6 +320,9 @@ def find_files_to_add(self, exclude_build: bool = False) -> Set[BuildIncludeFile license_file for license_file in self._path.glob("LICENSE*") } + # add script files + additional_files.update(self.convert_script_files()) + # Include project files additional_files.add("pyproject.toml") diff --git a/poetry/core/masonry/builders/wheel.py b/poetry/core/masonry/builders/wheel.py index 1f4c4af87..29bff308c 100644 --- a/poetry/core/masonry/builders/wheel.py +++ b/poetry/core/masonry/builders/wheel.py @@ -107,6 +107,7 @@ def build(self) -> None: self._copy_module(zip_file) self._build(zip_file) + self._copy_file_scripts(zip_file) self._write_metadata(zip_file) self._write_record(zip_file) @@ -164,6 +165,16 @@ def _build(self, wheel: zipfile.ZipFile) -> None: self._add_file(wheel, pkg, rel_path) + def _copy_file_scripts(self, wheel: zipfile.ZipFile) -> None: + file_scripts = self.convert_script_files() + + for abs_path in file_scripts: + self._add_file( + wheel, + abs_path, + Path.joinpath(Path(self.wheel_data_folder), "scripts", abs_path.name), + ) + def _run_build_command(self, setup: Path) -> None: subprocess.check_call( [ @@ -238,6 +249,10 @@ def _write_record(self, wheel: zipfile.ZipFile) -> None: def dist_info(self) -> str: return self.dist_info_name(self._package.name, self._meta.version) + @property + def wheel_data_folder(self) -> str: + return "{}-{}.data".format(self._package.name, self._meta.version) + @property def wheel_filename(self) -> str: return "{}-{}-{}.whl".format( diff --git a/tests/fixtures/complete.toml b/tests/fixtures/complete.toml index a894a89c6..8d45ac0c2 100644 --- a/tests/fixtures/complete.toml +++ b/tests/fixtures/complete.toml @@ -38,6 +38,8 @@ pytest-cov = "^2.4" [tool.poetry.scripts] my-script = 'my_package:main' +sample_pyscript = { reference = "script-files/sample_script.py", type= "file" } +sample_shscript = { reference = "script-files/sample_script.sh", type= "file" } [[tool.poetry.source]] diff --git a/tests/fixtures/script-files/sample_script.py b/tests/fixtures/script-files/sample_script.py new file mode 100644 index 000000000..2b73fa429 --- /dev/null +++ b/tests/fixtures/script-files/sample_script.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python + +hello = "Hello World!" diff --git a/tests/fixtures/script-files/sample_script.sh b/tests/fixtures/script-files/sample_script.sh new file mode 100644 index 000000000..d6954d951 --- /dev/null +++ b/tests/fixtures/script-files/sample_script.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +echo "Hello World!" diff --git a/tests/masonry/builders/fixtures/case_sensitive_exclusions/pyproject.toml b/tests/masonry/builders/fixtures/case_sensitive_exclusions/pyproject.toml index 57d25a431..3bf793edc 100644 --- a/tests/masonry/builders/fixtures/case_sensitive_exclusions/pyproject.toml +++ b/tests/masonry/builders/fixtures/case_sensitive_exclusions/pyproject.toml @@ -46,4 +46,4 @@ time = ["pendulum"] [tool.poetry.scripts] my-script = "my_package:main" my-2nd-script = "my_package:main2" -extra-script = {callable = "my_package.extra:main", extras = ["time"]} +extra-script = {reference = "my_package.extra:main", extras = ["time"], type = "console"} diff --git a/tests/masonry/builders/fixtures/complete/bin/script1.sh b/tests/masonry/builders/fixtures/complete/bin/script1.sh new file mode 100644 index 000000000..2a9686ac6 --- /dev/null +++ b/tests/masonry/builders/fixtures/complete/bin/script1.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +echo "Hello World!" \ No newline at end of file diff --git a/tests/masonry/builders/fixtures/complete/pyproject.toml b/tests/masonry/builders/fixtures/complete/pyproject.toml index d6455b7dd..711005450 100644 --- a/tests/masonry/builders/fixtures/complete/pyproject.toml +++ b/tests/masonry/builders/fixtures/complete/pyproject.toml @@ -48,7 +48,10 @@ time = ["pendulum"] [tool.poetry.scripts] my-script = "my_package:main" my-2nd-script = "my_package:main2" -extra-script = {callable = "my_package.extra:main", extras = ["time"]} +extra-script-legacy = {callable = "my_package.extra_legacy:main", extras = ["time"]} +extra-script = {reference = "my_package.extra:main", extras = ["time"], type = "console"} +sh-script = {reference = "bin/script1.sh", type = "file"} + [tool.poetry.urls] "Issue Tracker" = "https://github.com/python-poetry/poetry/issues" diff --git a/tests/masonry/builders/fixtures/invalid_case_sensitive_exclusions/pyproject.toml b/tests/masonry/builders/fixtures/invalid_case_sensitive_exclusions/pyproject.toml index 6ef10e595..44e226ccc 100644 --- a/tests/masonry/builders/fixtures/invalid_case_sensitive_exclusions/pyproject.toml +++ b/tests/masonry/builders/fixtures/invalid_case_sensitive_exclusions/pyproject.toml @@ -41,4 +41,4 @@ time = ["pendulum"] [tool.poetry.scripts] my-script = "my_package:main" my-2nd-script = "my_package:main2" -extra-script = {callable = "my_package.extra:main", extras = ["time"]} +extra-script = {reference = "my_package.extra:main", extras = ["time"], type = "console"} diff --git a/tests/masonry/builders/fixtures/licenses_and_copying/pyproject.toml b/tests/masonry/builders/fixtures/licenses_and_copying/pyproject.toml index b56bbe637..70880bcda 100644 --- a/tests/masonry/builders/fixtures/licenses_and_copying/pyproject.toml +++ b/tests/masonry/builders/fixtures/licenses_and_copying/pyproject.toml @@ -43,7 +43,7 @@ time = ["pendulum"] [tool.poetry.scripts] my-script = "my_package:main" my-2nd-script = "my_package:main2" -extra-script = {callable = "my_package.extra:main", extras = ["time"]} +extra-script = {reference = "my_package.extra:main", extras = ["time"], type = "console"} [tool.poetry.urls] "Issue Tracker" = "https://github.com/python-poetry/poetry/issues" diff --git a/tests/masonry/builders/fixtures/missing_script_files/README.rst b/tests/masonry/builders/fixtures/missing_script_files/README.rst new file mode 100644 index 000000000..f127696c5 --- /dev/null +++ b/tests/masonry/builders/fixtures/missing_script_files/README.rst @@ -0,0 +1,2 @@ +Missing Script Files +======== \ No newline at end of file diff --git a/tests/masonry/builders/fixtures/missing_script_files/missing_script_files/__init__.py b/tests/masonry/builders/fixtures/missing_script_files/missing_script_files/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/masonry/builders/fixtures/missing_script_files/pyproject.toml b/tests/masonry/builders/fixtures/missing_script_files/pyproject.toml new file mode 100644 index 000000000..94eee3fb1 --- /dev/null +++ b/tests/masonry/builders/fixtures/missing_script_files/pyproject.toml @@ -0,0 +1,15 @@ +[tool.poetry] +name = "missing-script-files" +version = "0.1" +description = "Some description." +authors = [ + "Sébastien Eustace " +] +readme = "README.rst" + +[tool.poetry.scripts] +missing_file = {reference = "not_existing_folder/not_existing_file.sh", type = "file"} + + +[tool.poetry.dependencies] +python = "3.6" \ No newline at end of file diff --git a/tests/masonry/builders/fixtures/script_file_invalid_definition/README.rst b/tests/masonry/builders/fixtures/script_file_invalid_definition/README.rst new file mode 100644 index 000000000..4f2f20661 --- /dev/null +++ b/tests/masonry/builders/fixtures/script_file_invalid_definition/README.rst @@ -0,0 +1,4 @@ +Script File Invalid Definition +======== + +This is a use case where the user provides a pyproject.toml where the file script definition is wrong. \ No newline at end of file diff --git a/tests/masonry/builders/fixtures/script_file_invalid_definition/bin/script.sh b/tests/masonry/builders/fixtures/script_file_invalid_definition/bin/script.sh new file mode 100644 index 000000000..2fcee7113 --- /dev/null +++ b/tests/masonry/builders/fixtures/script_file_invalid_definition/bin/script.sh @@ -0,0 +1 @@ +echo "Hello World" \ No newline at end of file diff --git a/tests/masonry/builders/fixtures/script_file_invalid_definition/pyproject.toml b/tests/masonry/builders/fixtures/script_file_invalid_definition/pyproject.toml new file mode 100644 index 000000000..7f2601769 --- /dev/null +++ b/tests/masonry/builders/fixtures/script_file_invalid_definition/pyproject.toml @@ -0,0 +1,15 @@ +[tool.poetry] +name = "script_file_invalid_definition" +version = "0.1" +description = "Some description." +authors = [ + "Sébastien Eustace " +] +readme = "README.rst" + +[tool.poetry.scripts] +invalid_definition = {reference = "bin/script.sh", type = "ffiillee"} + + +[tool.poetry.dependencies] +python = "3.6" \ No newline at end of file diff --git a/tests/masonry/builders/fixtures/script_file_invalid_definition/script_file_invalid_definition/__init__.py b/tests/masonry/builders/fixtures/script_file_invalid_definition/script_file_invalid_definition/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/masonry/builders/test_builder.py b/tests/masonry/builders/test_builder.py index c178ea6b8..af634e5e4 100644 --- a/tests/masonry/builders/test_builder.py +++ b/tests/masonry/builders/test_builder.py @@ -154,3 +154,28 @@ def test_metadata_with_url_dependencies(): "demo @ https://python-poetry.org/distributions/demo-0.1.0-py2.py3-none-any.whl" == requires_dist ) + + +def test_missing_script_files_throws_error(): + builder = Builder( + Factory().create_poetry( + Path(__file__).parent / "fixtures" / "missing_script_files" + ) + ) + + with pytest.raises(RuntimeError) as err: + builder.convert_script_files() + + assert "file-script is not found." in err.value.args[0] + + +def test_invalid_script_files_definition(): + with pytest.raises(RuntimeError) as err: + Builder( + Factory().create_poetry( + Path(__file__).parent / "fixtures" / "script_file_invalid_definition" + ) + ) + + assert "configuration is invalid" in err.value.args[0] + assert "[scripts.invalid_definition]" in err.value.args[0] diff --git a/tests/masonry/builders/test_complete.py b/tests/masonry/builders/test_complete.py index 8d7e647ee..7b7a986c8 100644 --- a/tests/masonry/builders/test_complete.py +++ b/tests/masonry/builders/test_complete.py @@ -223,6 +223,11 @@ def test_complete(): try: assert "my_package/sub_pgk1/extra_file.xml" not in zip.namelist() + assert "my-package-1.2.3.data/scripts/script1.sh" in zip.namelist() + assert ( + "Hello World" + in zip.read("my-package-1.2.3.data/scripts/script1.sh").decode() + ) entry_points = zip.read("my_package-1.2.3.dist-info/entry_points.txt") @@ -231,6 +236,7 @@ def test_complete(): == """\ [console_scripts] extra-script=my_package.extra:main[time] +extra-script-legacy=my_package.extra_legacy:main[time] my-2nd-script=my_package:main2 my-script=my_package:main @@ -289,6 +295,28 @@ def test_complete(): """ ) + actual_records = zip.read("my_package-1.2.3.dist-info/RECORD").decode() + + # For some reason, the ordering of the files and the SHA hashes + # vary per operating systems and Python versions. + # So instead of 1:1 assertion, let's do a bit clunkier one: + + expected_records = [ + "my_package/__init__.py", + "my_package/data1/test.json", + "my_package/sub_pkg1/__init__.py", + "my_package/sub_pkg2/__init__.py", + "my_package/sub_pkg2/data2/data.json", + "my-package-1.2.3.data/scripts/script1.sh", + "my_package-1.2.3.dist-info/entry_points.txt", + "my_package-1.2.3.dist-info/LICENSE", + "my_package-1.2.3.dist-info/WHEEL", + "my_package-1.2.3.dist-info/METADATA", + ] + + for expected_record in expected_records: + assert expected_record in actual_records + finally: zip.close() @@ -317,6 +345,7 @@ def test_complete_no_vcs(): "my_package/sub_pkg1/__init__.py", "my_package/sub_pkg2/__init__.py", "my_package/sub_pkg2/data2/data.json", + "my-package-1.2.3.data/scripts/script1.sh", "my_package/sub_pkg3/foo.py", "my_package-1.2.3.dist-info/entry_points.txt", "my_package-1.2.3.dist-info/LICENSE", @@ -335,6 +364,7 @@ def test_complete_no_vcs(): == """\ [console_scripts] extra-script=my_package.extra:main[time] +extra-script-legacy=my_package.extra_legacy:main[time] my-2nd-script=my_package:main2 my-script=my_package:main diff --git a/tests/masonry/builders/test_sdist.py b/tests/masonry/builders/test_sdist.py index effebd763..3bb8c0411 100644 --- a/tests/masonry/builders/test_sdist.py +++ b/tests/masonry/builders/test_sdist.py @@ -126,6 +126,7 @@ def test_make_setup(): assert ns["entry_points"] == { "console_scripts": [ "extra-script = my_package.extra:main[time]", + "extra-script-legacy = my_package.extra_legacy:main[time]", "my-2nd-script = my_package:main2", "my-script = my_package:main", ] @@ -170,6 +171,7 @@ def test_find_files_to_add(): [ Path("LICENSE"), Path("README.rst"), + Path("bin/script1.sh"), Path("my_package/__init__.py"), Path("my_package/data1/test.json"), Path("my_package/sub_pkg1/__init__.py"), diff --git a/tests/masonry/test_api.py b/tests/masonry/test_api.py index e0c73ab4c..47afe6ae7 100644 --- a/tests/masonry/test_api.py +++ b/tests/masonry/test_api.py @@ -133,6 +133,7 @@ def test_prepare_metadata_for_build_wheel(): entry_points = """\ [console_scripts] extra-script=my_package.extra:main[time] +extra-script-legacy=my_package.extra_legacy:main[time] my-2nd-script=my_package:main2 my-script=my_package:main