From c956178b4c9a92781003782941b8a9bb4de3c084 Mon Sep 17 00:00:00 2001 From: Mr-Neutr0n <64578610+Mr-Neutr0n@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:08:39 +0530 Subject: [PATCH 1/2] Copy file scripts to venv bin directory on poetry install When pyproject.toml defines scripts with type = "file" (e.g., my-command = { reference = "my-script.sh", type = "file" }), poetry install now copies them into the virtualenv's bin/ directory, matching the behavior of poetry build which already included them in wheels. The EditableBuilder._add_scripts() method previously only handled console_scripts entry points. This adds a second pass that iterates over [tool.poetry.scripts], finds entries with type = "file", and copies the referenced files to the scripts directory with proper executable permissions. Fixes #10664 --- src/poetry/masonry/builders/editable.py | 32 +++++++++++++++ .../file_scripts_project/bin/my-script.sh | 2 + .../file_scripts_project/__init__.py | 2 + .../file_scripts_project/pyproject.toml | 16 ++++++++ .../masonry/builders/test_editable_builder.py | 40 +++++++++++++++++++ 5 files changed, 92 insertions(+) create mode 100755 tests/fixtures/file_scripts_project/bin/my-script.sh create mode 100644 tests/fixtures/file_scripts_project/file_scripts_project/__init__.py create mode 100644 tests/fixtures/file_scripts_project/pyproject.toml diff --git a/src/poetry/masonry/builders/editable.py b/src/poetry/masonry/builders/editable.py index 47904bbdab9..9690e5b45b2 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,37 @@ 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["reference"] + 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_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..a59d1507f58 100644 --- a/tests/masonry/builders/test_editable_builder.py +++ b/tests/masonry/builders/test_editable_builder.py @@ -422,3 +422,43 @@ 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}" + ) From a737e659eae1a1466b342f6d9c396b553a3adc69 Mon Sep 17 00:00:00 2001 From: Mr-Neutr0n <64578610+Mr-Neutr0n@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:24:49 +0530 Subject: [PATCH 2/2] fix: use .get() for file script reference and add failure mode tests Use specification.get('reference') instead of specification['reference'] to avoid KeyError on malformed configs. Add a validation error message when the reference field is missing. Add tests covering three failure modes: - File script with non-existent reference path - File script referencing a directory instead of a file - File script missing the reference field entirely --- src/poetry/masonry/builders/editable.py | 8 +- .../file_scripts_dir_ref_project/__init__.py | 2 + .../pyproject.toml | 16 ++++ .../__init__.py | 2 + .../pyproject.toml | 16 ++++ .../__init__.py | 2 + .../pyproject.toml | 16 ++++ .../masonry/builders/test_editable_builder.py | 80 +++++++++++++++++++ 8 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/file_scripts_dir_ref_project/file_scripts_dir_ref_project/__init__.py create mode 100644 tests/fixtures/file_scripts_dir_ref_project/pyproject.toml create mode 100644 tests/fixtures/file_scripts_missing_ref_project/file_scripts_missing_ref_project/__init__.py create mode 100644 tests/fixtures/file_scripts_missing_ref_project/pyproject.toml create mode 100644 tests/fixtures/file_scripts_no_ref_field_project/file_scripts_no_ref_field_project/__init__.py create mode 100644 tests/fixtures/file_scripts_no_ref_field_project/pyproject.toml diff --git a/src/poetry/masonry/builders/editable.py b/src/poetry/masonry/builders/editable.py index 9690e5b45b2..58c6c86e07d 100644 --- a/src/poetry/masonry/builders/editable.py +++ b/src/poetry/masonry/builders/editable.py @@ -217,7 +217,13 @@ def _add_scripts(self) -> list[Path]: "scripts", {} ).items(): if isinstance(specification, dict) and specification.get("type") == "file": - source = specification["reference"] + 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(): 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/masonry/builders/test_editable_builder.py b/tests/masonry/builders/test_editable_builder.py index a59d1507f58..2d675721a5d 100644 --- a/tests/masonry/builders/test_editable_builder.py +++ b/tests/masonry/builders/test_editable_builder.py @@ -462,3 +462,83 @@ def test_builder_installs_file_scripts( 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