Repository: zulip/zulip-terminal
Pull request #1435 (2023-11-02 23:05:05)

Extend platform detection to include details of python
<!-- See README for documentation, or ask in #zulip-terminal if unclear -->
### What does this PR do, and why?

This was motivated by confirming which python was being used for running ZT, originally in the context of testing locally with python 3.12 and others (eg. for #1379).

Having this information included is also useful to users, as well as for having feedback from them. Initial console output is therefore updated to show this, and included in the About popup in a new Detected Environment category, including the platform - which resolves the last part of #1216.

In much the same way as the detected platform, a test step is also added to CI to ensure that the expected python version matches that detected.

Note that this includes the platform (CPython vs PyPy), and if present the branch - which appears to be where the PyPy implementation version is stored.

This also includes an infrastructure simplification to avoid MacOS testing on 3.x, which was a simple way to test on the latest version, but randomly breaks CI if version support is not updated - and would require a special case for python environment detection.

### Outstanding aspect(s)    <!-- DELETE SECTION IF EMPTY -->
<!-- In what ways is this not fully implemented/functioning? Compared to a discussion/issue? -->
<!-- Do you not understand something? Are you unsure about a certain approach? Want feedback? -->
- [ ] Output warning regarding unsupported python versions

### External discussion & connections
<!-- [x] all that apply, specifying topic and adding numbers after # for issues/PRs -->
- [ ] Discussed in **#zulip-terminal** in `topic`
- [ ] Fully fixes #
- [ ] Partially fixes issue #
- [ ] Builds upon previous unmerged work in PR #
- [ ] Is a follow-up to work in PR #
- [ ] Requires merge of PR #
- [ ] Merge will enable work on #

### How did you test this?
<!-- [x] all that apply -->
- [x] Manually - Behavioral changes
- [x] Manually - Visual changes
- [x] Adapting existing automated tests
- [x] Adding automated tests for new behavior (or missing tests)
- [ ] Existing automated tests should already cover this (*only a refactor of tested code*)

### Self-review checklist for each commit
- [x] It is a [minimal coherent idea](https://github.com/zulip/zulip-terminal#structuring-commits---speeding-up-reviews-merging--development)
- [x] It has a commit summary following the [documented style](https://github.com/zulip/zulip-terminal#structuring-commits---speeding-up-reviews-merging--development) (title & body)
- [x] It has a commit summary describing the  motivation and reasoning for the change
- [x] It individually passes linting and tests
- [x] It contains test additions for any new behavior
- [x] It flows clearly from a previous branch commit, and/or prepares for the next commit

### Visual changes    <!-- DELETE SECTION IF NO VISUAL CHANGE -->
<!-- Zulip tips at https://zulip.readthedocs.io/en/latest/tutorials/screenshot-and-gif-software.html -->
<!-- For video, try asciinema; after uploading, embed using
[![yourtitle](https://asciinema.org/a/<id>.png)](https://asciinema.org/a/<id>)
-->
<!-- NOTE: Attached videos/images will be clearer from smaller terminal windows -->



Patch:
diff --git a/zulipterminal/cli/run.py b/zulipterminal/cli/run.py
index 937f2c7..87794e9 100755
--- a/zulipterminal/cli/run.py
+++ b/zulipterminal/cli/run.py
@@ -26,7 +26,7 @@ from zulipterminal.config.themes import (
 )
 from zulipterminal.core import Controller
 from zulipterminal.model import ServerConnectionFailure
-from zulipterminal.platform_code import detected_platform
+from zulipterminal.platform_code import detected_platform, detected_python_in_full
 from zulipterminal.version import ZT_VERSION
 
 
@@ -430,7 +430,11 @@ def main(options: Optional[List[str]] = None) -> None:
     else:
         zuliprc_path = "~/zuliprc"
 
-    print(f"Detected platform: {detected_platform()}")
+    print(
+        "Detected:"
+        f"\n - platform: {detected_platform()}"
+        f"\n - python: {detected_python_in_full()}"
+    )
 
     try:
         zterm = parse_zuliprc(zuliprc_path)
diff --git a/zulipterminal/platform_code.py b/zulipterminal/platform_code.py
index 89e25be..2741e8e 100644
--- a/zulipterminal/platform_code.py
+++ b/zulipterminal/platform_code.py
@@ -4,10 +4,37 @@ Detection of supported platforms & platform-specific functions
 
 import platform
 import subprocess
+from typing import Tuple
 
 from typing_extensions import Literal
 
 
+# PYTHON DETECTION
+def detected_python() -> Tuple[str, str, str]:
+    return (
+        platform.python_version(),
+        platform.python_implementation(),
+        platform.python_branch(),
+    )
+
+
+def detected_python_in_full() -> str:
+    version, implementation, branch = detected_python()
+    branch_text = f"[{branch}]" if branch else ""
+    return f"{version} ({implementation}) {branch_text}"
+
+
+def detected_python_short() -> str:
+    """Concise output for comparison in CI (CPython implied in version)"""
+    version, implementation, _ = detected_python()
+    short_version = version[: version.rfind(".")]
+    if implementation == "CPython":
+        return short_version
+    if implementation == "PyPy":
+        return f"pypy-{short_version}"
+    raise NotImplementedError
+
+
 # PLATFORM DETECTION
 SupportedPlatforms = Literal["Linux", "MacOS", "WSL"]
 AllPlatforms = Literal[SupportedPlatforms, "unsupported"]
diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py
index ef59d19..463478b 100644
--- a/zulipterminal/ui_tools/views.py
+++ b/zulipterminal/ui_tools/views.py
@@ -41,6 +41,7 @@ from zulipterminal.helper import (
     match_stream,
     match_user,
 )
+from zulipterminal.platform_code import PLATFORM, detected_python_in_full
 from zulipterminal.server_url import near_message_url
 from zulipterminal.ui_tools.boxes import PanelSearchBox
 from zulipterminal.ui_tools.buttons import (
@@ -1104,6 +1105,10 @@ class AboutView(PopUpView):
                     ("Notifications", "enabled" if notify_enabled else "disabled"),
                 ],
             ),
+            (
+                "Detected environment",
+                [("Platform", PLATFORM), ("Python", detected_python_in_full())],
+            ),
         ]
 
         popup_width, column_widths = self.calculate_table_widths(contents, len(title))


Test patch:
diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml
index 1660bcc..a2f9b44 100644
--- a/.github/workflows/lint-and-test.yml
+++ b/.github/workflows/lint-and-test.yml
@@ -206,9 +206,10 @@ jobs:
           - {PYTHON: 'pypy-3.7', OS: ubuntu-latest, NAME: "PyPy 3.7 (ubuntu)", EXPECT: "Linux"}
           - {PYTHON: 'pypy-3.8', OS: ubuntu-latest, NAME: "PyPy 3.8 (ubuntu)", EXPECT: "Linux"}
           - {PYTHON: 'pypy-3.9', OS: ubuntu-latest, NAME: "PyPy 3.9 (ubuntu)", EXPECT: "Linux"}
-          - {PYTHON: '3.x', OS: macos-latest, NAME: "CPython 3.x [latest] (macos)", EXPECT: "MacOS"}
+          - {PYTHON: '3.11', OS: macos-latest, NAME: "CPython 3.11 (macos)", EXPECT: "MacOS"}
     env:
       EXPECT: ${{ matrix.env.EXPECT }}
+      PYTHON: ${{ matrix.env.PYTHON }}
     runs-on: ${{ matrix.env.OS }}
     name: Install & test - ${{ matrix.env.NAME}}
     steps:
@@ -230,6 +231,8 @@ jobs:
         run: sudo apt install libxml2-dev libxslt1-dev
       - name: Ensure regular package installs from checkout
         run: pip install .
+      - name: Check we detect the python environment correctly
+        run: python -c "from zulipterminal.platform_code import detected_python_short; import os; e, d = os.environ['PYTHON'], detected_python_short(); assert d == e, f'{d} != {e}'"
       - name: Check we detect the platform correctly
         run: python -c "from zulipterminal.platform_code import detected_platform; import os; e, d = os.environ['EXPECT'], detected_platform(); assert d == e, f'{d} != {e}'"
       - name: Install test dependencies
diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py
index ea2b775..f1fde1c 100644
--- a/tests/cli/test_run.py
+++ b/tests/cli/test_run.py
@@ -153,6 +153,16 @@ def test_main_help(capsys: CaptureFixture[str], options: str) -> None:
     assert captured.err == ""
 
 
+@pytest.fixture
+def platform_mocker(mocker: MockerFixture) -> Callable[[str, str], List[str]]:
+    def factory(platform: str, python: str) -> List[str]:
+        mocker.patch(MODULE + ".detected_platform", return_value=platform)
+        mocker.patch(MODULE + ".detected_python_in_full", return_value=python)
+        return ["Detected:", f" - platform: {platform}", f" - python: {python}"]
+
+    return factory
+
+
 @pytest.fixture
 def minimal_zuliprc(tmp_path: Path) -> str:
     zuliprc_path = tmp_path / "zuliprc"
@@ -165,15 +175,17 @@ def minimal_zuliprc(tmp_path: Path) -> str:
 def test_valid_zuliprc_but_no_connection(
     capsys: CaptureFixture[str],
     mocker: MockerFixture,
+    platform_mocker: Callable[[str, str], List[str]],
     minimal_zuliprc: str,
     server_connection_error: str = "some_error",
     platform: str = "some_platform",
+    python: str = "3.99 (Zython) [cool]",
 ) -> None:
     mocker.patch(
         CONTROLLER + ".__init__",
         side_effect=ServerConnectionFailure(server_connection_error),
     )
-    mocker.patch(MODULE + ".detected_platform", return_value=platform)
+    expected_platform_output = platform_mocker(platform, python)
 
     with pytest.raises(SystemExit) as e:
         main(["-c", minimal_zuliprc])
@@ -183,8 +195,7 @@ def test_valid_zuliprc_but_no_connection(
     captured = capsys.readouterr()
 
     lines = captured.out.strip().split("\n")
-    expected_lines = [
-        f"Detected platform: {platform}",
+    expected_lines = expected_platform_output + [
         "Loading with:",
         "   theme 'zt_dark' specified from default config.",
         "   autohide setting 'no_autohide' specified from default config.",
@@ -209,12 +220,14 @@ def test_valid_zuliprc_but_no_connection(
 def test_warning_regarding_incomplete_theme(
     capsys: CaptureFixture[str],
     mocker: MockerFixture,
+    platform_mocker: Callable[[str, str], List[str]],
     minimal_zuliprc: str,
     bad_theme: str,
     expected_complete_incomplete_themes: Tuple[List[str], List[str]],
     expected_warning: str,
     server_connection_error: str = "sce",
     platform: str = "some_platform",
+    python: str = "3.99 (Zython) [cool]",
 ) -> None:
     mocker.patch(
         CONTROLLER + ".__init__",
@@ -228,6 +241,8 @@ def test_warning_regarding_incomplete_theme(
     )
     mocker.patch(MODULE + ".generate_theme")
 
+    expected_platform_output = platform_mocker(platform, python)
+
     with pytest.raises(SystemExit) as e:
         main(["-c", minimal_zuliprc, "-t", bad_theme])
 
@@ -236,8 +251,7 @@ def test_warning_regarding_incomplete_theme(
     captured = capsys.readouterr()
 
     lines = captured.out.strip().split("\n")
-    expected_lines = [
-        f"Detected platform: {platform}",
+    expected_lines = expected_platform_output + [
         "Loading with:",
         f"   theme '{bad_theme}' specified on command line.",
         "\x1b[93m   WARNING: Incomplete theme; results may vary!",
@@ -430,14 +444,14 @@ def parameterized_zuliprc(tmp_path: Path) -> Callable[[Dict[str, str]], str]:
 def test_successful_main_function_with_config(
     capsys: CaptureFixture[str],
     mocker: MockerFixture,
+    platform_mocker: Callable[[str, str], List[str]],
     parameterized_zuliprc: Callable[[Dict[str, str]], str],
     config_key: str,
     config_value: str,
     footlinks_output: str,
     platform: str = "some_platform",
+    python: str = "3.99 (Zython) [cool]",
 ) -> None:
-    mocker.patch(MODULE + ".detected_platform", return_value=platform)
-
     config = {
         "theme": "default",
         "autohide": "autohide",
@@ -446,16 +460,18 @@ def test_successful_main_function_with_config(
     }
     config[config_key] = config_value
     zuliprc = parameterized_zuliprc(config)
+
     mocker.patch(CONTROLLER + ".__init__", return_value=None)
     mocker.patch(CONTROLLER + ".main", return_value=None)
 
+    expected_platform_output = platform_mocker(platform, python)
+
     with pytest.raises(SystemExit):
         main(["-c", zuliprc])
 
     captured = capsys.readouterr()
     lines = captured.out.strip().split("\n")
-    expected_lines = [
-        f"Detected platform: {platform}",
+    expected_lines = expected_platform_output + [
         "Loading with:",
         "   theme 'zt_dark' specified in zuliprc file (by alias 'default').",
         "   autohide setting 'autohide' specified in zuliprc file.",
@@ -484,16 +500,20 @@ def test_successful_main_function_with_config(
 def test_main_error_with_invalid_zuliprc_options(
     capsys: CaptureFixture[str],
     mocker: MockerFixture,
+    platform_mocker: Callable[[str, str], List[str]],
     parameterized_zuliprc: Callable[[Dict[str, str]], str],
     zulip_config: Dict[str, str],
     error_message: str,
     platform: str = "some_platform",
+    python: str = "3.99 (Zython) [cool]",
 ) -> None:
     zuliprc = parameterized_zuliprc(zulip_config)
     mocker.patch(CONTROLLER + ".__init__", return_value=None)
     mocker.patch(MODULE + ".detected_platform", return_value=platform)
     mocker.patch(CONTROLLER + ".main", return_value=None)
 
+    expected_platform_output = platform_mocker(platform, python)
+
     with pytest.raises(SystemExit) as e:
         main(["-c", zuliprc])
 
@@ -502,7 +522,7 @@ def test_main_error_with_invalid_zuliprc_options(
     captured = capsys.readouterr()
     lines = captured.out.strip()
     expected_lines = "\n".join(
-        [f"Detected platform: {platform}", f"\033[91m{error_message}\033[0m"]
+        expected_platform_output + [f"\033[91m{error_message}\033[0m"]
     )
     assert lines == expected_lines
 
diff --git a/tests/ui_tools/test_popups.py b/tests/ui_tools/test_popups.py
index c378ba9..e525397 100644
--- a/tests/ui_tools/test_popups.py
+++ b/tests/ui_tools/test_popups.py
@@ -187,7 +187,6 @@ class TestAboutView:
         mocker.patch.object(
             self.controller, "maximum_popup_dimensions", return_value=(64, 64)
         )
-        mocker.patch(LISTWALKER, return_value=[])
         server_version, server_feature_level = MINIMUM_SUPPORTED_SERVER_VERSION
 
         self.about_view = AboutView(
@@ -248,6 +247,21 @@ class TestAboutView:
             1 if server_feature_level else 0
         )
 
+    def test_categories(self) -> None:
+        categories = [
+            widget.text
+            for widget in self.about_view.log
+            if isinstance(widget, Text)
+            and len(widget.attrib)
+            and "popup_category" in widget.attrib[0][0]
+        ]
+        assert categories == [
+            "Application",
+            "Server",
+            "Application Configuration",
+            "Detected environment",
+        ]
+
 
 class TestUserInfoView:
     @pytest.fixture(autouse=True)