Skip to content

Commit 337cd3a

Browse files
committed
🔧 chore: switch to ty and fix CI failures
CI was failing due to shim tests assuming from_exe is called exactly once (UV_PYTHON_INSTALL_DIR adds extra binaries in CI) and pyright type errors across the codebase. Switched to ty (Astral's type checker) to match the pytest-env pattern, fixed all type errors, applied walrus operators where they simplify code, and ensured 100% coverage holds.
1 parent f09c197 commit 337cd3a

13 files changed

Lines changed: 57 additions & 53 deletions

File tree

‎pyproject.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ lint.isort = { known-first-party = [
129129
] }
130130
lint.preview = true
131131

132+
[tool.ty]
133+
environment.python-version = "3.14"
134+
src.exclude = ["tests/windows/winreg_mock_values.py"]
135+
132136
[tool.codespell]
133137
builtin = "clear,usage,en-GB_to_en-US"
134138
write-changes = true

‎src/py_discovery/_builtin.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ def get_interpreter(
5959
proposed_paths = set()
6060
env = os.environ if env is None else env
6161
for interpreter, impl_must_match in propose_interpreters(spec, try_first_with, cache, env):
62+
if interpreter is None: # pragma: no cover
63+
continue
6264
key = interpreter.system_executable, impl_must_match
6365
if key in proposed_paths:
6466
continue
@@ -75,7 +77,7 @@ def propose_interpreters(
7577
try_first_with: Iterable[str],
7678
cache: PyInfoCache | None = None,
7779
env: Mapping[str, str] | None = None,
78-
) -> Generator[tuple[PythonInfo, bool], None, None]:
80+
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
7981
env = os.environ if env is None else env
8082
tested_exes: set[str] = set()
8183
if spec.is_abs and spec.path is not None:

‎src/py_discovery/_py_info.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def abs_path(v):
4848
self.platform = sys.platform
4949
self.implementation = platform.python_implementation()
5050
if self.implementation == "PyPy":
51-
self.pypy_version_info = tuple(sys.pypy_version_info) # ty: ignore[unresolved-attribute]
51+
self.pypy_version_info = tuple(sys.pypy_version_info) # ty: ignore[unresolved-attribute] # pypy only
5252

5353
# this is a tuple in earlier, struct later, unify to our own named tuple
5454
self.version_info = VersionInfo(*sys.version_info)
@@ -281,7 +281,7 @@ def _distutils_install():
281281
with warnings.catch_warnings(): # disable warning for PEP-632
282282
warnings.simplefilter("ignore")
283283
try:
284-
from distutils import ( # ty: ignore[unresolved-import] - setuptools provides distutils on 3.12+
284+
from distutils import ( # ty: ignore[unresolved-import]
285285
dist,
286286
)
287287
from distutils.command.install import ( # ty: ignore[unresolved-import]
@@ -298,7 +298,7 @@ def _distutils_install():
298298
warnings.simplefilter("ignore")
299299
i = d.get_command_obj("install", create=True)
300300

301-
i.prefix = os.sep # paths generated are relative to prefix that contains the path sep, this makes it relative
301+
i.prefix = os.sep # paths generated are relative to prefix that contains the path sep
302302
i.finalize_options()
303303
return {key: (getattr(i, f"install_{key}")[1:]).lstrip(os.sep) for key in SCHEME_KEYS}
304304

‎src/py_discovery/_py_spec.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,7 @@ def from_string_spec(cls, string_spec: str) -> PythonSpec:
6868
path = string_spec
6969
else:
7070
ok = False
71-
match = re.match(PATTERN, string_spec)
72-
if match:
71+
if match := re.match(PATTERN, string_spec):
7372

7473
def _int_or_none(val):
7574
return None if val is None else int(val)
@@ -103,8 +102,7 @@ def _int_or_none(val):
103102
machine = normalize_isa(machine)
104103

105104
if not ok:
106-
specifier_match = SPECIFIER_PATTERN.match(string_spec.strip())
107-
if specifier_match and SpecifierSet is not None:
105+
if (specifier_match := SPECIFIER_PATTERN.match(string_spec.strip())) and SpecifierSet is not None:
108106
impl = specifier_match.group("impl")
109107
spec_text = specifier_match.group("spec").strip()
110108
try:

‎src/py_discovery/_specifier.py‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@ def __init__(self, version_str: str) -> None:
1818
self.version_str = version_str
1919
# Parse version string into components
2020
# Support formats like: "3.11", "3.11.0", "3.11.0a1", "3.11.0b2", "3.11.0rc1"
21-
match = re.match(
22-
r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:(a|b|rc)(\d+))?$",
23-
version_str.strip(),
24-
)
25-
if not match:
21+
if not (
22+
match := re.match(
23+
r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:(a|b|rc)(\d+))?$",
24+
version_str.strip(),
25+
)
26+
):
2627
msg = f"Invalid version: {version_str}"
2728
raise ValueError(msg)
2829

@@ -98,8 +99,7 @@ class SimpleSpecifier:
9899
def __init__(self, spec_str: str) -> None:
99100
self.spec_str = spec_str.strip()
100101
# Parse operator and version
101-
match = re.match(r"^(===|==|~=|!=|<=|>=|<|>)\s*(.+)$", self.spec_str)
102-
if not match:
102+
if not (match := re.match(r"^(===|==|~=|!=|<=|>=|<|>)\s*(.+)$", self.spec_str)):
103103
msg = f"Invalid specifier: {spec_str}"
104104
raise ValueError(msg)
105105

‎src/py_discovery/_windows/_pep514.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,7 @@ def load_arch_data(hive_name: str, company: str, tag: str, tag_key: Any, default
117117

118118
def parse_arch(arch_str: Any) -> int:
119119
if isinstance(arch_str, str):
120-
match = re.match(r"^(\d+)bit$", arch_str)
121-
if match:
120+
if match := re.match(r"^(\d+)bit$", arch_str):
122121
return int(next(iter(match.groups())))
123122
error = f"invalid format {arch_str}"
124123
else:
@@ -143,8 +142,7 @@ def load_version_data(
143142

144143
def parse_version(version_str: Any) -> tuple[int | None, int | None, int | None]:
145144
if isinstance(version_str, str):
146-
match = re.match(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?$", version_str)
147-
if match:
145+
if match := re.match(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?$", version_str):
148146
g1, g2, g3 = match.groups()
149147
return (
150148
int(g1) if g1 is not None else None,

‎tests/conftest.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
from __future__ import annotations
22

3+
from typing import TYPE_CHECKING
4+
35
import pytest
46

57
from py_discovery import DiskCache, PythonInfo
68

9+
if TYPE_CHECKING:
10+
from collections.abc import Generator
11+
712

813
@pytest.fixture(scope="session")
914
def session_cache(tmp_path_factory: pytest.TempPathFactory) -> DiskCache:
1015
return DiskCache(tmp_path_factory.mktemp("py-discovery-cache"))
1116

1217

1318
@pytest.fixture(autouse=True)
14-
def _ensure_py_info_cache_empty(session_cache: DiskCache) -> None:
19+
def _ensure_py_info_cache_empty(session_cache: DiskCache) -> Generator[None]:
1520
PythonInfo.clear_cache(session_cache)
1621
yield
1722
PythonInfo.clear_cache(session_cache)

‎tests/py_info/test_py_info.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,12 @@ def test_satisfy_not_threaded() -> None:
108108

109109

110110
def _generate_not_match_current_interpreter_version() -> list[str]:
111-
result = []
111+
result: list[str] = []
112112
for i in range(3):
113-
ver = sys.version_info[0 : i + 1]
113+
ver: list[int] = [int(v) for v in sys.version_info[0 : i + 1]]
114114
for a in range(len(ver)):
115115
for o in [-1, 1]:
116-
temp = list(ver)
116+
temp = ver.copy()
117117
temp[a] += o
118118
result.append(".".join(str(i) for i in temp))
119119
return result
@@ -215,7 +215,7 @@ def _make_py_info(of: PyInfoMock) -> PythonInfo:
215215
py_info = _make_py_info(i)
216216
py_info.system_executable = CURRENT.system_executable
217217
py_info.executable = CURRENT.system_executable
218-
py_info.base_executable = str(path)
218+
py_info.base_executable = str(path) # ty: ignore[unresolved-attribute]
219219
if pos == position:
220220
selected = py_info
221221
discovered_with_path[str(path)] = py_info
@@ -261,6 +261,7 @@ def test_py_info_ignores_distutils_config(monkeypatch: pytest.MonkeyPatch, tmp_p
261261
(tmp_path / "setup.cfg").write_text(dedent(raw), encoding="utf-8")
262262
monkeypatch.chdir(tmp_path)
263263
py_info = PythonInfo.from_exe(sys.executable)
264+
assert py_info is not None
264265
distutils = py_info.distutils_install
265266
for key, value in distutils.items(): # pragma: no cover # distutils_install is empty with "venv" scheme
266267
assert not value.startswith(str(tmp_path)), f"{key}={value}"

‎tests/test_builtin.py‎

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -300,8 +300,7 @@ def test_shim_resolved_to_real_binary(
300300
with patch("py_discovery._builtin.PathPythonInfo.from_exe") as mock_from_exe:
301301
mock_from_exe.return_value = None
302302
get_interpreter("python2.7", [])
303-
mock_from_exe.assert_called_once()
304-
assert mock_from_exe.call_args[0][0] == str(real_binary)
303+
assert mock_from_exe.call_args_list[0][0][0] == str(real_binary)
305304

306305

307306
def test_shim_not_resolved_without_version_manager_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -318,8 +317,7 @@ def test_shim_not_resolved_without_version_manager_env(tmp_path: Path, monkeypat
318317
with patch("py_discovery._builtin.PathPythonInfo.from_exe") as mock_from_exe:
319318
mock_from_exe.return_value = None
320319
get_interpreter("python2.7", [])
321-
mock_from_exe.assert_called_once()
322-
assert mock_from_exe.call_args[0][0] == str(shim)
320+
assert mock_from_exe.call_args_list[0][0][0] == str(shim)
323321

324322

325323
def test_shim_falls_through_when_binary_missing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -334,8 +332,7 @@ def test_shim_falls_through_when_binary_missing(tmp_path: Path, monkeypatch: pyt
334332
with patch("py_discovery._builtin.PathPythonInfo.from_exe") as mock_from_exe:
335333
mock_from_exe.return_value = None
336334
get_interpreter("python2.7", [])
337-
mock_from_exe.assert_called_once()
338-
assert mock_from_exe.call_args[0][0] == str(shim)
335+
assert mock_from_exe.call_args_list[0][0][0] == str(shim)
339336

340337

341338
def test_shim_uses_python_version_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -353,8 +350,7 @@ def test_shim_uses_python_version_file(tmp_path: Path, monkeypatch: pytest.Monke
353350
with patch("py_discovery._builtin.PathPythonInfo.from_exe") as mock_from_exe:
354351
mock_from_exe.return_value = None
355352
get_interpreter("python2.7", [])
356-
mock_from_exe.assert_called_once()
357-
assert mock_from_exe.call_args[0][0] == str(real_binary)
353+
assert mock_from_exe.call_args_list[0][0][0] == str(real_binary)
358354

359355

360356
def test_shim_pyenv_version_env_takes_priority_over_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -373,8 +369,7 @@ def test_shim_pyenv_version_env_takes_priority_over_file(tmp_path: Path, monkeyp
373369
with patch("py_discovery._builtin.PathPythonInfo.from_exe") as mock_from_exe:
374370
mock_from_exe.return_value = None
375371
get_interpreter("python2.7", [])
376-
mock_from_exe.assert_called_once()
377-
assert mock_from_exe.call_args[0][0] == str(env_binary)
372+
assert mock_from_exe.call_args_list[0][0][0] == str(env_binary)
378373

379374

380375
def test_shim_uses_global_version_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -394,8 +389,7 @@ def test_shim_uses_global_version_file(tmp_path: Path, monkeypatch: pytest.Monke
394389
with patch("py_discovery._builtin.PathPythonInfo.from_exe") as mock_from_exe:
395390
mock_from_exe.return_value = None
396391
get_interpreter("python2.7", [])
397-
mock_from_exe.assert_called_once()
398-
assert mock_from_exe.call_args[0][0] == str(real_binary)
392+
assert mock_from_exe.call_args_list[0][0][0] == str(real_binary)
399393

400394

401395
def test_shim_colon_separated_pyenv_version_picks_first_match(
@@ -415,5 +409,4 @@ def test_shim_colon_separated_pyenv_version_picks_first_match(
415409
with patch("py_discovery._builtin.PathPythonInfo.from_exe") as mock_from_exe:
416410
mock_from_exe.return_value = None
417411
get_interpreter("python2.7", [])
418-
mock_from_exe.assert_called_once()
419-
assert mock_from_exe.call_args[0][0] == str(second_binary)
412+
assert mock_from_exe.call_args_list[0][0][0] == str(second_binary)

‎tests/test_py_spec.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
from __future__ import annotations
22

33
import sys
4-
from copy import copy
4+
from typing import TYPE_CHECKING
55

66
import pytest
77

88
from py_discovery import PythonSpec
99
from py_discovery._py_info import normalize_isa
1010
from py_discovery._specifier import SimpleSpecifierSet as SpecifierSet
1111

12+
if TYPE_CHECKING:
13+
from pathlib import Path
14+
1215

1316
def test_bad_py_spec() -> None:
1417
text = "python2.3.4.5"
@@ -107,10 +110,10 @@ def _version_not_satisfies_pairs() -> list[tuple[str, str]]:
107110
for major in range(len(version)):
108111
req = ".".join(version[0 : major + 1])
109112
for minor in range(major + 1):
110-
sat_ver = list(sys.version_info[0 : minor + 1])
113+
sat_ver: list[int] = [int(v) for v in sys.version_info[0 : minor + 1]]
111114
for patch in range(minor + 1):
112115
for o in [1, -1]:
113-
temp = copy(sat_ver)
116+
temp = sat_ver.copy()
114117
temp[patch] += o
115118
if temp[patch] < 0:
116119
continue # pragma: no cover
@@ -126,7 +129,7 @@ def test_version_satisfies_nok(req: str, spec: str) -> None:
126129
assert sat_spec.satisfies(req_spec) is False
127130

128131

129-
def test_relative_spec(tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch) -> None:
132+
def test_relative_spec(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
130133
monkeypatch.chdir(tmp_path)
131134
a_relative_path = str((tmp_path / "a" / "b").relative_to(tmp_path))
132135
spec = PythonSpec.from_string_spec(a_relative_path)

0 commit comments

Comments
 (0)