11from __future__ import annotations
22
3- import shutil
4- import subprocess
53import sys
64
75from functools import cached_property
86from pathlib import Path
97from typing import TYPE_CHECKING
8+ from typing import cast
9+ from typing import overload
10+
11+ import findpython
12+ import packaging .version
1013
1114from cleo .io .null_io import NullIO
1215from cleo .io .outputs .output import Verbosity
1316from poetry .core .constraints .version import Version
14- from poetry .core .constraints .version import parse_constraint
1517
16- from poetry .utils ._compat import decode
1718from poetry .utils .env .exceptions import NoCompatiblePythonVersionFoundError
18- from poetry .utils .env .script_strings import GET_PYTHON_VERSION_ONELINER
1919
2020
2121if TYPE_CHECKING :
2626
2727
2828class Python :
29- def __init__ (self , executable : str | Path , version : Version | None = None ) -> None :
30- self .executable = Path (executable )
31- self ._version = version
29+ @overload
30+ def __init__ (self , * , python : findpython .PythonVersion ) -> None : ...
31+
32+ @overload
33+ def __init__ (
34+ self , executable : str | Path , version : Version | None = None
35+ ) -> None : ...
36+
37+ # we overload __init__ to ensure we do not break any downstream plugins
38+ # that use the this
39+ def __init__ (
40+ self ,
41+ executable : str | Path | None = None ,
42+ version : Version | None = None ,
43+ python : findpython .PythonVersion | None = None ,
44+ ) -> None :
45+ if python and (executable or version ):
46+ ValueError (
47+ "When python is provided, neither executable or version must be specified"
48+ )
49+
50+ if python :
51+ self ._python = python
52+ elif executable :
53+ self ._python = findpython .PythonVersion (
54+ executable = Path (executable ),
55+ _version = packaging .version .Version (str (version )) if version else None ,
56+ )
57+ else :
58+ raise ValueError ("Either python or executable must be provided" )
3259
3360 @property
34- def version (self ) -> Version :
35- if not self ._version :
36- if self .executable == Path (sys .executable ):
37- python_version = "." .join (str (v ) for v in sys .version_info [:3 ])
38- else :
39- encoding = "locale" if sys .version_info >= (3 , 10 ) else None
40- python_version = decode (
41- subprocess .check_output (
42- [str (self .executable ), "-c" , GET_PYTHON_VERSION_ONELINER ],
43- text = True ,
44- encoding = encoding ,
45- ).strip ()
46- )
47- self ._version = Version .parse (python_version )
61+ def python (self ) -> findpython .PythonVersion :
62+ return self ._python
4863
49- return self ._version
64+ @property
65+ def executable (self ) -> Path :
66+ return cast (Path , self ._python .executable )
67+
68+ @property
69+ def version (self ) -> Version :
70+ return Version .parse (str (self ._python .version ))
5071
5172 @cached_property
5273 def patch_version (self ) -> Version :
@@ -60,66 +81,47 @@ def patch_version(self) -> Version:
6081 def minor_version (self ) -> Version :
6182 return Version .from_parts (major = self .version .major , minor = self .version .minor )
6283
63- @staticmethod
64- def _full_python_path (python : str ) -> Path | None :
65- # eg first find pythonXY.bat on windows.
66- path_python = shutil .which (python )
67- if path_python is None :
68- return None
84+ @classmethod
85+ def get_active_python (cls ) -> Python | None :
86+ if python := findpython .find ():
87+ return cls (python = python )
88+ return None
6989
90+ @classmethod
91+ def from_executable (cls , path : Path | str ) -> Python :
7092 try :
71- encoding = "locale" if sys .version_info >= (3 , 10 ) else None
72- executable = subprocess .check_output (
73- [path_python , "-c" , "import sys; print(sys.executable)" ],
74- text = True ,
75- encoding = encoding ,
76- ).strip ()
77- return Path (executable )
78-
79- except subprocess .CalledProcessError :
80- return None
81-
82- @staticmethod
83- def _detect_active_python (io : IO ) -> Path | None :
84- io .write_error_line (
85- "Trying to detect current active python executable as specified in"
86- " the config." ,
87- verbosity = Verbosity .VERBOSE ,
88- )
89-
90- executable = Python ._full_python_path ("python" )
91-
92- if executable is not None :
93- io .write_error_line (f"Found: { executable } " , verbosity = Verbosity .VERBOSE )
94- else :
95- io .write_error_line (
96- "Unable to detect the current active python executable. Falling"
97- " back to default." ,
98- verbosity = Verbosity .VERBOSE ,
99- )
100-
101- return executable
93+ return cls (python = findpython .PythonVersion (executable = Path (path )))
94+ except (FileNotFoundError , NotADirectoryError , ValueError ):
95+ raise ValueError (f"{ path } is not a valid Python executable" )
10296
10397 @classmethod
10498 def get_system_python (cls ) -> Python :
105- return cls (executable = sys .executable )
99+ return cls (
100+ python = findpython .PythonVersion (
101+ executable = Path (sys .executable ),
102+ _version = packaging .version .Version (
103+ "." .join (str (v ) for v in sys .version_info [:3 ])
104+ ),
105+ )
106+ )
106107
107108 @classmethod
108109 def get_by_name (cls , python_name : str ) -> Python | None :
109- executable = cls ._full_python_path (python_name )
110- if not executable :
111- return None
112-
113- return cls (executable = executable )
110+ if python := findpython .find (python_name ):
111+ return cls (python = python )
112+ return None
114113
115114 @classmethod
116115 def get_preferred_python (cls , config : Config , io : IO | None = None ) -> Python :
117116 io = io or NullIO ()
118117
119118 if not config .get ("virtualenvs.use-poetry-python" ) and (
120- active_python := Python ._detect_active_python ( io )
119+ active_python := Python .get_active_python ( )
121120 ):
122- return cls (executable = active_python )
121+ io .write_error_line (
122+ f"Found: { active_python .executable } " , verbosity = Verbosity .VERBOSE
123+ )
124+ return active_python
123125
124126 return cls .get_system_python ()
125127
@@ -129,39 +131,12 @@ def get_compatible_python(cls, poetry: Poetry, io: IO | None = None) -> Python:
129131 supported_python = poetry .package .python_constraint
130132 python = None
131133
132- for suffix in [
133- * sorted (
134- poetry .package .AVAILABLE_PYTHONS ,
135- key = lambda v : (v .startswith ("3" ), - len (v ), v ),
136- reverse = True ,
137- ),
138- "" ,
139- ]:
140- if len (suffix ) == 1 :
141- if not parse_constraint (f"^{ suffix } .0" ).allows_any (supported_python ):
142- continue
143- elif suffix and not supported_python .allows_any (
144- parse_constraint (suffix + ".*" )
145- ):
146- continue
147-
148- python_name = f"python{ suffix } "
149- if io .is_debug ():
150- io .write_error_line (f"<debug>Trying { python_name } </debug>" )
151-
152- executable = cls ._full_python_path (python_name )
153- if executable is None :
154- continue
155-
156- candidate = cls (executable )
157- if supported_python .allows (candidate .patch_version ):
158- python = candidate
134+ for candidate in findpython .find_all ():
135+ python = cls (python = candidate )
136+ if python .version .allows_any (supported_python ):
159137 io .write_error_line (
160- f"Using <c1>{ python_name } </c1> ({ python .patch_version } )"
138+ f"Using <c1>{ candidate . name } </c1> ({ python .patch_version } )"
161139 )
162- break
163-
164- if not python :
165- raise NoCompatiblePythonVersionFoundError (poetry .package .python_versions )
140+ return python
166141
167- return python
142+ raise NoCompatiblePythonVersionFoundError ( poetry . package . python_versions )
0 commit comments