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