Repository: python-poetry/poetry

Issue:
"poetry install" returns a zero exit status when build.py failed
<!--
  Hi there! Thank you for discovering and submitting an issue.

  Before you submit this; let's make sure of a few things.
  Please make sure the following boxes are ticked if they are correct.
  If not, please try and fulfill these first.
-->

<!-- Checked checkbox should look like this: [x] -->
- [x] I am on the [latest](https://github.com/python-poetry/poetry/releases/latest) Poetry version.
- [x] I have searched the [issues](https://github.com/python-poetry/poetry/issues) of this repo and believe that this is not a duplicate.
- [ ] If an exception occurs when executing a command, I executed it again in debug mode (`-vvv` option).

<!--
  Once those are done, if you're able to fill in the following list with your information,
  it'd be very helpful to whoever handles the issue.
-->

- **OS version and name**: Ubuntu 18.04.5 LTS / WSL 2
- **Poetry version**: 1.1.4
- **Link of a [Gist](https://gist.github.com/) with the contents of your pyproject.toml file**: https://gist.github.com/tueda/7d970d66245106352ce56d3f5edb5fc3

## Issue
<!-- Now feel free to write your issue, but please be descriptive! Thanks again 🙌 ❤️ -->

If I use the "build.py" feature without generating `setup.py` (though it has not been stabilized yet, #2740), namely,
```toml
[tool.poetry.build]
script = "build.py"
generate-setup-file = false
```
then `poetry install` gives a zero exit status even when `build.py` failed:
```
$ cat build.py
raise AssertionError("Failed!")

$ poetry install -vvv; echo $?

...

Installing the current project: example (0.1.0)
  - Building package example in editable mode
  - Executing build script: build.py
Traceback (most recent call last):
  File "/home/tueda/tmp/test-poetry-build-failure/build.py", line 1, in <module>
    raise AssertionError("Failed!")
AssertionError: Failed!
  - Adding example.pth to /home/tueda/tmp/test-poetry-build-failure/.venv/lib/python3.9/site-packages for /home/tueda/tmp/test-poetry-build-failure
  - Removing existing example-0.1.0.dist-info directory from /home/tueda/tmp/test-poetry-build-failure/.venv/lib/python3.9/site-packages
  - Adding the example-0.1.0.dist-info directory to /home/tueda/tmp/test-poetry-build-failure/.venv/lib/python3.9/site-packages
0
```

The same thing occurs even when `build.py` does not exist:
```
$ cat build.py
cat: build.py: No such file or directory

$ poetry install -vvv; echo $?

...

Installing the current project: example (0.1.0)
  - Building package example in editable mode
  - Executing build script: build.py
/home/tueda/tmp/test-poetry-build-failure/.venv/bin/python: can't open file '/home/tueda/tmp/test-poetry-build-failure/build.py': [Errno 2] No such file or directory
  - Adding example.pth to /home/tueda/tmp/test-poetry-build-failure/.venv/lib/python3.9/site-packages for /home/tueda/tmp/test-poetry-build-failure
  - Removing existing example-0.1.0.dist-info directory from /home/tueda/tmp/test-poetry-build-failure/.venv/lib/python3.9/site-packages
  - Adding the example-0.1.0.dist-info directory to /home/tueda/tmp/test-poetry-build-failure/.venv/lib/python3.9/site-packages
0
```

In these cases, Poetry should report a build error with returning a non-zero exit status.


Discussion:
Looking over this and in the latest version of poetry (1.2.1) and its looks as if you are correct a failure or the file not existing does not produce an exit code 1 with the following config.

```
[tool.poetry.build]
script = "build.py"
generate-setup-file = false
```

Resolving patch:
diff --git a/src/poetry/utils/env.py b/src/poetry/utils/env.py
--- a/src/poetry/utils/env.py
+++ b/src/poetry/utils/env.py
@@ -1499,16 +1499,16 @@ def get_command_from_bin(self, bin: str) -> list[str]:
 
         return [str(self._bin(bin))]
 
-    def run(self, bin: str, *args: str, **kwargs: Any) -> str | int:
+    def run(self, bin: str, *args: str, **kwargs: Any) -> str:
         cmd = self.get_command_from_bin(bin) + list(args)
         return self._run(cmd, **kwargs)
 
-    def run_pip(self, *args: str, **kwargs: Any) -> int | str:
+    def run_pip(self, *args: str, **kwargs: Any) -> str:
         pip = self.get_pip_command()
         cmd = pip + list(args)
         return self._run(cmd, **kwargs)
 
-    def run_python_script(self, content: str, **kwargs: Any) -> int | str:
+    def run_python_script(self, content: str, **kwargs: Any) -> str:
         return self.run(
             self._executable,
             "-I",
@@ -1520,7 +1520,7 @@ def run_python_script(self, content: str, **kwargs: Any) -> int | str:
             **kwargs,
         )
 
-    def _run(self, cmd: list[str], **kwargs: Any) -> int | str:
+    def _run(self, cmd: list[str], **kwargs: Any) -> str:
         """
         Run a command inside the Python environment.
         """
@@ -1542,7 +1542,8 @@ def _run(self, cmd: list[str], **kwargs: Any) -> int | str:
                 ).stdout
             elif call:
                 assert stderr != subprocess.PIPE
-                return subprocess.call(cmd, stderr=stderr, env=env, **kwargs)
+                subprocess.check_call(cmd, stderr=stderr, env=env, **kwargs)
+                output = ""
             else:
                 output = subprocess.check_output(cmd, stderr=stderr, env=env, **kwargs)
         except CalledProcessError as e:
@@ -1723,19 +1724,16 @@ def __init__(self, path: Path, base: Path | None = None) -> None:
         # from inside the virtualenv.
         if base is None:
             output = self.run_python_script(GET_BASE_PREFIX)
-            assert isinstance(output, str)
             self._base = Path(output.strip())
 
     @property
     def sys_path(self) -> list[str]:
         output = self.run_python_script(GET_SYS_PATH)
-        assert isinstance(output, str)
         paths: list[str] = json.loads(output)
         return paths
 
     def get_version_info(self) -> tuple[Any, ...]:
         output = self.run_python_script(GET_PYTHON_VERSION)
-        assert isinstance(output, str)
 
         return tuple(int(s) for s in output.strip().split("."))
 
@@ -1745,20 +1743,17 @@ def get_python_implementation(self) -> str:
 
     def get_supported_tags(self) -> list[Tag]:
         output = self.run_python_script(GET_SYS_TAGS)
-        assert isinstance(output, str)
 
         return [Tag(*t) for t in json.loads(output)]
 
     def get_marker_env(self) -> dict[str, Any]:
         output = self.run_python_script(GET_ENVIRONMENT_INFO)
-        assert isinstance(output, str)
 
         env: dict[str, Any] = json.loads(output)
         return env
 
     def get_pip_version(self) -> Version:
         output = self.run_pip("--version")
-        assert isinstance(output, str)
         output = output.strip()
 
         m = re.match("pip (.+?)(?: from .+)?$", output)
@@ -1769,7 +1764,6 @@ def get_pip_version(self) -> Version:
 
     def get_paths(self) -> dict[str, str]:
         output = self.run_python_script(GET_PATHS)
-        assert isinstance(output, str)
         paths: dict[str, str] = json.loads(output)
         return paths
 
@@ -1780,7 +1774,7 @@ def is_sane(self) -> bool:
         # A virtualenv is considered sane if "python" exists.
         return os.path.exists(self.python)
 
-    def _run(self, cmd: list[str], **kwargs: Any) -> int | str:
+    def _run(self, cmd: list[str], **kwargs: Any) -> str:
         kwargs["env"] = self.get_temp_environ(environ=kwargs.get("env"))
         return super()._run(cmd, **kwargs)
 
@@ -1885,7 +1879,6 @@ def find_executables(self) -> None:
 
     def get_paths(self) -> dict[str, str]:
         output = self.run_python_script(GET_PATHS_FOR_GENERIC_ENVS)
-        assert isinstance(output, str)
 
         paths: dict[str, str] = json.loads(output)
         return paths
@@ -1902,7 +1895,7 @@ def execute(self, bin: str, *args: str, **kwargs: Any) -> int:
 
         return exe.returncode
 
-    def _run(self, cmd: list[str], **kwargs: Any) -> int | str:
+    def _run(self, cmd: list[str], **kwargs: Any) -> str:
         return super(VirtualEnv, self)._run(cmd, **kwargs)
 
     def is_venv(self) -> bool:
@@ -1932,12 +1925,12 @@ def paths(self) -> dict[str, str]:
 
         return self._paths
 
-    def _run(self, cmd: list[str], **kwargs: Any) -> int | str:
+    def _run(self, cmd: list[str], **kwargs: Any) -> str:
         self.executed.append(cmd)
 
         if self._execute:
             return super()._run(cmd, **kwargs)
-        return 0
+        return ""
 
     def execute(self, bin: str, *args: str, **kwargs: Any) -> int:
         self.executed.append([bin] + list(args))
diff --git a/src/poetry/utils/pip.py b/src/poetry/utils/pip.py
--- a/src/poetry/utils/pip.py
+++ b/src/poetry/utils/pip.py
@@ -18,7 +18,7 @@ def pip_install(
     editable: bool = False,
     deps: bool = False,
     upgrade: bool = False,
-) -> int | str:
+) -> str:
     is_wheel = path.suffix == ".whl"
 
     # We disable version check here as we are already pinning to version available in


Test patch:
diff --git a/tests/puzzle/test_provider.py b/tests/puzzle/test_provider.py
--- a/tests/puzzle/test_provider.py
+++ b/tests/puzzle/test_provider.py
@@ -36,7 +36,7 @@
 
 
 class MockEnv(BaseMockEnv):
-    def run(self, bin: str, *args: str, **kwargs: Any) -> str | int:
+    def run(self, bin: str, *args: str, **kwargs: Any) -> str:
         raise EnvCommandError(CalledProcessError(1, "python", output=""))
 
 
diff --git a/tests/utils/test_env.py b/tests/utils/test_env.py
--- a/tests/utils/test_env.py
+++ b/tests/utils/test_env.py
@@ -966,11 +966,11 @@ def test_call_with_input_and_keyboard_interrupt(
 def test_call_no_input_with_keyboard_interrupt(
     tmp_path: Path, tmp_venv: VirtualEnv, mocker: MockerFixture
 ) -> None:
-    mocker.patch("subprocess.call", side_effect=KeyboardInterrupt())
+    mocker.patch("subprocess.check_call", side_effect=KeyboardInterrupt())
     kwargs = {"call": True}
     with pytest.raises(KeyboardInterrupt):
         tmp_venv.run("python", "-", **kwargs)
-    subprocess.call.assert_called_once()
+    subprocess.check_call.assert_called_once()
 
 
 def test_run_with_called_process_error(
@@ -1010,7 +1010,7 @@ def test_call_no_input_with_called_process_error(
     tmp_path: Path, tmp_venv: VirtualEnv, mocker: MockerFixture
 ) -> None:
     mocker.patch(
-        "subprocess.call",
+        "subprocess.check_call",
         side_effect=subprocess.CalledProcessError(
             42, "some_command", "some output", "some error"
         ),
@@ -1018,7 +1018,7 @@ def test_call_no_input_with_called_process_error(
     kwargs = {"call": True}
     with pytest.raises(EnvCommandError) as error:
         tmp_venv.run("python", "-", **kwargs)
-    subprocess.call.assert_called_once()
+    subprocess.check_call.assert_called_once()
     assert "some output" in str(error.value)
     assert "some error" in str(error.value)
 
@@ -1054,9 +1054,10 @@ def test_call_does_not_block_on_full_pipe(
     )
 
     def target(result: list[int]) -> None:
-        result.append(tmp_venv.run("python", str(script), call=True))
+        tmp_venv.run("python", str(script), call=True)
+        result.append(0)
 
-    results = []
+    results: list[int] = []
     # use a separate thread, so that the test does not block in case of error
     thread = Thread(target=target, args=(results,))
     thread.start()