summaryrefslogtreecommitdiffstats
path: root/pre_commit/languages/python.py
diff options
context:
space:
mode:
Diffstat (limited to 'pre_commit/languages/python.py')
-rw-r--r--pre_commit/languages/python.py214
1 files changed, 214 insertions, 0 deletions
diff --git a/pre_commit/languages/python.py b/pre_commit/languages/python.py
new file mode 100644
index 0000000..9f4bf69
--- /dev/null
+++ b/pre_commit/languages/python.py
@@ -0,0 +1,214 @@
+from __future__ import annotations
+
+import contextlib
+import functools
+import os
+import sys
+from collections.abc import Generator
+from collections.abc import Sequence
+
+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))
+ install_cmd = ('python', '-mpip', 'install', '.', *additional_dependencies)
+
+ cmd_output_b(*venv_cmd, cwd='/')
+ with in_env(prefix, version):
+ lang_base.setup_cmd(prefix, install_cmd)