Repository: python-poetry/poetry

Issue:
Exit code relay fails when run command is a script
<!--
    Hi there! Thank you for submitting a bug report!

    Before you submit your issue, please review and follow the instructions at
    https://python-poetry.org/docs/contributing/#reporting-bugs. Fully fill out the template below; insufficient
    information or bad reproduction instructions will impair the ability of others to help you.
-->

<!-- All the below information must be provided for others to understand and help with your issue. -->
- **Poetry version**: `1.2.2`
- **Python version**: `3.10.6`
- **OS version and name**: macOS 12.5.1
- **pyproject.toml**: Relevant details below

```
[tool.poetry.scripts]
toolname = "toolname.__main__:main"
```

<!-- All the below steps should be completed before submitting your issue. Checked checkbox should look like this: [x] -->
- [x] I am on the [latest](https://github.com/python-poetry/poetry/releases/latest) stable Poetry version, installed using a recommended method.
- [x] I have searched the [issues](https://github.com/python-poetry/poetry/issues) of this repo and believe that this is not a duplicate.
- [x] I have consulted the [FAQ](https://python-poetry.org/docs/faq/) and [blog](https://python-poetry.org/blog/) for any relevant entries or release notes.
- [x] If an exception occurs when executing a command, I executed it again in debug mode (`-vvv` option) and have included the output below.

## Issue
<!-- Now feel free to write your issue, and please be as descriptive as possible! Make sure to include detailed reproduction steps. -->
<!-- Thanks again 🙌 ❤ -->

Hi there,

When using `poetry run` to run a script defined in the [`scripts`](https://python-poetry.org/docs/pyproject/#scripts) section of `pyproject.toml` the exit code of the script is not relayed as the final exit code of `poetry` itself. I originally noticed https://github.com/python-poetry/poetry/issues/5773 and https://github.com/python-poetry/poetry/issues/2369 and was wondering why it wasn't working for me.

Consider the following cases:

```
$ poetry run python -m toolname --version
0.5.0
$ echo $?
1
```

```
$ poetry run toolname --version
0.5.0
$ echo $?
0
```

It looks like https://github.com/python-poetry/poetry/pull/4456 did not fix https://github.com/python-poetry/poetry/issues/2369 because these are different execution paths. Looking at [`commands/run.py`](https://github.com/python-poetry/poetry/blob/16046d9ac9b72a49e1bc4618fb686695cc64821c/src/poetry/console/commands/run.py#L61), running a script (as defined in `pyproject.toml`) is a different execution path than running non-scripts. As mentioned in https://github.com/python-poetry/poetry/issues/2369, I believe the fix would be:

`f"import_module('{module}').{callable_}()"` -> `f"sys.exit(import_module('{module}').{callable_}())"`

I made this change locally and it fixed this issue for me.

Alternatively, I believe another fix would be removing the `RunCommand.run_script` special-case code. Since `poetry` knows where the script is (`$VIRTUALENV/bin/`), it could include this directory in the `$PATH` when executing the command. `poetry` would then execute the script generated by [`builders/editable.py`](https://github.com/python-poetry/poetry/blob/master/src/poetry/masonry/builders/editable.py#L31).

There may be reasons these two code paths are distinct that I don't understand, but if they can be unified to a single path that would ease maintenance burden and help alleviate situations like this where one path is updated but the other is forgotten.


Discussion:
works fine for me, can you put together a reproduction eg in a dockerfile?

```python
$ cat toolname/__main__.py
#!/usr/bin/env python3

def main():
    import sys
    print("hello")
    sys.exit(4)


if __name__ == "__main__":
    main()
```

```
$ poetry run python -m toolname
hello
$ echo $?
4
$ poetry run toolname
hello
$ echo $?
4
```
oh I see, you don't mean that the _exit_ code is not relayed, you mean that the _return_ code is not relayed, as an exit code.

(Apologies if this seems pedantic, but this is what confused me)

I expect an MR would be welcome, either of the approaches that you suggest sounds reasonable. 

Resolving patch:
diff --git a/src/poetry/console/commands/run.py b/src/poetry/console/commands/run.py
--- a/src/poetry/console/commands/run.py
+++ b/src/poetry/console/commands/run.py
@@ -58,7 +58,7 @@ def run_script(self, script: str | dict[str, str], args: str) -> int:
             "import sys; "
             "from importlib import import_module; "
             f"sys.argv = {args!r}; {src_in_sys_path}"
-            f"import_module('{module}').{callable_}()"
+            f"sys.exit(import_module('{module}').{callable_}())"
         ]
 
         return self.env.execute(*cmd)


Test patch:
diff --git a/tests/console/commands/test_run.py b/tests/console/commands/test_run.py
--- a/tests/console/commands/test_run.py
+++ b/tests/console/commands/test_run.py
@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+import subprocess
+
 from typing import TYPE_CHECKING
 
 import pytest
@@ -12,9 +14,12 @@
     from cleo.testers.command_tester import CommandTester
     from pytest_mock import MockerFixture
 
+    from poetry.poetry import Poetry
     from poetry.utils.env import MockEnv
     from poetry.utils.env import VirtualEnv
     from tests.types import CommandTesterFactory
+    from tests.types import FixtureDirGetter
+    from tests.types import ProjectFactory
 
 
 @pytest.fixture
@@ -27,6 +32,19 @@ def patches(mocker: MockerFixture, env: MockEnv) -> None:
     mocker.patch("poetry.utils.env.EnvManager.get", return_value=env)
 
 
+@pytest.fixture
+def poetry_with_scripts(
+    project_factory: ProjectFactory, fixture_dir: FixtureDirGetter
+) -> Poetry:
+    source = fixture_dir("scripts")
+
+    return project_factory(
+        name="scripts",
+        pyproject_content=(source / "pyproject.toml").read_text(encoding="utf-8"),
+        source=source,
+    )
+
+
 def test_run_passes_all_args(app_tester: ApplicationTester, env: MockEnv):
     app_tester.execute("run python -V")
     assert [["python", "-V"]] == env.executed
@@ -105,3 +123,26 @@ def test_run_console_scripts_of_editable_dependencies_on_windows(
     # We prove that the CMD script executed successfully by verifying the exit code
     # matches what we wrote in the script
     assert tester.execute("quix") == 123
+
+
+def test_run_script_exit_code(
+    poetry_with_scripts: Poetry,
+    command_tester_factory: CommandTesterFactory,
+    tmp_venv: VirtualEnv,
+    mocker: MockerFixture,
+) -> None:
+    mocker.patch(
+        "os.execvpe",
+        lambda file, args, env: subprocess.call([file] + args[1:], env=env),
+    )
+    install_tester = command_tester_factory(
+        "install",
+        poetry=poetry_with_scripts,
+        environment=tmp_venv,
+    )
+    assert install_tester.execute() == 0
+    tester = command_tester_factory(
+        "run", poetry=poetry_with_scripts, environment=tmp_venv
+    )
+    assert tester.execute("exit-code") == 42
+    assert tester.execute("return-code") == 42
diff --git a/tests/fixtures/scripts/README.md b/tests/fixtures/scripts/README.md
new file mode 100644
diff --git a/tests/fixtures/scripts/pyproject.toml b/tests/fixtures/scripts/pyproject.toml
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/scripts/pyproject.toml
@@ -0,0 +1,17 @@
+[tool.poetry]
+name = "scripts"
+version = "0.1.0"
+description = ""
+authors = ["Your Name <you@example.com>"]
+readme = "README.md"
+
+[tool.poetry.dependencies]
+python = "^3.7"
+
+[tool.poetry.scripts]
+exit-code = "scripts.exit_code:main"
+return-code = "scripts.return_code:main"
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
diff --git a/tests/fixtures/scripts/scripts/__init__.py b/tests/fixtures/scripts/scripts/__init__.py
new file mode 100644
diff --git a/tests/fixtures/scripts/scripts/exit_code.py b/tests/fixtures/scripts/scripts/exit_code.py
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/scripts/scripts/exit_code.py
@@ -0,0 +1,9 @@
+from __future__ import annotations
+
+
+def main() -> None:
+    raise SystemExit(42)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/fixtures/scripts/scripts/return_code.py b/tests/fixtures/scripts/scripts/return_code.py
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/scripts/scripts/return_code.py
@@ -0,0 +1,9 @@
+from __future__ import annotations
+
+
+def main() -> int:
+    return 42
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())