Repository: pypa/pipx (python)

Issue:
Look into ruff rule RUF012
**How would this feature be useful?**

[`RUF012`](https://docs.astral.sh/ruff/rules/mutable-class-default/) checks for mutable default values in class attributes:
> Mutable default values share state across all instances of the class, while not being obvious. This can lead to bugs when the attributes are changed in one instance, as those changes will unexpectedly affect all other instances.

```console
$ ruff check --extend-select RUF --ignore RUF005
src/pipx/animate.py:24:20: RUF012 Mutable class attributes should be annotated with `typing.ClassVar`
src/pipx/pipx_metadata_file.py:40:28: RUF012 Mutable class attributes should be annotated with `typing.ClassVar`
src/pipx/pipx_metadata_file.py:41:29: RUF012 Mutable class attributes should be annotated with `typing.ClassVar`
src/pipx/pipx_metadata_file.py:42:44: RUF012 Mutable class attributes should be annotated with `typing.ClassVar`
src/pipx/pipx_metadata_file.py:43:56: RUF012 Mutable class attributes should be annotated with `typing.ClassVar`
Found 5 errors.
$ 
```

Introduced in #610:
https://github.com/pypa/pipx/blob/5eed47950ba97ea8e0ad292a1026cf87809f940e/src/pipx/animate.py#L23-L24

Introduced in #222:
https://github.com/pypa/pipx/blob/5eed47950ba97ea8e0ad292a1026cf87809f940e/src/pipx/pipx_metadata_file.py#L29-L44

**Describe the solution you'd like**

As a first step, I'd like to understand whether this is on purpose or not.

I suspect the relevant classes are designed to have one instance, in which case this is not an issue in practice. Nevertheless, it's good practice to avoid mutable default values, so that future maintainers don't have to ask themselves again.

**Describe alternatives you've considered**


Pull request:
Apply ruff rule RUF012
- [ ] I have added a news fragment under `changelog.d/` (if the patch affects the end users)

## Summary of changes

Apply ruff rule `RUF012`
```
RUF012 Mutable class attributes should be annotated with `typing.ClassVar`
```

Gitr rid of mutable default values:
* `list` → `tuple`
* `list` → `field(default_factory=list)` (using [`dataclass`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass) instead of [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple))
* `dict` → `field(default_factory=dict)`(using [`dataclass`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass) instead of [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple))

Fixes #1408.

## Test plan

<!-- provide evidence of testing, preferably with command(s) that can be copy+pasted by others -->

Tested by running
```console
$ ruff check --extend-select RUF012
All checks passed!
$ 
```


Patch:
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/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/pipx_metadata_file.py b/src/pipx/pipx_metadata_file.py
index 7d04062..0c37967 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
@@ -26,7 +27,8 @@ def _json_decoder_object_hook(json_dict: Dict[str, Any]) -> Union[Dict[str, Any]
     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__,
         }
 


Test patch:
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/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):