Repository: python-poetry/poetry

Issue:
Include strtobool in utils.extras to speed up the shell command
# Pull Request Check List

<!-- This is just a reminder about the most common mistakes. Please make sure that you tick all *appropriate* boxes.  But please read our [contribution guide](https://python-poetry.org/docs/contributing/) at least once, it will save you unnecessary review cycles! -->

- [x] Added **tests** for changed code.
- [ ] Updated **documentation** for changed code. (The change doesn't reflect in the current doc)

<!-- If you have *any* questions to *any* of the points above, just **submit and ask**!  This checklist is here to *help* you, not to deter you from contributing! -->

# What does this PR do?

This line `from distutils.util import strtobool` in  `console.commands.shell` is slow and makes poetry shell command slow, since importing `distutils.util` also does a bunch of other things. 

`strtobool` is a very simple function (~10 lines), if poetry includes this function itself, for example, putting it in utils.extras.py and use from poetry.utils.extras import strtobool, poetry shell would run faster.

[Discord discussion link](https://discord.com/channels/487711540787675139/974839878669987840/988024065933594704)


Discussion:
`utils/extras.py` does not seem to be a suitable place to me because that file is about package extras. Maybe, put it in `utils/shell.py`?

Can you provide some simple measurements? Startup time with `distutils` import vs. startup time without it?
I'm wondering if having `strtobool` conversion is actually necessary, since we only have one usage of it and I'm not sure it really makes sense.

Looking back at the history, it was added in https://github.com/python-poetry/poetry/pull/201, and the justification for using it is [in this thread](https://github.com/python-poetry/poetry/pull/201/files/c5a1c18cb6b775b1437c7224148cb5fc7a7e32f6#r196260004).

Now looking at the code itself, it seems that we explicitly set `POETRY_ACTIVE` to `"1"`: https://github.com/python-poetry/poetry/blob/0ff5a6a06f97f79740ed2f8c1aa68c079865190a/src/poetry/console/commands/shell.py#L37-L38

We can definitely confirm that by running:
```console
$ poetry shell
Spawning shell within <REDACTED>

$ echo $POETRY_ACTIVE
1
```

Even if setting a value before entering the shell, we then erase it with `"1"`:
```console
$ POETRY_ACTIVE=invalid_value

$ poetry shell
Spawning shell within <REDACTED>

$ echo $POETRY_ACTIVE
1
```

So the only ways to end up with other values would be if the user explicitly rewrites `POETRY_ACTIVE` value once in a shell through `poetry shell`, or if a shell command rewrites the value, which I'm not sure are cases worth handling.
@mkniewallner Yes, it's only used once and `shell.py` doesn't need such a function to work.

Maybe we can change `strtobool(environ.get("POETRY_ACTIVE", "0"))` to just `environ.get("POETRY_ACTIVE", False)`?
@radoering 

Importing time:
```
$ python3 << EOF
import time

t0 = time.time()
from distutils.util import strtobool
t1 = time.time()
print(t1 - t0)
EOF

0.52388596534729

$ python3 << EOF
import time

t0 = time.time()
from poetry.utils.extras import str_to_bool
t1 = time.time()
print(t1 - t0)
EOF

0.00874018669128418
```

I put it in the `utils` module because it might be useful for other things in the future.
> @mkniewallner Yes, it's only used once and `shell.py` doesn't need such a function to work.
> 
> Maybe we can change `strtobool(environ.get("POETRY_ACTIVE", "0"))` to just `environ.get("POETRY_ACTIVE", False)`?

Explicitly checking for `"1"` for truthiness may be safer, but yeah, this would be the idea.

Though I'm not part of the core team, it was simply a possible suggestion based on my limited knowledge of the codebase. We may prefer to keep the current behavior as a safety measure.
I don't think that `POETRY_ACTIVE` is an environment variable a user should tamper, so I'm fine with an exact check.

@neersighted What do you think? Is it sufficient to check `POETRY_ACTIVE` to be present since nobody else then poetry itself should set/remove it or should we keep the current distinction between true and false values?

Resolving patch:
diff --git a/src/poetry/console/commands/shell.py b/src/poetry/console/commands/shell.py
--- a/src/poetry/console/commands/shell.py
+++ b/src/poetry/console/commands/shell.py
@@ -2,7 +2,6 @@
 
 import sys
 
-from distutils.util import strtobool
 from os import environ
 from typing import TYPE_CHECKING
 from typing import cast
@@ -27,10 +26,7 @@ def handle(self) -> int:
         from poetry.utils.shell import Shell
 
         # Check if it's already activated or doesn't exist and won't be created
-        venv_activated = strtobool(environ.get("POETRY_ACTIVE", "0")) or getattr(
-            sys, "real_prefix", sys.prefix
-        ) == str(self.env.path)
-        if venv_activated:
+        if self._is_venv_activated():
             self.line(
                 f"Virtual environment already activated: <info>{self.env.path}</>"
             )
@@ -51,3 +47,8 @@ def handle(self) -> int:
         environ.pop("POETRY_ACTIVE")
 
         return 0
+
+    def _is_venv_activated(self) -> bool:
+        return bool(environ.get("POETRY_ACTIVE")) or getattr(
+            sys, "real_prefix", sys.prefix
+        ) == str(self.env.path)


Test patch:
diff --git a/tests/console/commands/test_shell.py b/tests/console/commands/test_shell.py
new file mode 100644
--- /dev/null
+++ b/tests/console/commands/test_shell.py
@@ -0,0 +1,84 @@
+from __future__ import annotations
+
+import os
+
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import pytest
+
+
+if TYPE_CHECKING:
+    from cleo.testers.command_tester import CommandTester
+    from pytest_mock import MockerFixture
+
+    from tests.types import CommandTesterFactory
+
+
+@pytest.fixture
+def tester(command_tester_factory: CommandTesterFactory) -> CommandTester:
+    return command_tester_factory("shell")
+
+
+def test_shell(tester: CommandTester, mocker: MockerFixture):
+    shell_activate = mocker.patch("poetry.utils.shell.Shell.activate")
+
+    tester.execute()
+
+    expected_output = f"Spawning shell within {tester.command.env.path}\n"
+
+    shell_activate.assert_called_once_with(tester.command.env)
+    assert tester.io.fetch_output() == expected_output
+    assert tester.status_code == 0
+
+
+def test_shell_already_active(tester: CommandTester, mocker: MockerFixture):
+    os.environ["POETRY_ACTIVE"] = "1"
+    shell_activate = mocker.patch("poetry.utils.shell.Shell.activate")
+
+    tester.execute()
+
+    expected_output = (
+        f"Virtual environment already activated: {tester.command.env.path}\n"
+    )
+
+    shell_activate.assert_not_called()
+    assert tester.io.fetch_output() == expected_output
+    assert tester.status_code == 0
+
+
+@pytest.mark.parametrize(
+    ("poetry_active", "real_prefix", "prefix", "expected"),
+    [
+        (None, None, "", False),
+        ("", None, "", False),
+        (" ", None, "", True),
+        ("0", None, "", True),
+        ("1", None, "", True),
+        ("foobar", None, "", True),
+        ("1", "foobar", "foobar", True),
+        (None, "foobar", "foobar", True),
+        (None, "foobar", "foo", True),
+        (None, None, "foobar", True),
+        (None, "foo", "foobar", False),
+        (None, "foo", "foo", False),
+    ],
+)
+def test__is_venv_activated(
+    tester: CommandTester,
+    mocker: MockerFixture,
+    poetry_active: str | None,
+    real_prefix: str | None,
+    prefix: str,
+    expected: bool,
+):
+    mocker.patch.object(tester.command.env, "_path", Path("foobar"))
+    mocker.patch("sys.prefix", prefix)
+
+    if real_prefix is not None:
+        mocker.patch("sys.real_prefix", real_prefix, create=True)
+
+    if poetry_active is not None:
+        os.environ["POETRY_ACTIVE"] = poetry_active
+
+    assert tester.command._is_venv_activated() is expected