diff --git a/src/poetry/masonry/builders/editable.py b/src/poetry/masonry/builders/editable.py index 47904bbdab9..58c6c86e07d 100644 --- a/src/poetry/masonry/builders/editable.py +++ b/src/poetry/masonry/builders/editable.py @@ -4,6 +4,7 @@ import hashlib import json import os +import shutil from base64 import urlsafe_b64encode from pathlib import Path @@ -211,6 +212,43 @@ def _add_scripts(self) -> list[Path]: added.append(cmd_script) + # Handle file scripts (type = "file" in [tool.poetry.scripts]) + for name, specification in self._poetry.local_config.get( + "scripts", {} + ).items(): + if isinstance(specification, dict) and specification.get("type") == "file": + source = specification.get("reference") + if not source: + self._io.write_error_line( + f" - File script {name} is missing" + " a \"reference\" field" + ) + continue + source_path = self._path / source + + if not source_path.exists(): + self._io.write_error_line( + f" - File script {name} references" + f" {source} which does not exist" + ) + continue + + if not source_path.is_file(): + self._io.write_error_line( + f" - File script {name} references" + f" {source} which is not a file" + ) + continue + + target = scripts_path.joinpath(name) + self._debug( + f" - Adding the {name} file script" + f" to {scripts_path}" + ) + shutil.copy2(source_path, target) + target.chmod(0o755) + added.append(target) + return added def _add_dist_info(self, added_files: list[Path]) -> None: diff --git a/tests/fixtures/file_scripts_dir_ref_project/file_scripts_dir_ref_project/__init__.py b/tests/fixtures/file_scripts_dir_ref_project/file_scripts_dir_ref_project/__init__.py new file mode 100644 index 00000000000..33f27e6caa8 --- /dev/null +++ b/tests/fixtures/file_scripts_dir_ref_project/file_scripts_dir_ref_project/__init__.py @@ -0,0 +1,2 @@ +def main(): + print("Hello from console entry point") diff --git a/tests/fixtures/file_scripts_dir_ref_project/pyproject.toml b/tests/fixtures/file_scripts_dir_ref_project/pyproject.toml new file mode 100644 index 00000000000..6fb44d6fa1e --- /dev/null +++ b/tests/fixtures/file_scripts_dir_ref_project/pyproject.toml @@ -0,0 +1,16 @@ +[tool.poetry] +name = "file-scripts-dir-ref-project" +version = "1.0.0" +description = "A project with a file script referencing a directory." +authors = ["Test Author "] + +[tool.poetry.dependencies] +python = "^3.7" + +[tool.poetry.scripts] +dir-script = { reference = "bin/some-directory", type = "file" } +console-entry = "file_scripts_dir_ref_project:main" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/tests/fixtures/file_scripts_missing_ref_project/file_scripts_missing_ref_project/__init__.py b/tests/fixtures/file_scripts_missing_ref_project/file_scripts_missing_ref_project/__init__.py new file mode 100644 index 00000000000..33f27e6caa8 --- /dev/null +++ b/tests/fixtures/file_scripts_missing_ref_project/file_scripts_missing_ref_project/__init__.py @@ -0,0 +1,2 @@ +def main(): + print("Hello from console entry point") diff --git a/tests/fixtures/file_scripts_missing_ref_project/pyproject.toml b/tests/fixtures/file_scripts_missing_ref_project/pyproject.toml new file mode 100644 index 00000000000..457e5ac44b4 --- /dev/null +++ b/tests/fixtures/file_scripts_missing_ref_project/pyproject.toml @@ -0,0 +1,16 @@ +[tool.poetry] +name = "file-scripts-missing-ref-project" +version = "1.0.0" +description = "A project with a file script referencing a non-existent file." +authors = ["Test Author "] + +[tool.poetry.dependencies] +python = "^3.7" + +[tool.poetry.scripts] +missing-script = { reference = "bin/does-not-exist.sh", type = "file" } +console-entry = "file_scripts_missing_ref_project:main" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/tests/fixtures/file_scripts_no_ref_field_project/file_scripts_no_ref_field_project/__init__.py b/tests/fixtures/file_scripts_no_ref_field_project/file_scripts_no_ref_field_project/__init__.py new file mode 100644 index 00000000000..33f27e6caa8 --- /dev/null +++ b/tests/fixtures/file_scripts_no_ref_field_project/file_scripts_no_ref_field_project/__init__.py @@ -0,0 +1,2 @@ +def main(): + print("Hello from console entry point") diff --git a/tests/fixtures/file_scripts_no_ref_field_project/pyproject.toml b/tests/fixtures/file_scripts_no_ref_field_project/pyproject.toml new file mode 100644 index 00000000000..19da30fade9 --- /dev/null +++ b/tests/fixtures/file_scripts_no_ref_field_project/pyproject.toml @@ -0,0 +1,16 @@ +[tool.poetry] +name = "file-scripts-no-ref-field-project" +version = "1.0.0" +description = "A project with a file script missing the reference field." +authors = ["Test Author "] + +[tool.poetry.dependencies] +python = "^3.7" + +[tool.poetry.scripts] +no-ref-script = { type = "file" } +console-entry = "file_scripts_no_ref_field_project:main" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/tests/fixtures/file_scripts_project/bin/my-script.sh b/tests/fixtures/file_scripts_project/bin/my-script.sh new file mode 100755 index 00000000000..2278b5aa584 --- /dev/null +++ b/tests/fixtures/file_scripts_project/bin/my-script.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo "Hello from file script" diff --git a/tests/fixtures/file_scripts_project/file_scripts_project/__init__.py b/tests/fixtures/file_scripts_project/file_scripts_project/__init__.py new file mode 100644 index 00000000000..33f27e6caa8 --- /dev/null +++ b/tests/fixtures/file_scripts_project/file_scripts_project/__init__.py @@ -0,0 +1,2 @@ +def main(): + print("Hello from console entry point") diff --git a/tests/fixtures/file_scripts_project/pyproject.toml b/tests/fixtures/file_scripts_project/pyproject.toml new file mode 100644 index 00000000000..cddd15b1411 --- /dev/null +++ b/tests/fixtures/file_scripts_project/pyproject.toml @@ -0,0 +1,16 @@ +[tool.poetry] +name = "file-scripts-project" +version = "1.0.0" +description = "A project with file scripts." +authors = ["Test Author "] + +[tool.poetry.dependencies] +python = "^3.7" + +[tool.poetry.scripts] +my-script = { reference = "bin/my-script.sh", type = "file" } +console-entry = "file_scripts_project:main" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/tests/masonry/builders/test_editable_builder.py b/tests/masonry/builders/test_editable_builder.py index 958c33c4350..2d675721a5d 100644 --- a/tests/masonry/builders/test_editable_builder.py +++ b/tests/masonry/builders/test_editable_builder.py @@ -422,3 +422,123 @@ def test_builder_catches_bad_scripts_too_many_colon( assert "foo::bar" in msg # and some hint about what is wrong assert "Too many" in msg + + +@pytest.fixture() +def file_scripts_poetry(fixture_dir: FixtureDirGetter) -> Poetry: + poetry = Factory().create_poetry(fixture_dir("file_scripts_project")) + return poetry + + +def test_builder_installs_file_scripts( + file_scripts_poetry: Poetry, + tmp_path: Path, +) -> None: + env_manager = EnvManager(file_scripts_poetry) + venv_path = tmp_path / "venv" + env_manager.build_venv(venv_path) + tmp_venv = VirtualEnv(venv_path) + + builder = EditableBuilder(file_scripts_poetry, tmp_venv, NullIO()) + builder.build() + + # The file script should be copied to the venv bin directory + script_path = tmp_venv._bin_dir.joinpath("my-script") + assert script_path.exists(), ( + f"File script 'my-script' was not copied to {tmp_venv._bin_dir}" + ) + + # Check script content matches the source + source_content = ( + file_scripts_poetry.file.path.parent / "bin" / "my-script.sh" + ).read_text(encoding="utf-8") + assert script_path.read_text(encoding="utf-8") == source_content + + # Check the file is executable + assert os.access(script_path, os.X_OK) + + # The console entry point should also be installed + console_script = tmp_venv._bin_dir.joinpath("console-entry") + assert console_script.exists(), ( + f"Console script 'console-entry' was not installed to {tmp_venv._bin_dir}" + ) + + +def test_builder_skips_missing_file_script( + fixture_dir: FixtureDirGetter, + tmp_path: Path, +) -> None: + from cleo.io.buffered_io import BufferedIO + + poetry = Factory().create_poetry(fixture_dir("file_scripts_missing_ref_project")) + env_manager = EnvManager(poetry) + venv_path = tmp_path / "venv" + env_manager.build_venv(venv_path) + tmp_venv = VirtualEnv(venv_path) + + io = BufferedIO() + builder = EditableBuilder(poetry, tmp_venv, io) + builder.build() + + # The file script for the missing reference must not be created + script_path = tmp_venv._bin_dir.joinpath("missing-script") + assert not script_path.exists() + + # The error message should be logged + error_output = io.fetch_error() + assert "missing-script" in error_output + assert "does not exist" in error_output + + +def test_builder_skips_directory_file_script( + fixture_dir: FixtureDirGetter, + tmp_path: Path, +) -> None: + from cleo.io.buffered_io import BufferedIO + + poetry = Factory().create_poetry(fixture_dir("file_scripts_dir_ref_project")) + env_manager = EnvManager(poetry) + venv_path = tmp_path / "venv" + env_manager.build_venv(venv_path) + tmp_venv = VirtualEnv(venv_path) + + io = BufferedIO() + builder = EditableBuilder(poetry, tmp_venv, io) + builder.build() + + # The file script for the directory reference must not be created + script_path = tmp_venv._bin_dir.joinpath("dir-script") + assert not script_path.exists() + + # The error message should be logged + error_output = io.fetch_error() + assert "dir-script" in error_output + assert "is not a file" in error_output + + +def test_builder_skips_file_script_missing_reference_field( + fixture_dir: FixtureDirGetter, + tmp_path: Path, +) -> None: + from cleo.io.buffered_io import BufferedIO + + poetry = Factory().create_poetry( + fixture_dir("file_scripts_no_ref_field_project") + ) + env_manager = EnvManager(poetry) + venv_path = tmp_path / "venv" + env_manager.build_venv(venv_path) + tmp_venv = VirtualEnv(venv_path) + + io = BufferedIO() + builder = EditableBuilder(poetry, tmp_venv, io) + builder.build() + + # The file script with missing reference field must not be created + script_path = tmp_venv._bin_dir.joinpath("no-ref-script") + assert not script_path.exists() + + # The error message should be logged + error_output = io.fetch_error() + assert "no-ref-script" in error_output + assert "reference" in error_output