461 lines
14 KiB
Python
461 lines
14 KiB
Python
import errno
|
|
import os
|
|
import posixpath
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import stat
|
|
import sys
|
|
import warnings
|
|
from contextlib import contextmanager
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
|
|
from pipenv.utils import err
|
|
from pipenv.utils.fileutils import normalize_drive, normalize_path
|
|
from pipenv.vendor import click
|
|
from pipenv.vendor.pythonfinder.utils import ensure_path, parse_python_version
|
|
|
|
from .constants import FALSE_VALUES, SCHEME_LIST, TRUE_VALUES
|
|
from .processes import subprocess_run
|
|
|
|
|
|
@lru_cache
|
|
def make_posix(path: str) -> str:
|
|
"""
|
|
Convert a path with possible windows-style separators to a posix-style path
|
|
(with **/** separators instead of **\\** separators).
|
|
|
|
:param Text path: A path to convert.
|
|
:return: A converted posix-style path
|
|
:rtype: Text
|
|
|
|
>>> make_posix("c:/users/user/venvs/some_venv\\Lib\\site-packages")
|
|
"c:/users/user/venvs/some_venv/Lib/site-packages"
|
|
|
|
>>> make_posix("c:\\users\\user\\venvs\\some_venv")
|
|
"c:/users/user/venvs/some_venv"
|
|
"""
|
|
if not isinstance(path, str):
|
|
raise TypeError(f"Expected a string for path, received {path!r}...")
|
|
starts_with_sep = path.startswith(os.path.sep)
|
|
separated = normalize_path(path).split(os.path.sep)
|
|
if isinstance(separated, (list, tuple)):
|
|
path = posixpath.join(*separated)
|
|
if starts_with_sep:
|
|
path = f"/{path}"
|
|
return path
|
|
|
|
|
|
@contextmanager
|
|
def chdir(path):
|
|
"""Context manager to change working directories."""
|
|
if not path:
|
|
return
|
|
prev_cwd = Path.cwd().as_posix()
|
|
if isinstance(path, Path):
|
|
path = path.as_posix()
|
|
os.chdir(str(path))
|
|
try:
|
|
yield
|
|
finally:
|
|
os.chdir(prev_cwd)
|
|
|
|
|
|
def looks_like_dir(path):
|
|
seps = (sep for sep in (os.path.sep, os.path.altsep) if sep is not None)
|
|
return any(sep in path for sep in seps)
|
|
|
|
|
|
def load_path(python):
|
|
import json
|
|
from pathlib import Path
|
|
|
|
python = Path(python).as_posix()
|
|
c = subprocess_run([python, "-c", "import json, sys; print(json.dumps(sys.path))"])
|
|
if c.returncode == 0:
|
|
return json.loads(c.stdout.strip())
|
|
else:
|
|
return []
|
|
|
|
|
|
def path_to_url(path):
|
|
return Path(normalize_drive(os.path.abspath(path))).as_uri()
|
|
|
|
|
|
def get_windows_path(*args):
|
|
"""Sanitize a path for windows environments
|
|
|
|
Accepts an arbitrary list of arguments and makes a clean windows path"""
|
|
return os.path.normpath(os.path.join(*args))
|
|
|
|
|
|
def find_windows_executable(bin_path, exe_name):
|
|
"""Given an executable name, search the given location for an executable"""
|
|
requested_path = get_windows_path(bin_path, exe_name)
|
|
if os.path.isfile(requested_path):
|
|
return requested_path
|
|
|
|
try:
|
|
pathext = os.environ["PATHEXT"]
|
|
except KeyError:
|
|
pass
|
|
else:
|
|
for ext in pathext.split(os.pathsep):
|
|
path = get_windows_path(bin_path, exe_name + ext.strip().lower())
|
|
if os.path.isfile(path):
|
|
return path
|
|
|
|
return shutil.which(exe_name)
|
|
|
|
|
|
def walk_up(bottom):
|
|
"""Mimic os.walk, but walk 'up' instead of down the directory tree.
|
|
From: https://gist.github.com/zdavkeos/1098474
|
|
"""
|
|
bottom = os.path.realpath(bottom)
|
|
# Get files in current dir.
|
|
try:
|
|
names = os.listdir(bottom)
|
|
except Exception:
|
|
return
|
|
|
|
dirs, nondirs = [], []
|
|
for name in names:
|
|
if os.path.isdir(os.path.join(bottom, name)):
|
|
dirs.append(name)
|
|
else:
|
|
nondirs.append(name)
|
|
yield bottom, dirs, nondirs
|
|
|
|
new_path = os.path.realpath(os.path.join(bottom, ".."))
|
|
# See if we are at the top.
|
|
if new_path == bottom:
|
|
return
|
|
|
|
yield from walk_up(new_path)
|
|
|
|
|
|
def find_requirements(max_depth=3):
|
|
"""Returns the path of a requirements.txt file in parent directories."""
|
|
i = 0
|
|
for c, _, _ in walk_up(os.getcwd()):
|
|
i += 1
|
|
if i < max_depth:
|
|
r = os.path.join(c, "requirements.txt")
|
|
if os.path.isfile(r):
|
|
return r
|
|
|
|
raise RuntimeError("No requirements.txt found!")
|
|
|
|
|
|
# Borrowed from Pew.
|
|
# See https://github.com/berdario/pew/blob/master/pew/_utils.py#L82
|
|
@contextmanager
|
|
def temp_environ():
|
|
"""Allow the ability to set os.environ temporarily"""
|
|
environ = dict(os.environ)
|
|
try:
|
|
yield
|
|
|
|
finally:
|
|
os.environ.clear()
|
|
os.environ.update(environ)
|
|
|
|
|
|
def escape_cmd(cmd):
|
|
if any(special_char in cmd for special_char in ["<", ">", "&", ".", "^", "|", "?"]):
|
|
cmd = f'"{cmd}"'
|
|
return cmd
|
|
|
|
|
|
def safe_expandvars(value):
|
|
"""Call os.path.expandvars if value is a string, otherwise do nothing."""
|
|
if isinstance(value, str):
|
|
return os.path.expandvars(value)
|
|
return value
|
|
|
|
|
|
def cmd_list_to_shell(args):
|
|
"""Convert a list of arguments to a quoted shell command."""
|
|
return " ".join(shlex.quote(str(token)) for token in args)
|
|
|
|
|
|
def get_workon_home():
|
|
workon_home = os.environ.get("WORKON_HOME")
|
|
if not workon_home:
|
|
if os.name == "nt":
|
|
workon_home = "~/.virtualenvs"
|
|
else:
|
|
workon_home = os.path.join(
|
|
os.environ.get("XDG_DATA_HOME", "~/.local/share"), "virtualenvs"
|
|
)
|
|
# Create directory if it does not already exist
|
|
expanded_path = Path(os.path.expandvars(workon_home)).expanduser()
|
|
expanded_path = ensure_path(expanded_path)
|
|
os.makedirs(expanded_path, exist_ok=True)
|
|
return expanded_path
|
|
|
|
|
|
def is_file(package):
|
|
"""Determine if a package name is for a File dependency."""
|
|
if hasattr(package, "keys"):
|
|
return any(key for key in package if key in ["file", "path"])
|
|
|
|
if os.path.exists(str(package)):
|
|
return True
|
|
|
|
return any(str(package).startswith(start) for start in SCHEME_LIST)
|
|
|
|
|
|
def is_virtual_environment(path):
|
|
"""Check if a given path is a virtual environment's root.
|
|
|
|
This is done by checking if the directory contains a Python executable in
|
|
its bin/Scripts directory. Not technically correct, but good enough for
|
|
general usage.
|
|
"""
|
|
if not path.is_dir():
|
|
return False
|
|
for bindir_name in ("bin", "Scripts"):
|
|
for python in path.joinpath(bindir_name).glob("python*"):
|
|
try:
|
|
exeness = python.is_file() and os.access(str(python), os.X_OK)
|
|
except OSError:
|
|
exeness = False
|
|
if exeness:
|
|
return True
|
|
return False
|
|
|
|
|
|
def find_python(finder, line=None):
|
|
"""
|
|
Given a `pythonfinder.Finder` instance and an optional line, find a corresponding python
|
|
|
|
:param finder: A :class:`pythonfinder.Finder` instance to use for searching
|
|
:type finder: :class:pythonfinder.Finder`
|
|
:param str line: A version, path, name, or nothing, defaults to None
|
|
:return: A path to python
|
|
:rtype: str
|
|
"""
|
|
if line and not isinstance(line, str):
|
|
raise TypeError(f"Invalid python search type: expected string, received {line!r}")
|
|
if line:
|
|
modified_line = line
|
|
if (
|
|
os.name == "nt"
|
|
and not os.path.exists(modified_line)
|
|
and not modified_line.lower().endswith(".exe")
|
|
):
|
|
modified_line += ".exe"
|
|
if os.path.exists(modified_line) and shutil.which(modified_line):
|
|
return modified_line
|
|
|
|
if not finder:
|
|
from pipenv.vendor.pythonfinder import Finder
|
|
|
|
finder = Finder(global_search=True)
|
|
if not line:
|
|
result = next(iter(finder.find_all_python_versions()), None)
|
|
elif line and line[0].isdigit() or re.match(r"^\d+(\.\d+)*$", line):
|
|
version_info = parse_python_version(line)
|
|
result = finder.find_python_version(
|
|
major=version_info.get("major"),
|
|
minor=version_info.get("minor"),
|
|
patch=version_info.get("patch"),
|
|
pre=version_info.get("is_prerelease"),
|
|
dev=version_info.get("is_devrelease"),
|
|
sort_by_path=True,
|
|
)
|
|
else:
|
|
result = finder.find_python_version(name=line)
|
|
if not result:
|
|
result = finder.which(line)
|
|
if not result and "python" not in line.lower():
|
|
line = f"python{line}"
|
|
result = find_python(finder, line)
|
|
|
|
if result:
|
|
if not isinstance(result, str):
|
|
return result.path.as_posix()
|
|
return result
|
|
return
|
|
|
|
|
|
def is_python_command(line):
|
|
"""
|
|
Given an input, checks whether the input is a request for python or notself.
|
|
|
|
This can be a version, a python runtime name, or a generic 'python' or 'pythonX.Y'
|
|
|
|
:param str line: A potential request to find python
|
|
:returns: Whether the line is a python lookup
|
|
:rtype: bool
|
|
"""
|
|
|
|
if not isinstance(line, str):
|
|
raise TypeError(f"Not a valid command to check: {line!r}")
|
|
|
|
from pipenv.vendor.pythonfinder.utils import PYTHON_IMPLEMENTATIONS
|
|
|
|
is_version = re.match(r"\d+(\.\d+)*", line)
|
|
if (
|
|
line.startswith("python")
|
|
or is_version
|
|
or any(line.startswith(v) for v in PYTHON_IMPLEMENTATIONS)
|
|
):
|
|
return True
|
|
# we are less sure about this but we can guess
|
|
if line.startswith("py"):
|
|
return True
|
|
return False
|
|
|
|
|
|
@contextmanager
|
|
def temp_path():
|
|
"""Allow the ability to set os.environ temporarily"""
|
|
path = list(sys.path)
|
|
try:
|
|
yield
|
|
finally:
|
|
sys.path = list(path)
|
|
|
|
|
|
def is_readonly_path(fn):
|
|
"""Check if a provided path exists and is readonly.
|
|
|
|
Permissions check is `bool(path.stat & stat.S_IREAD)` or `not os.access(path, os.W_OK)`
|
|
"""
|
|
if os.path.exists(fn):
|
|
return (os.stat(fn).st_mode & stat.S_IREAD) or not os.access(fn, os.W_OK)
|
|
|
|
return False
|
|
|
|
|
|
def set_write_bit(fn):
|
|
if isinstance(fn, str) and not os.path.exists(fn):
|
|
return
|
|
os.chmod(fn, stat.S_IWRITE | stat.S_IWUSR | stat.S_IRUSR)
|
|
return
|
|
|
|
|
|
def handle_remove_readonly(func, path, exc):
|
|
"""Error handler for shutil.rmtree.
|
|
|
|
Windows source repo folders are read-only by default, so this error handler
|
|
attempts to set them as writeable and then proceed with deletion."""
|
|
# Check for read-only attribute
|
|
default_warning_message = "Unable to remove file due to permissions restriction: {!r}"
|
|
# split the initial exception out into its type, exception, and traceback
|
|
exc_type, exc_exception, exc_tb = exc
|
|
if is_readonly_path(path):
|
|
# Apply write permission and call original function
|
|
set_write_bit(path)
|
|
try:
|
|
func(path)
|
|
except OSError as e:
|
|
if e.errno in [errno.EACCES, errno.EPERM]:
|
|
warnings.warn(
|
|
default_warning_message.format(path), ResourceWarning, stacklevel=1
|
|
)
|
|
return
|
|
|
|
if exc_exception.errno in [errno.EACCES, errno.EPERM]:
|
|
warnings.warn(default_warning_message.format(path), ResourceWarning, stacklevel=1)
|
|
return
|
|
|
|
raise exc
|
|
|
|
|
|
def style_no_color(text, fg=None, bg=None, **kwargs) -> str:
|
|
"""Wrap click style to ignore colors."""
|
|
if hasattr(click, "original_style"):
|
|
return click.original_style(text, **kwargs)
|
|
return click.style(text, **kwargs)
|
|
|
|
|
|
def env_to_bool(val):
|
|
"""
|
|
Convert **val** to boolean, returning True if truthy or False if falsey
|
|
|
|
:param Any val: The value to convert
|
|
:return: False if falsey, True if truthy
|
|
:rtype: bool
|
|
:raises:
|
|
ValueError: if val is not a valid boolean-like
|
|
"""
|
|
if isinstance(val, bool):
|
|
return val
|
|
|
|
try:
|
|
if val.lower() in FALSE_VALUES:
|
|
return False
|
|
if val.lower() in TRUE_VALUES:
|
|
return True
|
|
except AttributeError:
|
|
pass
|
|
|
|
raise ValueError(f"Value is not a valid boolean-like: {val}")
|
|
|
|
|
|
def is_env_truthy(name):
|
|
"""An environment variable is truthy if it exists and isn't one of (0, false, no, off)"""
|
|
return env_to_bool(os.getenv(name, False)) # noqa: PLW1508
|
|
|
|
|
|
def project_python(project, system=False):
|
|
if not system:
|
|
python = project._which("python")
|
|
else:
|
|
interpreters = [system_which(p) for p in ("python3", "python")]
|
|
interpreters = [i for i in interpreters if i] # filter out not found interpreters
|
|
python = interpreters[0] if interpreters else None
|
|
if not python:
|
|
err.print("The Python interpreter can't be found.", style="red")
|
|
sys.exit(1)
|
|
return Path(python).as_posix()
|
|
|
|
|
|
def system_which(command, path=None):
|
|
"""Emulates the system's which. Returns None if not found."""
|
|
import shutil
|
|
|
|
result = shutil.which(command, path=path)
|
|
if result is None:
|
|
_which = "where" if os.name == "nt" else "which -a"
|
|
env = {"PATH": path} if path else None
|
|
c = subprocess_run(f"{_which} {command}", shell=True, env=env)
|
|
if c.returncode == 127:
|
|
click.echo(
|
|
"{}: the {} system utility is required for Pipenv to find Python installations properly."
|
|
"\n Please install it.".format(
|
|
click.style("Warning", fg="red", bold=True),
|
|
click.style(_which, fg="yellow"),
|
|
),
|
|
err=True,
|
|
)
|
|
if c.returncode == 0:
|
|
result = next(iter(c.stdout.splitlines()), None)
|
|
return result
|
|
|
|
|
|
def shorten_path(location, bold=False):
|
|
"""Returns a visually shorter representation of a given system path."""
|
|
original = location
|
|
short = os.sep.join(
|
|
[s[0] if len(s) > (len("2long4")) else s for s in location.split(os.sep)]
|
|
)
|
|
short = short.split(os.sep)
|
|
short[-1] = original.split(os.sep)[-1]
|
|
if bold:
|
|
short[-1] = str(click.style(short[-1], bold=True))
|
|
return os.sep.join(short)
|
|
|
|
|
|
def isatty(stream):
|
|
try:
|
|
is_a_tty = stream.isatty()
|
|
except Exception: # pragma: no cover
|
|
is_a_tty = False
|
|
return is_a_tty
|