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
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 03:04 +0000
1"""
2Some utilities for dealing with python.
4>>> PYTHON_INTERPRETER.is_file()
5True
7>>> PYTHON_INTERPRETER_SHORT
8'python3'
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"""
20import os.path
21import subprocess # nosec
22import sys
23from os import environ
24from typing import Callable, Final, Mapping, cast # pylint: disable=W0611
26from pycommons.ds.immutable_map import immutable_mapping
27from pycommons.io.path import Path, file_path
29#: the Python interpreter used to launch this program
30PYTHON_INTERPRETER: Final[Path] = file_path(sys.executable)
33def __get_python_interpreter_short() -> str:
34 """
35 Get the python interpreter.
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?")
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
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
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
82#: The python interpreter in short form.
83PYTHON_INTERPRETER_SHORT: Final[str] = __get_python_interpreter_short()
84del __get_python_interpreter_short
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))
93def __get_python_env() -> Mapping[str, str]:
94 """
95 Get the Python-related environment variables in the current environment.
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)
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
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.
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.
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.
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`.
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)
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)
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?
189 for bp in __BASE_PATHS:
190 if bp.contains(module):
191 start += len(bp) + 1
192 is_module = True
193 break
195 end: int = len(module)
196 if is_module:
197 if module.endswith(".py"):
198 end -= 3
199 else:
200 is_module = False
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]