pre-commit/pre_commit/languages/python.py
2024-03-01 16:08:08 +00:00

289 lines
9.2 KiB
Python

from __future__ import annotations
import contextlib
import functools
import os
import sys
import tempfile
from collections.abc import Generator
from collections.abc import Sequence
from pathlib import Path
import tomlkit
from tomlkit import TOMLDocument
import pre_commit.constants as C
from pre_commit import lang_base
from pre_commit.envcontext import envcontext
from pre_commit.envcontext import PatchesT
from pre_commit.envcontext import UNSET
from pre_commit.envcontext import Var
from pre_commit.parse_shebang import find_executable
from pre_commit.prefix import Prefix
from pre_commit.util import CalledProcessError
from pre_commit.util import cmd_output
from pre_commit.util import cmd_output_b
from pre_commit.util import win_exe
ENVIRONMENT_DIR = 'py_env'
run_hook = lang_base.basic_run_hook
@functools.cache
def _version_info(exe: str) -> str:
prog = 'import sys;print(".".join(str(p) for p in sys.version_info))'
try:
return cmd_output(exe, '-S', '-c', prog)[1].strip()
except CalledProcessError:
return f'<<error retrieving version from {exe}>>'
def _read_pyvenv_cfg(filename: str) -> dict[str, str]:
ret = {}
with open(filename, encoding='UTF-8') as f:
for line in f:
try:
k, v = line.split('=')
except ValueError: # blank line / comment / etc.
continue
else:
ret[k.strip()] = v.strip()
return ret
def bin_dir(venv: str) -> str:
"""On windows there's a different directory for the virtualenv"""
bin_part = 'Scripts' if sys.platform == 'win32' else 'bin'
return os.path.join(venv, bin_part)
def get_env_patch(venv: str) -> PatchesT:
return (
('PIP_DISABLE_PIP_VERSION_CHECK', '1'),
('PYTHONHOME', UNSET),
('VIRTUAL_ENV', venv),
('PATH', (bin_dir(venv), os.pathsep, Var('PATH'))),
)
def _find_by_py_launcher(
version: str,
) -> str | None: # pragma: no cover (windows only)
if version.startswith('python'):
num = version.removeprefix('python')
cmd = ('py', f'-{num}', '-c', 'import sys; print(sys.executable)')
env = dict(os.environ, PYTHONIOENCODING='UTF-8')
try:
return cmd_output(*cmd, env=env)[1].strip()
except CalledProcessError:
pass
return None
def _find_by_sys_executable() -> str | None:
def _norm(path: str) -> str | None:
_, exe = os.path.split(path.lower())
exe, _, _ = exe.partition('.exe')
if exe not in {'python', 'pythonw'} and find_executable(exe):
return exe
return None
# On linux, I see these common sys.executables:
#
# system `python`: /usr/bin/python -> python2.7
# system `python2`: /usr/bin/python2 -> python2.7
# virtualenv v: v/bin/python (will not return from this loop)
# virtualenv v -ppython2: v/bin/python -> python2
# virtualenv v -ppython2.7: v/bin/python -> python2.7
# virtualenv v -ppypy: v/bin/python -> v/bin/pypy
for path in (sys.executable, os.path.realpath(sys.executable)):
exe = _norm(path)
if exe:
return exe
return None
@functools.lru_cache(maxsize=1)
def get_default_version() -> str: # pragma: no cover (platform dependent)
# First attempt from `sys.executable` (or the realpath)
exe = _find_by_sys_executable()
if exe:
return exe
# Next try the `pythonX.X` executable
exe = f'python{sys.version_info[0]}.{sys.version_info[1]}'
if find_executable(exe):
return exe
if _find_by_py_launcher(exe):
return exe
# We tried!
return C.DEFAULT
def _sys_executable_matches(version: str) -> bool:
if version == 'python':
return True
elif not version.startswith('python'):
return False
try:
info = tuple(int(p) for p in version.removeprefix('python').split('.'))
except ValueError:
return False
return sys.version_info[:len(info)] == info
def norm_version(version: str) -> str | None:
if version == C.DEFAULT: # use virtualenv's default
return None
elif _sys_executable_matches(version): # virtualenv defaults to our exe
return None
if sys.platform == 'win32': # pragma: no cover (windows)
version_exec = _find_by_py_launcher(version)
if version_exec:
return version_exec
# Try looking up by name
version_exec = find_executable(version)
if version_exec and version_exec != version:
return version_exec
# Otherwise assume it is a path
return os.path.expanduser(version)
@contextlib.contextmanager
def in_env(prefix: Prefix, version: str) -> Generator[None, None, None]:
envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version)
with envcontext(get_env_patch(envdir)):
yield
def health_check(prefix: Prefix, version: str) -> str | None:
envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version)
pyvenv_cfg = os.path.join(envdir, 'pyvenv.cfg')
# created with "old" virtualenv
if not os.path.exists(pyvenv_cfg):
return 'pyvenv.cfg does not exist (old virtualenv?)'
exe_name = win_exe('python')
py_exe = prefix.path(bin_dir(envdir), exe_name)
cfg = _read_pyvenv_cfg(pyvenv_cfg)
if 'version_info' not in cfg:
return "created virtualenv's pyvenv.cfg is missing `version_info`"
# always use uncached lookup here in case we replaced an unhealthy env
virtualenv_version = _version_info.__wrapped__(py_exe)
if virtualenv_version != cfg['version_info']:
return (
f'virtualenv python version did not match created version:\n'
f'- actual version: {virtualenv_version}\n'
f'- expected version: {cfg["version_info"]}\n'
)
# made with an older version of virtualenv? skip `base-executable` check
if 'base-executable' not in cfg:
return None
base_exe_version = _version_info(cfg['base-executable'])
if base_exe_version != cfg['version_info']:
return (
f'base executable python version does not match created version:\n'
f'- base-executable version: {base_exe_version}\n'
f'- expected version: {cfg["version_info"]}\n'
)
else:
return None
def install_environment(
prefix: Prefix,
version: str,
additional_dependencies: Sequence[str],
) -> None:
envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version)
venv_cmd = [sys.executable, '-mvirtualenv', envdir]
python = norm_version(version)
if python is not None:
venv_cmd.extend(('-p', python))
cmd_output_b(*venv_cmd, cwd='/')
with in_env(prefix, version):
pip_cmd = None
if 'PRE_COMMIT_USE_UV' in os.environ:
name = None
proj_file = prefix.path() / Path('pyproject.toml')
if proj_file.exists():
with open(proj_file) as fd:
parsed = tomlkit.parse(fd.read())
def try_navigate(*coords: str) -> TOMLDocument | None:
curr = parsed
for coord in coords:
if coord not in curr:
return None
curr = curr[coord]
return curr
name = try_navigate('tool', 'poetry', 'name')
if name is None:
name = try_navigate('project', 'name')
if name is None:
setup = prefix.prefix_dir / Path('setup.py')
if setup.exists():
with tempfile.TemporaryDirectory() as td:
try:
lang_base.setup_cmd(
prefix,
('uv', 'pip', 'install', 'setuptools'),
)
lang_base.setup_cmd(
prefix,
(
'python',
'setup.py',
'egg_info',
'-e',
f'{td}',
),
)
candidates = list(Path(td).glob('*.egg-info'))
if len(candidates) == 1:
name = candidates[0].name[
: (-len('.egg-info'))
]
except CalledProcessError:
pass # fallback
if name is not None:
pip_cmd = (
'uv',
'pip',
'install',
f'{name} @ .',
*additional_dependencies,
)
if pip_cmd is not None:
try:
lang_base.setup_cmd(prefix, pip_cmd)
return
except CalledProcessError:
pass # fallback
lang_base.setup_cmd(
prefix,
(
'python',
'-mpip',
'install',
'.',
*additional_dependencies,
),
)