Coverage for pycommons / processes / python.py: 82%

62 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-11 03:04 +0000

1""" 

2Some utilities for dealing with python. 

3 

4>>> PYTHON_INTERPRETER.is_file() 

5True 

6 

7>>> PYTHON_INTERPRETER_SHORT 

8'python3' 

9 

10>>> len(__BASE_PATHS) > 0 

11True 

12>>> all((isinstance(f, Path) for f in __BASE_PATHS)) 

13True 

14>>> all((len(__BASE_PATHS[i]) >= len(__BASE_PATHS[i + 1]) 

15... for i in range(len(__BASE_PATHS) - 1))) 

16True 

17""" 

18 

19 

20import os.path 

21import subprocess # nosec 

22import sys 

23from os import environ 

24from typing import Callable, Final, Mapping, cast # pylint: disable=W0611 

25 

26from pycommons.ds.immutable_map import immutable_mapping 

27from pycommons.io.path import Path, file_path 

28 

29#: the Python interpreter used to launch this program 

30PYTHON_INTERPRETER: Final[Path] = file_path(sys.executable) 

31 

32 

33def __get_python_interpreter_short() -> str: 

34 """ 

35 Get the python interpreter. 

36 

37 :returns: the fully-qualified path 

38 """ 

39 inter: Final[Path] = PYTHON_INTERPRETER 

40 inter_version: str 

41 try: 

42 # noqa # nosemgrep 

43 retval = subprocess.run( # nosec # noqa 

44 args=(inter, "--version"), check=True, # nosec # noqa 

45 text=True, timeout=10, capture_output=True) # nosec # noqa 

46 inter_version = retval.stdout 

47 del retval 

48 except subprocess.SubprocessError as se: 

49 raise ValueError(f"Interpreter {inter!r} is invalid?") from se 

50 if (str.__len__(inter_version) <= 0) or ( 

51 not inter_version.startswith("Python 3.")): 

52 raise ValueError(f"Interpreter {inter!r} has no version?") 

53 

54 def __check_is_python(s: str, __c=inter_version) -> bool: 

55 """Check whether a command results in the same Python interpreter.""" 

56 try: 

57 # noqa # nosemgrep 

58 rv = subprocess.run( # nosec # noqa 

59 args=(s, "--version"), check=True, text=True, # nosec # noqa 

60 timeout=10, capture_output=True) # nosec # noqa 

61 except subprocess.SubprocessError: 

62 return False 

63 return rv.stdout == __c 

64 

65 # check if we can just use the interpreter basename without the full path 

66 # this should usually work 

67 bn: Final[str] = inter.basename() 

68 if not __check_is_python(bn): 

69 return inter 

70 

71 # if the interpreter is something like "python3.10", then maybe "python3" 

72 # works, too? 

73 if bn.startswith("python3."): 

74 bn2: Final[str] = bn[:7] 

75 interp2: Final[str] = os.path.join(inter.up(), bn2) 

76 if (os.path.exists(interp2) and os.path.isfile(interp2) and ( 

77 file_path(interp2) == inter)) or __check_is_python(bn2): 

78 return bn2 

79 return bn 

80 

81 

82#: The python interpreter in short form. 

83PYTHON_INTERPRETER_SHORT: Final[str] = __get_python_interpreter_short() 

84del __get_python_interpreter_short 

85 

86 

87#: the base paths in which we would search for python modules 

88__BASE_PATHS: Final[tuple[Path, ...]] = tuple(sorted((p for p in { 

89 Path(d) for d in sys.path if str.__len__(d) > 0} if p.is_dir()), 

90 key=cast("Callable[[Path], int]", str.__len__), reverse=True)) 

91 

92 

93def __get_python_env() -> Mapping[str, str]: 

94 """ 

95 Get the Python-related environment variables in the current environment. 

96 

97 :returns: A mapping of variable names to values, or `None` if none were 

98 specified. 

99 """ 

100 pienv: Final[str] = "PYTHON_INTERPRETER" 

101 selected: dict[str, str] = {k: v for k, v in environ.items() if k in { 

102 "PATH", pienv, "PYTHONCASEOK", "PYTHONCOERCECLOCALE", 

103 "PYTHONDONTWRITEBYTECODE", "PYTHONEXECUTABLE", "PYTHONFAULTHANDLER", 

104 "PYTHONHASHSEED", "PYTHONHOME", "PYTHONINTMAXSTRDIGITS", 

105 "PYTHONIOENCODING", "PYTHONLEGACYWINDOWSFSENCODING", 

106 "PYTHONLEGACYWINDOWSSTDIO", "PYTHONNOUSERSITE", "PYTHONOPTIMIZE", 

107 "PYTHONPATH", "PYTHONPLATLIBDIR", "PYTHONPYCACHEPREFIX", 

108 "PYTHONSAFEPATH", "PYTHONUNBUFFERED", "PYTHONUSERBASE", "PYTHONUTF8", 

109 "PYTHONWARNDEFAULTENCODING", "PYTHONWARNINGS", "VIRTUAL_ENV"}} 

110 if pienv not in selected: 

111 selected[pienv] = PYTHON_INTERPRETER 

112 return immutable_mapping(selected) 

113 

114 

115#: The environment variables related to Python that were set in the current 

116#: process. It makes sense to pass these on with any :func:`python_command` 

117#: invocation or other calls to the Python interpreter. 

118#: This collection includes information about the Python interpreter, 

119#: executable, `PATH`, and the virtual environment, if any, as well as any 

120#: Python-related environment variables passed to this process. 

121#: The special variable `PYTHON_INTERPRETER` will be passed into this 

122#: environment. If it already exists in this process' environment, it will be 

123#: passed along as-is. If it does not exist in the current environment, it is 

124#: created and made to point to the Python executable that was used to 

125#: launch this process. 

126PYTHON_ENV: Final[Mapping[str, str]] = __get_python_env() 

127del __get_python_env 

128 

129 

130def python_command( 

131 file: str, use_short_interpreter: bool = True) -> list[str]: 

132 """ 

133 Get a python command that could be used to interpret the given file. 

134 

135 This function tries to detect whether `file` identifies a Python module 

136 of an installed package, in which case it will issue a `-m` flag in the 

137 resulting command, or whether it is some other script, in which it will 

138 just return a normal interpreter invocation. 

139 

140 Notice that you should forward :const:`PYTHON_ENV` as environment to the 

141 new Python process if it uses any packages. If we are currently running 

142 in a virtual environment, we want to tell this command about that. 

143 

144 :param file: the python script 

145 :param use_short_interpreter: use the short interpreter path, for 

146 reabability and maybe portablity, or the full path? 

147 :returns: a list that can be passed to the shell to run that program, see, 

148 e.g., :class:`pycommons.processes.shell.Command`. 

149 

150 >>> python_command(os.__file__) 

151 ['python3', '-m', 'os'] 

152 >>> python_command(__file__) 

153 ['python3', '-m', 'pycommons.processes.python'] 

154 >>> from tempfile import mkstemp 

155 >>> from os import remove as osremovex 

156 >>> from os import close as osclosex 

157 >>> h, p = mkstemp(text=True) 

158 >>> osclosex(h) 

159 >>> python_command(p) == [PYTHON_INTERPRETER_SHORT, p] 

160 True 

161 >>> python_command(p, False) == [PYTHON_INTERPRETER, p] 

162 True 

163 >>> osremovex(p) 

164 

165 >>> h, p = mkstemp(dir=file_path(__file__).up(), text=True) 

166 >>> osclosex(h) 

167 >>> python_command(p) == [PYTHON_INTERPRETER_SHORT, p] 

168 True 

169 >>> python_command(p, False) == [PYTHON_INTERPRETER, p] 

170 True 

171 >>> osremovex(p) 

172 

173 >>> the_pack = file_path(__file__).up() 

174 >>> h, p = mkstemp(dir=the_pack, 

175 ... suffix=".py", text=True) 

176 >>> osclosex(h) 

177 >>> the_str = p[len(the_pack.up(2)) + 1:-3].replace(os.sep, '.') 

178 >>> python_command(p) == [PYTHON_INTERPRETER_SHORT, "-m", the_str] 

179 True 

180 >>> python_command(p, False) == [PYTHON_INTERPRETER, "-m", the_str] 

181 True 

182 >>> osremovex(p) 

183 """ 

184 # first, get the real path to the module 

185 module: str = file_path(file) 

186 start: int = 0 

187 is_module: bool = False # is this is a module of an installed package? 

188 

189 for bp in __BASE_PATHS: 

190 if bp.contains(module): 

191 start += len(bp) + 1 

192 is_module = True 

193 break 

194 

195 end: int = len(module) 

196 if is_module: 

197 if module.endswith(".py"): 

198 end -= 3 

199 else: 

200 is_module = False 

201 

202 interpreter: Final[str] = PYTHON_INTERPRETER_SHORT \ 

203 if use_short_interpreter else PYTHON_INTERPRETER 

204 if is_module: 

205 return [interpreter, "-m", module[start:end].replace(os.sep, ".")] 

206 return [interpreter, module]