Skip to content

Commit 3b381df

Browse files
committed
♻️ refactor: improve code quality across package
Make specifier classes frozen kw-only dataclasses with from_string factory classmethods instead of mutable __init__. This makes them properly immutable and separates construction from parsing. Also standardize module-level constants: private _LOGGER with Final[type] annotations, compiled verbose regexes as module constants, and deduplicate exe-checking logic into _check_exe/_is_new_exe helpers.
1 parent 4f297b4 commit 3b381df

14 files changed

Lines changed: 322 additions & 263 deletions

src/py_discovery/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
from __future__ import annotations
44

55
from importlib.metadata import version
6+
67
from ._cache import ContentStore, DiskCache, PyInfoCache
78
from ._discovery import get_interpreter
89
from ._py_info import PythonInfo
910
from ._py_spec import PythonSpec
1011

11-
1212
__version__ = version("py-discovery")
13+
1314
__all__ = [
1415
"ContentStore",
1516
"DiskCache",

src/py_discovery/_cached_py_info.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from pathlib import Path
1717
from shlex import quote
1818
from subprocess import Popen
19-
from typing import TYPE_CHECKING
19+
from typing import TYPE_CHECKING, Final
2020

2121
from ._cache import NoOpCache
2222
from ._py_info import PythonInfo
@@ -29,7 +29,7 @@
2929

3030
_CACHE: OrderedDict[Path, PythonInfo | Exception] = OrderedDict()
3131
_CACHE[Path(sys.executable)] = PythonInfo()
32-
LOGGER = logging.getLogger(__name__)
32+
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
3333

3434

3535
def from_exe(
@@ -45,7 +45,7 @@ def from_exe(
4545
if isinstance(result, Exception):
4646
if raise_on_error:
4747
raise result
48-
LOGGER.info("%s", result)
48+
_LOGGER.info("%s", result)
4949
result = None
5050
return result
5151

@@ -105,7 +105,7 @@ def _get_via_file_cache(
105105
if py_info is None:
106106
failure, py_info = _run_subprocess(cls, exe, env)
107107
if failure is not None:
108-
LOGGER.debug("first subprocess attempt failed for %s (%s), retrying", exe, failure)
108+
_LOGGER.debug("first subprocess attempt failed for %s (%s), retrying", exe, failure)
109109
failure, py_info = _run_subprocess(cls, exe, env)
110110
if failure is not None:
111111
return failure
@@ -138,7 +138,7 @@ def _load_cached_py_info(
138138
return py_info
139139

140140

141-
COOKIE_LENGTH: int = 32
141+
COOKIE_LENGTH: Final[int] = 32
142142

143143

144144
def gen_cookie() -> str:
@@ -176,7 +176,7 @@ def _run_subprocess(
176176
env = dict(env)
177177
env.pop("__PYVENV_LAUNCHER__", None)
178178
env["PYTHONUTF8"] = "1"
179-
LOGGER.debug("get interpreter info via cmd: %s", LogCmd(cmd))
179+
_LOGGER.debug("get interpreter info via cmd: %s", LogCmd(cmd))
180180
try:
181181
process = Popen(
182182
cmd,
@@ -220,7 +220,7 @@ def _run_subprocess(
220220
result = cls._from_json(out)
221221
result.executable = exe
222222
except json.JSONDecodeError as exc:
223-
LOGGER.warning(
223+
_LOGGER.warning(
224224
"subprocess %s returned invalid JSON; raw stdout %d chars, start cookie %s, end cookie %s, "
225225
"parsed output %d chars: %r",
226226
exe,

src/py_discovery/_compat.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,17 @@
55
import functools
66
import logging
77
import os
8-
import sys
98
import tempfile
9+
from typing import Final
1010

11-
IS_WIN = sys.platform == "win32"
12-
LOGGER = logging.getLogger(__name__)
11+
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
1312

1413

1514
@functools.lru_cache(maxsize=1)
1615
def fs_is_case_sensitive() -> bool:
1716
with tempfile.NamedTemporaryFile(prefix="TmP") as tmp_file:
1817
result = not os.path.exists(tmp_file.name.lower())
19-
LOGGER.debug("filesystem is %scase-sensitive", "" if result else "not ")
18+
_LOGGER.debug("filesystem is %scase-sensitive", "" if result else "not ")
2019
return result
2120

2221

@@ -25,7 +24,6 @@ def fs_path_id(path: str) -> str:
2524

2625

2726
__all__ = [
28-
"IS_WIN",
2927
"fs_is_case_sensitive",
3028
"fs_path_id",
3129
]

src/py_discovery/_discovery.py

Lines changed: 41 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
import sys
66
from contextlib import suppress
77
from pathlib import Path
8-
from typing import TYPE_CHECKING
8+
from typing import TYPE_CHECKING, Final
99

1010
from platformdirs import user_data_path
1111

12-
from ._compat import IS_WIN, fs_path_id
12+
from ._compat import fs_path_id
1313
from ._py_info import PythonInfo
1414
from ._py_spec import PythonSpec
1515

@@ -18,7 +18,8 @@
1818

1919
from ._cache import PyInfoCache
2020

21-
LOGGER = logging.getLogger(__name__)
21+
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
22+
IS_WIN: Final[bool] = sys.platform == "win32"
2223

2324

2425
def get_interpreter(
@@ -41,7 +42,7 @@ def _find_interpreter(
4142
env: Mapping[str, str] | None = None,
4243
) -> PythonInfo | None:
4344
spec = PythonSpec.from_string_spec(key)
44-
LOGGER.info("find interpreter for spec %r", spec)
45+
_LOGGER.info("find interpreter for spec %r", spec)
4546
proposed_paths: set[tuple[str, bool]] = set()
4647
env = os.environ if env is None else env
4748
for interpreter, impl_must_match in propose_interpreters(spec, try_first_with, cache, env):
@@ -50,14 +51,37 @@ def _find_interpreter(
5051
proposed_key = interpreter.system_executable, impl_must_match
5152
if proposed_key in proposed_paths:
5253
continue
53-
LOGGER.info("proposed %s", interpreter)
54+
_LOGGER.info("proposed %s", interpreter)
5455
if interpreter.satisfies(spec, impl_must_match):
55-
LOGGER.debug("accepted %s", interpreter)
56+
_LOGGER.debug("accepted %s", interpreter)
5657
return interpreter
5758
proposed_paths.add(proposed_key)
5859
return None
5960

6061

62+
def _check_exe(path: str, tested_exes: set[str]) -> str | None:
63+
"""Resolve *path* to an absolute path and return it if not yet tested, otherwise ``None``."""
64+
try:
65+
os.lstat(path)
66+
except OSError:
67+
return None
68+
exe_raw = os.path.abspath(path)
69+
exe_id = fs_path_id(exe_raw)
70+
if exe_id in tested_exes:
71+
return None
72+
tested_exes.add(exe_id)
73+
return exe_raw
74+
75+
76+
def _is_new_exe(exe_raw: str, tested_exes: set[str]) -> bool:
77+
"""Return ``True`` and register *exe_raw* if it hasn't been tested yet."""
78+
exe_id = fs_path_id(exe_raw)
79+
if exe_id in tested_exes:
80+
return False
81+
tested_exes.add(exe_id)
82+
return True
83+
84+
6185
def propose_interpreters(
6286
spec: PythonSpec,
6387
try_first_with: Iterable[str],
@@ -67,76 +91,41 @@ def propose_interpreters(
6791
env = os.environ if env is None else env
6892
tested_exes: set[str] = set()
6993
if spec.is_abs and spec.path is not None:
70-
try:
71-
os.lstat(spec.path)
72-
except OSError:
73-
pass
74-
else:
75-
exe_raw = os.path.abspath(spec.path)
76-
exe_id = fs_path_id(exe_raw)
77-
if exe_id not in tested_exes: # pragma: no branch # first exe always new
78-
tested_exes.add(exe_id)
79-
yield PythonInfo.from_exe(exe_raw, cache, env=env), True
94+
if exe_raw := _check_exe(spec.path, tested_exes): # pragma: no branch # first exe always new
95+
yield PythonInfo.from_exe(exe_raw, cache, env=env), True
8096
return
8197

8298
for py_exe in try_first_with:
83-
path = os.path.abspath(py_exe)
84-
try:
85-
os.lstat(path)
86-
except OSError:
87-
pass
88-
else:
89-
exe_raw = os.path.abspath(path)
90-
exe_id = fs_path_id(exe_raw)
91-
if exe_id in tested_exes:
92-
continue
93-
tested_exes.add(exe_id)
99+
if exe_raw := _check_exe(os.path.abspath(py_exe), tested_exes):
94100
yield PythonInfo.from_exe(exe_raw, cache, env=env), True
95101

96102
if spec.path is not None:
97-
try:
98-
os.lstat(spec.path)
99-
except OSError:
100-
pass
101-
else:
102-
exe_raw = os.path.abspath(spec.path)
103-
exe_id = fs_path_id(exe_raw)
104-
if exe_id not in tested_exes: # pragma: no branch
105-
tested_exes.add(exe_id)
106-
yield PythonInfo.from_exe(exe_raw, cache, env=env), True
103+
if exe_raw := _check_exe(spec.path, tested_exes): # pragma: no branch
104+
yield PythonInfo.from_exe(exe_raw, cache, env=env), True
107105
if spec.is_abs: # pragma: no cover # relative spec.path is never abs
108106
return
109107
else:
110108
current_python = PythonInfo.current_system(cache)
111-
exe_raw = str(current_python.executable)
112-
exe_id = fs_path_id(exe_raw)
113-
if exe_id not in tested_exes:
114-
tested_exes.add(exe_id)
109+
if _is_new_exe(str(current_python.executable), tested_exes):
115110
yield current_python, True
116111

117112
if IS_WIN: # pragma: win32 cover
118113
from ._windows import propose_interpreters
119114

120115
for interpreter in propose_interpreters(spec, cache, env):
121-
exe_raw = str(interpreter.executable)
122-
exe_id = fs_path_id(exe_raw)
123-
if exe_id in tested_exes:
124-
continue
125-
tested_exes.add(exe_id)
126-
yield interpreter, True
116+
if _is_new_exe(str(interpreter.executable), tested_exes):
117+
yield interpreter, True
127118

128119
find_candidates = path_exe_finder(spec)
129120
for pos, path in enumerate(get_paths(env)):
130-
LOGGER.debug(LazyPathDump(pos, path, env))
121+
_LOGGER.debug(LazyPathDump(pos, path, env))
131122
for exe, impl_must_match in find_candidates(path):
132123
exe_raw = str(exe)
133124
if resolved := _resolve_shim(exe_raw, env):
134-
LOGGER.debug("resolved shim %s to %s", exe_raw, resolved)
125+
_LOGGER.debug("resolved shim %s to %s", exe_raw, resolved)
135126
exe_raw = resolved
136-
exe_id = fs_path_id(exe_raw)
137-
if exe_id in tested_exes:
127+
if not _is_new_exe(exe_raw, tested_exes):
138128
continue
139-
tested_exes.add(exe_id)
140129
interpreter = PathPythonInfo.from_exe(exe_raw, cache, raise_on_error=False, env=env)
141130
if interpreter is not None:
142131
yield interpreter, impl_must_match

src/py_discovery/_py_info.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import warnings
1818
from collections import OrderedDict, namedtuple
1919
from string import digits
20-
from typing import TYPE_CHECKING, ClassVar
20+
from typing import TYPE_CHECKING, ClassVar, Final
2121

2222
if TYPE_CHECKING:
2323
from collections.abc import Mapping
@@ -26,15 +26,20 @@
2626
from ._py_spec import PythonSpec
2727

2828
VersionInfo = namedtuple("VersionInfo", ["major", "minor", "micro", "releaselevel", "serial"])
29-
LOGGER = logging.getLogger(__name__)
29+
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
3030

3131

3232
def _get_path_extensions():
3333
return list(OrderedDict.fromkeys(["", *os.environ.get("PATHEXT", "").lower().split(os.pathsep)]))
3434

3535

36-
EXTENSIONS = _get_path_extensions()
37-
_CONF_VAR_RE = re.compile(r"\{\w+}")
36+
EXTENSIONS: Final[list[str]] = _get_path_extensions()
37+
_CONF_VAR_RE: Final[re.Pattern[str]] = re.compile(
38+
r"""
39+
\{ \w+ \} # sysconfig variable placeholder like {base}
40+
""",
41+
re.VERBOSE,
42+
)
3843

3944

4045
class PythonInfo:
@@ -594,7 +599,7 @@ def from_exe(
594599
except Exception as exception:
595600
if raise_on_error:
596601
raise
597-
LOGGER.info("ignore %s due cannot resolve system due to %r", proposed.original_executable, exception)
602+
_LOGGER.info("ignore %s due cannot resolve system due to %r", proposed.original_executable, exception)
598603
proposed = None
599604
return proposed
600605

@@ -619,12 +624,12 @@ def _resolve_to_system(cls, cache, target):
619624
prefix = target.real_prefix or target.base_prefix or target.prefix
620625
if prefix in prefixes:
621626
if len(prefixes) == 1:
622-
LOGGER.info("%r links back to itself via prefixes", target)
627+
_LOGGER.info("%r links back to itself via prefixes", target)
623628
target.system_executable = target.executable
624629
break
625630
for at, (p, t) in enumerate(prefixes.items(), start=1):
626-
LOGGER.error("%d: prefix=%s, info=%r", at, p, t)
627-
LOGGER.error("%d: prefix=%s, info=%r", len(prefixes) + 1, prefix, target)
631+
_LOGGER.error("%d: prefix=%s, info=%r", at, p, t)
632+
_LOGGER.error("%d: prefix=%s, info=%r", len(prefixes) + 1, prefix, target)
628633
msg = "prefixes are causing a circle {}".format("|".join(prefixes.keys()))
629634
raise RuntimeError(msg)
630635
prefixes[prefix] = target
@@ -657,9 +662,9 @@ def discover_exe(
657662
"""
658663
key = prefix, exact
659664
if key in self._cache_exe_discovery and prefix:
660-
LOGGER.debug("discover exe from cache %s - exact %s: %r", prefix, exact, self._cache_exe_discovery[key])
665+
_LOGGER.debug("discover exe from cache %s - exact %s: %r", prefix, exact, self._cache_exe_discovery[key])
661666
return self._cache_exe_discovery[key]
662-
LOGGER.debug("discover exe for %s in %s", self, prefix)
667+
_LOGGER.debug("discover exe for %s in %s", self, prefix)
663668
possible_names = self._find_possible_exe_names()
664669
possible_folders = self._find_possible_folders(prefix)
665670
discovered = []
@@ -674,7 +679,7 @@ def discover_exe(
674679
info = self._select_most_likely(discovered, self)
675680
folders = os.pathsep.join(possible_folders)
676681
self._cache_exe_discovery[key] = info
677-
LOGGER.debug("no exact match found, chosen most similar of %s within base folders %s", info, folders)
682+
_LOGGER.debug("no exact match found, chosen most similar of %s within base folders %s", info, folders)
678683
return info
679684
msg = "failed to detect {} in {}".format("|".join(possible_names), os.pathsep.join(possible_folders))
680685
raise RuntimeError(msg)
@@ -693,7 +698,7 @@ def _check_exe(self, cache, folder, name, exact, discovered, env):
693698
if item == "version_info":
694699
found, searched = ".".join(str(i) for i in found), ".".join(str(i) for i in searched)
695700
executable = info.executable
696-
LOGGER.debug("refused interpreter %s because %s differs %s != %s", executable, item, found, searched)
701+
_LOGGER.debug("refused interpreter %s because %s differs %s != %s", executable, item, found, searched)
697702
if exact is False:
698703
discovered.append(info)
699704
break

src/py_discovery/_py_spec.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def _int_or_none(val):
106106
impl = specifier_match.group("impl")
107107
spec_text = specifier_match.group("spec").strip()
108108
try:
109-
version_specifier = SpecifierSet(spec_text)
109+
version_specifier = SpecifierSet.from_string(spec_text)
110110
except InvalidSpecifier: # pragma: no cover
111111
pass
112112
else:
@@ -171,7 +171,7 @@ def _check_version_specifier(self, spec: PythonSpec) -> bool:
171171
if spec.version_specifier is None:
172172
return True
173173
with contextlib.suppress(InvalidVersion):
174-
Version(version_str)
174+
Version.from_string(version_str)
175175
for item in spec.version_specifier:
176176
required_precision = self._get_required_precision(item)
177177
if required_precision is None or len(components) < required_precision:

0 commit comments

Comments
 (0)