|
17 | 17 | """A collection of utilities for fades.""" |
18 | 18 |
|
19 | 19 | import json |
| 20 | +import shutil |
20 | 21 | import logging |
21 | 22 | import os |
22 | 23 | import subprocess |
|
28 | 29 | from urllib.error import HTTPError |
29 | 30 |
|
30 | 31 | from packaging.requirements import Requirement |
| 32 | +from packaging.specifiers import InvalidSpecifier, SpecifierSet |
31 | 33 | from packaging.version import Version |
32 | 34 |
|
33 | 35 | from fades import FadesError, _version |
34 | 36 |
|
35 | 37 | logger = logging.getLogger(__name__) |
36 | 38 |
|
| 39 | +# range of CPython 3 minor versions probed when auto-selecting an interpreter to |
| 40 | +# satisfy a PEP 723 'requires-python' specifier |
| 41 | +PYTHON_MINOR_RANGE = range(6, 30) |
| 42 | + |
37 | 43 | # command to retrieve the version from an external Python |
38 | 44 | SHOW_VERSION_CMD = """ |
39 | 45 | import sys, json |
@@ -162,6 +168,93 @@ def get_interpreter_version(requested_interpreter): |
162 | 168 | return (requested_interpreter, is_current) |
163 | 169 |
|
164 | 170 |
|
| 171 | +def _get_interpreter_full_version(interpreter=None): |
| 172 | + """Return the (major, minor, micro) version tuple of an interpreter.""" |
| 173 | + if interpreter is None: |
| 174 | + return tuple(sys.version_info[:3]) |
| 175 | + args = [interpreter, '-c', SHOW_VERSION_CMD] |
| 176 | + try: |
| 177 | + raw = logged_exec(args) |
| 178 | + # parse inside the try: a noisy interpreter (e.g. a shim printing to stderr, which |
| 179 | + # logged_exec merges into stdout) can make raw[0] not be the expected JSON; turning |
| 180 | + # any such failure into a FadesError lets _find_interpreter skip that candidate |
| 181 | + info = json.loads(raw[0]) |
| 182 | + return (info['major'], info['minor'], info['micro']) |
| 183 | + except Exception as error: |
| 184 | + logger.error("Error getting requested interpreter version: %s", error) |
| 185 | + raise FadesError("Could not get interpreter version") |
| 186 | + |
| 187 | + |
| 188 | +def _find_interpreter(specifier): |
| 189 | + """Search PATH for a python interpreter whose version satisfies the specifier.""" |
| 190 | + candidate_names = ["python3.{}".format(minor) for minor in PYTHON_MINOR_RANGE] |
| 191 | + candidate_names += ["python3", "python"] |
| 192 | + |
| 193 | + found = {} # path -> Version, to avoid probing the same interpreter twice |
| 194 | + for name in candidate_names: |
| 195 | + path = shutil.which(name) |
| 196 | + if path is None or path in found: |
| 197 | + continue |
| 198 | + try: |
| 199 | + major, minor, micro = _get_interpreter_full_version(path) |
| 200 | + except FadesError: |
| 201 | + continue |
| 202 | + found[path] = Version("{}.{}.{}".format(major, minor, micro)) |
| 203 | + |
| 204 | + candidates = sorted( |
| 205 | + (version, path) for path, version in found.items() |
| 206 | + if specifier.contains(version, prereleases=True)) |
| 207 | + if not candidates: |
| 208 | + return None |
| 209 | + # pick the highest satisfying version |
| 210 | + return candidates[-1][1] |
| 211 | + |
| 212 | + |
| 213 | +def get_interpreter_for_requirement(requires_python, requested_python): |
| 214 | + """Honor a PEP 723 'requires-python' specifier, returning the interpreter to use. |
| 215 | +
|
| 216 | + The returned value is the python executable/path to use, which may be |
| 217 | + 'requested_python' unchanged or an auto-discovered one. Raises FadesError if no |
| 218 | + available interpreter satisfies the specifier. |
| 219 | + """ |
| 220 | + if not requires_python: |
| 221 | + return requested_python |
| 222 | + |
| 223 | + try: |
| 224 | + specifier = SpecifierSet(requires_python) |
| 225 | + except (InvalidSpecifier, TypeError) as error: |
| 226 | + # TypeError happens when requires-python is not a string (e.g. a TOML number) |
| 227 | + logger.error("Invalid PEP 723 requires-python %r: %s", requires_python, error) |
| 228 | + raise FadesError("Invalid PEP 723 requires-python") |
| 229 | + logger.debug("Honoring PEP 723 requires-python %r", requires_python) |
| 230 | + |
| 231 | + # check the currently selected interpreter (explicit -p or fades' own) |
| 232 | + major, minor, micro = _get_interpreter_full_version(requested_python) |
| 233 | + current_version = Version("{}.{}.{}".format(major, minor, micro)) |
| 234 | + if specifier.contains(current_version, prereleases=True): |
| 235 | + return requested_python |
| 236 | + |
| 237 | + if requested_python is not None: |
| 238 | + # the user explicitly chose an interpreter that conflicts with the script; don't |
| 239 | + # silently override that choice, fail so they can resolve it |
| 240 | + msg = ("The chosen Python interpreter (version {}) does not satisfy the script's " |
| 241 | + "requires-python ({!r})".format(current_version, requires_python)) |
| 242 | + logger.error(msg) |
| 243 | + raise FadesError(msg) |
| 244 | + |
| 245 | + # nothing was explicitly requested and fades' own python doesn't satisfy the spec: |
| 246 | + # try to discover a suitable interpreter in PATH |
| 247 | + discovered = _find_interpreter(specifier) |
| 248 | + if discovered is None: |
| 249 | + msg = ("No available Python interpreter satisfies the script's requires-python " |
| 250 | + "({!r})".format(requires_python)) |
| 251 | + logger.error(msg) |
| 252 | + raise FadesError(msg) |
| 253 | + logger.info("Using Python interpreter %r to satisfy requires-python %r", |
| 254 | + discovered, requires_python) |
| 255 | + return discovered |
| 256 | + |
| 257 | + |
165 | 258 | def get_latest_version_number(project_name): |
166 | 259 | """Return latest version of a package.""" |
167 | 260 | try: |
|
0 commit comments