Repository: pypa/pipx
Pull request #1416 (2024-05-18 13:49:43)

NamedTuple → dataclass
- [ ] I have added a news fragment under `changelog.d/` (if the patch affects the end users)

## Summary of changes

Extends #1414, improving maintainability.

## Test plan

Pass CI tests.


Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f9c3ab0..43e222b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -75,7 +75,7 @@ jobs:
           workflow_conclusion: success
           pr: ${{ github.event.pull_request.number }}
       - name: Upload to release
-        uses: softprops/action-gh-release@v1
+        uses: softprops/action-gh-release@v2
         with:
           files: pipx.pyz
           tag_name: "${{ needs.create-release.outputs.release-tag }}"
diff --git a/get-pipx.py b/get-pipx.py
old mode 100644
new mode 100755
diff --git a/pyproject.toml b/pyproject.toml
index 658a1b5..ac1b759 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -76,6 +76,7 @@ lint.extend-select = [
   "PLE",
   "PLW",
   "RSE",
+  "RUF012",
   "RUF100",
   "W",
 ]
diff --git a/scripts/update_package_cache.py b/scripts/update_package_cache.py
old mode 100644
new mode 100755
index 32ed45a..2c33eac
--- a/scripts/update_package_cache.py
+++ b/scripts/update_package_cache.py
@@ -115,10 +115,11 @@ def update_test_packages_cache(package_list_dir_path: Path, pipx_package_cache_p
             package_name = package_spec_re.group(1)
             package_ver = package_spec_re.group(2)
             package_dist_patt = re.escape(package_name) + r"-" + re.escape(package_ver) + r"(.tar.gz|.zip|-)"
-            matches = []
-            for output_dir_file in packages_dir_files:
-                if re.search(package_dist_patt, output_dir_file.name):
-                    matches.append(output_dir_file)
+            matches = [
+                output_dir_file
+                for output_dir_file in packages_dir_files
+                if re.search(package_dist_patt, output_dir_file.name)
+            ]
             if len(matches) == 1:
                 packages_dir_files.remove(matches[0])
                 packages_dir_hits.append(matches[0])
diff --git a/src/pipx/animate.py b/src/pipx/animate.py
index 327911d..ed1dcd6 100644
--- a/src/pipx/animate.py
+++ b/src/pipx/animate.py
@@ -21,7 +21,7 @@ if WINDOWS:
     import ctypes
 
     class _CursorInfo(ctypes.Structure):
-        _fields_ = [("size", ctypes.c_int), ("visible", ctypes.c_byte)]
+        _fields_ = (("size", ctypes.c_int), ("visible", ctypes.c_byte))
 
 
 def _env_supports_animation() -> bool:
diff --git a/src/pipx/commands/common.py b/src/pipx/commands/common.py
index 6f27a9d..9f6ebe8 100644
--- a/src/pipx/commands/common.py
+++ b/src/pipx/commands/common.py
@@ -350,20 +350,19 @@ def _get_list_output(
 
     if new_install and (exposed_binary_names or unavailable_binary_names):
         output.append("  These apps are now globally available")
-    for name in exposed_binary_names:
-        output.append(f"    - {name}")
-    for name in unavailable_binary_names:
-        output.append(f"    - {red(name)} (symlink missing or pointing to unexpected location)")
+    output.extend(f"    - {name}" for name in exposed_binary_names)
+    output.extend(
+        f"    - {red(name)} (symlink missing or pointing to unexpected location)" for name in unavailable_binary_names
+    )
     if new_install and (exposed_man_pages or unavailable_man_pages):
         output.append("  These manual pages are now globally available")
-    for name in exposed_man_pages:
-        output.append(f"    - {name}")
-    for name in unavailable_man_pages:
-        output.append(f"    - {red(name)} (symlink missing or pointing to unexpected location)")
+    output.extend(f"    - {name}" for name in exposed_man_pages)
+    output.extend(
+        f"    - {red(name)} (symlink missing or pointing to unexpected location)" for name in unavailable_man_pages
+    )
     if injected_packages:
         output.append("    Injected Packages:")
-        for name in injected_packages:
-            output.append(f"      - {name} {injected_packages[name].package_version}")
+        output.extend(f"      - {name} {injected_packages[name].package_version}" for name in injected_packages)
     return "\n".join(output)
 
 
diff --git a/src/pipx/commands/interpreter.py b/src/pipx/commands/interpreter.py
index 4e4b011..6fea85b 100644
--- a/src/pipx/commands/interpreter.py
+++ b/src/pipx/commands/interpreter.py
@@ -48,8 +48,7 @@ def list_interpreters(
         used_in = get_interpreter_users(interpreter, venvs)
         if used_in:
             output.append("    Used in:")
-            for p in used_in:
-                output.append(f"     - {p.main_package.package} {p.main_package.package_version}")
+            output.extend(f"     - {p.main_package.package} {p.main_package.package_version}" for p in used_in)
         else:
             output.append("    Unused")
 
diff --git a/src/pipx/interpreter.py b/src/pipx/interpreter.py
index fda8b1b..1728ad7 100644
--- a/src/pipx/interpreter.py
+++ b/src/pipx/interpreter.py
@@ -18,10 +18,9 @@ logger = logging.getLogger(__name__)
 def has_venv() -> bool:
     try:
         import venv  # noqa: F401
-
-        return True
     except ImportError:
         return False
+    return True
 
 
 class InterpreterResolutionError(PipxError):
@@ -102,8 +101,7 @@ def find_python_interpreter(python_version: str, fetch_missing_python: bool = Fa
 
     if fetch_missing_python or FETCH_MISSING_PYTHON:
         try:
-            standalone_executable = download_python_build_standalone(python_version)
-            return standalone_executable
+            return download_python_build_standalone(python_version)
         except PipxError as e:
             raise InterpreterResolutionError(source="the python-build-standalone project", version=python_version) from e
 
diff --git a/src/pipx/package_specifier.py b/src/pipx/package_specifier.py
index 26ced4f..62fe053 100644
--- a/src/pipx/package_specifier.py
+++ b/src/pipx/package_specifier.py
@@ -9,8 +9,9 @@
 import logging
 import re
 import urllib.parse
+from dataclasses import dataclass
 from pathlib import Path
-from typing import List, NamedTuple, Optional, Set, Tuple
+from typing import List, Optional, Set, Tuple
 
 from packaging.requirements import InvalidRequirement, Requirement
 from packaging.specifiers import SpecifierSet
@@ -24,7 +25,8 @@ logger = logging.getLogger(__name__)
 ARCHIVE_EXTENSIONS = (".whl", ".tar.gz", ".zip")
 
 
-class ParsedPackage(NamedTuple):
+@dataclass(frozen=True)
+class ParsedPackage:
     valid_pep508: Optional[Requirement]
     valid_url: Optional[str]
     valid_local_path: Optional[str]
@@ -172,8 +174,7 @@ def parse_specifier_for_metadata(package_spec: str) -> str:
     * Convert local paths to absolute paths
     """
     parsed_package = _parse_specifier(package_spec)
-    package_or_url = _parsed_package_to_package_or_url(parsed_package, remove_version_specifiers=False)
-    return package_or_url
+    return _parsed_package_to_package_or_url(parsed_package, remove_version_specifiers=False)
 
 
 def parse_specifier_for_upgrade(package_spec: str) -> str:
@@ -185,8 +186,7 @@ def parse_specifier_for_upgrade(package_spec: str) -> str:
     * Convert local paths to absolute paths
     """
     parsed_package = _parse_specifier(package_spec)
-    package_or_url = _parsed_package_to_package_or_url(parsed_package, remove_version_specifiers=True)
-    return package_or_url
+    return _parsed_package_to_package_or_url(parsed_package, remove_version_specifiers=True)
 
 
 def get_extras(package_spec: str) -> Set[str]:
diff --git a/src/pipx/pipx_metadata_file.py b/src/pipx/pipx_metadata_file.py
index 7d04062..2f5727f 100644
--- a/src/pipx/pipx_metadata_file.py
+++ b/src/pipx/pipx_metadata_file.py
@@ -1,7 +1,8 @@
 import json
 import logging
+from dataclasses import asdict, dataclass, field
 from pathlib import Path
-from typing import Any, Dict, List, NamedTuple, Optional, Union
+from typing import Any, Dict, List, Optional, Union
 
 from pipx.emojis import hazard
 from pipx.util import PipxError, pipx_wrap
@@ -21,12 +22,13 @@ class JsonEncoderHandlesPath(json.JSONEncoder):
 
 
 def _json_decoder_object_hook(json_dict: Dict[str, Any]) -> Union[Dict[str, Any], Path]:
-    if json_dict.get("__type__", None) == "Path" and "__Path__" in json_dict:
+    if json_dict.get("__type__") == "Path" and "__Path__" in json_dict:
         return Path(json_dict["__Path__"])
     return json_dict
 
 
-class PackageInfo(NamedTuple):
+@dataclass(frozen=True)
+class PackageInfo:
     package: Optional[str]
     package_or_url: Optional[str]
     pip_args: List[str]
@@ -37,10 +39,10 @@ class PackageInfo(NamedTuple):
     apps_of_dependencies: List[str]
     app_paths_of_dependencies: Dict[str, List[Path]]
     package_version: str
-    man_pages: List[str] = []
-    man_paths: List[Path] = []
-    man_pages_of_dependencies: List[str] = []
-    man_paths_of_dependencies: Dict[str, List[Path]] = {}
+    man_pages: List[str] = field(default_factory=list)
+    man_paths: List[Path] = field(default_factory=list)
+    man_pages_of_dependencies: List[str] = field(default_factory=list)
+    man_paths_of_dependencies: Dict[str, List[Path]] = field(default_factory=dict)
     suffix: str = ""
 
 
@@ -85,11 +87,11 @@ class PipxMetadata:
 
     def to_dict(self) -> Dict[str, Any]:
         return {
-            "main_package": self.main_package._asdict(),
+            "main_package": asdict(self.main_package),
             "python_version": self.python_version,
             "source_interpreter": self.source_interpreter,
             "venv_args": self.venv_args,
-            "injected_packages": {name: data._asdict() for (name, data) in self.injected_packages.items()},
+            "injected_packages": {name: asdict(data) for (name, data) in self.injected_packages.items()},
             "pipx_metadata_version": self.__METADATA_VERSION__,
         }
 
diff --git a/src/pipx/standalone_python.py b/src/pipx/standalone_python.py
index 756b0a4..465ce29 100644
--- a/src/pipx/standalone_python.py
+++ b/src/pipx/standalone_python.py
@@ -175,7 +175,7 @@ def list_pythons(use_cache: bool = True) -> Dict[str, str]:
         python_version = match[1]
         python_versions[python_version] = link
 
-    sorted_python_versions = {
+    return {
         version: python_versions[version]
         for version in sorted(
             python_versions,
@@ -184,7 +184,6 @@ def list_pythons(use_cache: bool = True) -> Dict[str, str]:
             reverse=True,
         )
     }
-    return sorted_python_versions
 
 
 def resolve_python_version(requested_version: str):
diff --git a/src/pipx/util.py b/src/pipx/util.py
index e5fd72f..0951232 100644
--- a/src/pipx/util.py
+++ b/src/pipx/util.py
@@ -7,12 +7,12 @@ import string
 import subprocess
 import sys
 import textwrap
+from dataclasses import dataclass
 from pathlib import Path
 from typing import (
     Any,
     Dict,
     List,
-    NamedTuple,
     NoReturn,
     Optional,
     Pattern,
@@ -36,7 +36,8 @@ class PipxError(Exception):
             super().__init__(message)
 
 
-class RelevantSearch(NamedTuple):
+@dataclass(frozen=True)
+class RelevantSearch:
     pattern: Pattern[str]
     category: str
 
@@ -185,7 +186,7 @@ def run_subprocess(
         stdout=subprocess.PIPE if capture_stdout else None,
         stderr=subprocess.PIPE if capture_stderr else None,
         encoding="utf-8",
-        universal_newlines=True,
+        text=True,
         check=False,
         cwd=run_dir,
     )
@@ -381,7 +382,7 @@ def exec_app(
                 stdout=None,
                 stderr=None,
                 encoding="utf-8",
-                universal_newlines=True,
+                text=True,
                 check=False,
             ).returncode
         )
@@ -427,6 +428,6 @@ def is_paths_relative(path: Path, parent: Path):
     # Can be replaced with path.is_relative_to() if support for python3.8 is dropped
     try:
         path.resolve().relative_to(parent.resolve())
-        return True
     except ValueError:
         return False
+    return True
diff --git a/src/pipx/venv_inspect.py b/src/pipx/venv_inspect.py
index db1eba0..3adcaee 100644
--- a/src/pipx/venv_inspect.py
+++ b/src/pipx/venv_inspect.py
@@ -305,7 +305,7 @@ def inspect_venv(
             str(Path(dep_path.parent.name) / dep_path.name) for dep_path in man_paths_of_dependencies[dep]
         ]
 
-    venv_metadata = VenvMetadata(
+    return VenvMetadata(
         apps=apps,
         app_paths=app_paths,
         apps_of_dependencies=apps_of_dependencies,
@@ -317,5 +317,3 @@ def inspect_venv(
         package_version=root_dist.version,
         python_version=venv_python_version,
     )
-
-    return venv_metadata


Test patch:
diff --git a/scripts/generate_man.py b/scripts/generate_man.py
old mode 100644
new mode 100755
diff --git a/scripts/list_test_packages.py b/scripts/list_test_packages.py
old mode 100644
new mode 100755
index 2dcf5cd..8459f58
--- a/scripts/list_test_packages.py
+++ b/scripts/list_test_packages.py
@@ -40,9 +40,7 @@ def process_command_line(argv: List[str]) -> argparse.Namespace:
     # switches/options:
     parser.add_argument("-v", "--verbose", action="store_true", help="Maximum verbosity, especially for pip operations.")
 
-    args = parser.parse_args(argv)
-
-    return args
+    return parser.parse_args(argv)
 
 
 def parse_package_list(package_list_file: Path) -> List[Dict[str, Any]]:
diff --git a/scripts/migrate_pipsi_to_pipx.py b/scripts/migrate_pipsi_to_pipx.py
old mode 100644
new mode 100755
diff --git a/scripts/test_packages_support.py b/scripts/test_packages_support.py
index 0341672..ee6010c 100644
--- a/scripts/test_packages_support.py
+++ b/scripts/test_packages_support.py
@@ -14,10 +14,8 @@ else:
 
 
 def get_platform_list_path(package_list_dir_path: Path) -> Path:
-    platform_package_list_path = package_list_dir_path / f"{FULL_PLATFORM}-python{PYTHON_VERSION_STR}.txt"
-    return platform_package_list_path
+    return package_list_dir_path / f"{FULL_PLATFORM}-python{PYTHON_VERSION_STR}.txt"
 
 
 def get_platform_packages_dir_path(pipx_package_cache_path: Path) -> Path:
-    platform_packages_dir_path = pipx_package_cache_path / f"{PYTHON_VERSION_STR}"
-    return platform_packages_dir_path
+    return pipx_package_cache_path / f"{PYTHON_VERSION_STR}"
diff --git a/tests/helpers.py b/tests/helpers.py
index c2d2cb8..888f9d4 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -2,6 +2,7 @@ import json
 import os
 import re
 import sys
+from dataclasses import replace
 from pathlib import Path
 from typing import Any, Dict, List, Optional
 from unittest import mock
@@ -195,13 +196,15 @@ def assert_package_metadata(test_metadata, ref_metadata):
     assert isinstance(test_metadata.apps, list)
     assert isinstance(test_metadata.app_paths, list)
 
-    test_metadata_replaced = test_metadata._replace(
+    test_metadata_replaced = replace(
+        test_metadata,
         apps=sorted(test_metadata.apps),
         app_paths=sorted(test_metadata.app_paths),
         apps_of_dependencies=sorted(test_metadata.apps_of_dependencies),
         app_paths_of_dependencies={key: sorted(value) for key, value in test_metadata.app_paths_of_dependencies.items()},
     )
-    ref_metadata_replaced = ref_metadata._replace(
+    ref_metadata_replaced = replace(
+        ref_metadata,
         apps=sorted(ref_metadata.apps),
         app_paths=sorted(ref_metadata.app_paths),
         apps_of_dependencies=sorted(ref_metadata.apps_of_dependencies),
diff --git a/tests/package_info.py b/tests/package_info.py
index 3f2d76d..c975f9f 100644
--- a/tests/package_info.py
+++ b/tests/package_info.py
@@ -6,9 +6,7 @@ WIN = sys.platform.startswith("win")
 
 
 def _exe_if_win(apps):
-    app_strings = []
-    app_strings = [f"{app}.exe" if WIN else app for app in apps]
-    return app_strings
+    return [f"{app}.exe" if WIN else app for app in apps]
 
 
 # Versions of all packages possibly used in our tests
diff --git a/tests/test_install_all_packages.py b/tests/test_install_all_packages.py
index 1da8e4d..d1d69b7 100644
--- a/tests/test_install_all_packages.py
+++ b/tests/test_install_all_packages.py
@@ -214,7 +214,7 @@ def format_report_table_row(package_data: PackageData) -> str:
     else:
         sys_install_time = ""
 
-    row_string = (
+    return (
         f"{package_data.package_spec:24}{package_data.overall_pf_str:12}"
         f"{package_data.clear_pip_pf_str:8}{package_data.clear_pipx_pf_str:8}"
         f"{clear_install_time:8}"
@@ -222,8 +222,6 @@ def format_report_table_row(package_data: PackageData) -> str:
         f"{sys_install_time:8}"
     )
 
-    return row_string
-
 
 def format_report_table_footer(module_globals: ModuleGlobalsData) -> str:
     fail_list = []
@@ -272,7 +270,7 @@ def verify_installed_resources(
     resource_name_long = {"app": "apps", "man": "manual pages"}[resource_type]
     package_resources = PKG[package_name][resource_name].copy()
     if deps:
-        package_resources += PKG[package_name]["%s_of_dependencies" % resource_name]
+        package_resources += PKG[package_name][f"{resource_name}_of_dependencies"]
     if len(package_resources) == 0:
         return True
 
diff --git a/tests/test_pipx_metadata_file.py b/tests/test_pipx_metadata_file.py
index 9e1cc78..83f7579 100644
--- a/tests/test_pipx_metadata_file.py
+++ b/tests/test_pipx_metadata_file.py
@@ -1,4 +1,5 @@
 import sys
+from dataclasses import replace
 from pathlib import Path
 
 import pytest  # type: ignore
@@ -68,9 +69,9 @@ def test_pipx_metadata_file_create(tmp_path):
 @pytest.mark.parametrize(
     "test_package",
     [
-        TEST_PACKAGE1._replace(include_apps=False),
-        TEST_PACKAGE1._replace(package=None),
-        TEST_PACKAGE1._replace(package_or_url=None),
+        replace(TEST_PACKAGE1, include_apps=False),
+        replace(TEST_PACKAGE1, package=None),
+        replace(TEST_PACKAGE1, package_or_url=None),
     ],
 )
 def test_pipx_metadata_file_validation(tmp_path, test_package):