codekingpro/portable-devtools
114k
1from __future__ import annotations2 3import json4import logging5import os6import sys7from abc import ABC, abstractmethod8from argparse import ArgumentTypeError9from ast import literal_eval10from collections import OrderedDict11from pathlib import Path12 13from virtualenv.discovery.cached_py_info import LogCmd14from virtualenv.util.path import safe_delete15from virtualenv.util.subprocess import run_cmd16from virtualenv.version import __version__17 18from .pyenv_cfg import PyEnvCfg19 20HERE = Path(os.path.abspath(__file__)).parent21DEBUG_SCRIPT = HERE / "debug.py"22 23 24class CreatorMeta:25 def __init__(self) -> None:26 self.error = None27 28 29class Creator(ABC):30 """A class that given a python Interpreter creates a virtual environment."""31 32 def __init__(self, options, interpreter) -> None:33 """34 Construct a new virtual environment creator.35 36 :param options: the CLI option as parsed from :meth:`add_parser_arguments`37 :param interpreter: the interpreter to create virtual environment from38 """39 self.interpreter = interpreter40 self._debug = None41 self.dest = Path(options.dest)42 self.clear = options.clear43 self.no_vcs_ignore = options.no_vcs_ignore44 self.pyenv_cfg = PyEnvCfg.from_folder(self.dest)45 self.app_data = options.app_data46 self.env = options.env47 48 def __repr__(self) -> str:49 return f"{self.__class__.__name__}({', '.join(f'{k}={v}' for k, v in self._args())})"50 51 def _args(self):52 return [53 ("dest", str(self.dest)),54 ("clear", self.clear),55 ("no_vcs_ignore", self.no_vcs_ignore),56 ]57 58 @classmethod59 def can_create(cls, interpreter): # noqa: ARG00360 """61 Determine if we can create a virtual environment.62 63 :param interpreter: the interpreter in question64 :return: ``None`` if we can't create, any other object otherwise that will be forwarded to \65 :meth:`add_parser_arguments`66 """67 return True68 69 @classmethod70 def add_parser_arguments(cls, parser, interpreter, meta, app_data): # noqa: ARG00371 """72 Add CLI arguments for the creator.73 74 :param parser: the CLI parser75 :param app_data: the application data folder76 :param interpreter: the interpreter we're asked to create virtual environment for77 :param meta: value as returned by :meth:`can_create`78 """79 parser.add_argument(80 "dest",81 help="directory to create virtualenv at",82 type=cls.validate_dest,83 )84 parser.add_argument(85 "--clear",86 dest="clear",87 action="store_true",88 help="remove the destination directory if exist before starting (will overwrite files otherwise)",89 default=False,90 )91 parser.add_argument(92 "--no-vcs-ignore",93 dest="no_vcs_ignore",94 action="store_true",95 help="don't create VCS ignore directive in the destination directory",96 default=False,97 )98 99 @abstractmethod100 def create(self):101 """Perform the virtual environment creation."""102 raise NotImplementedError103 104 @classmethod105 def validate_dest(cls, raw_value): # noqa: C901106 """No path separator in the path, valid chars and must be write-able."""107 108 def non_write_able(dest, value):109 common = Path(*os.path.commonprefix([value.parts, dest.parts]))110 msg = f"the destination {dest.relative_to(common)} is not write-able at {common}"111 raise ArgumentTypeError(msg)112 113 # the file system must be able to encode114 # note in newer CPython this is always utf-8 https://www.python.org/dev/peps/pep-0529/115 encoding = sys.getfilesystemencoding()116 refused = OrderedDict()117 kwargs = {"errors": "ignore"} if encoding != "mbcs" else {}118 for char in str(raw_value):119 try:120 trip = char.encode(encoding, **kwargs).decode(encoding)121 if trip == char:122 continue123 raise ValueError(trip) # noqa: TRY301124 except ValueError:125 refused[char] = None126 if refused:127 bad = "".join(refused.keys())128 msg = f"the file system codec ({encoding}) cannot handle characters {bad!r} within {raw_value!r}"129 raise ArgumentTypeError(msg)130 if os.pathsep in raw_value:131 msg = (132 f"destination {raw_value!r} must not contain the path separator ({os.pathsep})"133 f" as this would break the activation scripts"134 )135 raise ArgumentTypeError(msg)136 137 value = Path(raw_value)138 if value.exists() and value.is_file():139 msg = f"the destination {value} already exists and is a file"140 raise ArgumentTypeError(msg)141 dest = Path(os.path.abspath(str(value))).resolve() # on Windows absolute does not imply resolve so use both142 value = dest143 while dest:144 if dest.exists():145 if os.access(str(dest), os.W_OK):146 break147 non_write_able(dest, value)148 base, _ = dest.parent, dest.name149 if base == dest:150 non_write_able(dest, value) # pragma: no cover151 dest = base152 return str(value)153 154 def run(self):155 if self.dest.exists() and self.clear:156 logging.debug("delete %s", self.dest)157 safe_delete(self.dest)158 self.create()159 self.set_pyenv_cfg()160 if not self.no_vcs_ignore:161 self.setup_ignore_vcs()162 163 def set_pyenv_cfg(self):164 self.pyenv_cfg.content = OrderedDict()165 self.pyenv_cfg["home"] = os.path.dirname(os.path.abspath(self.interpreter.system_executable))166 self.pyenv_cfg["implementation"] = self.interpreter.implementation167 self.pyenv_cfg["version_info"] = ".".join(str(i) for i in self.interpreter.version_info)168 self.pyenv_cfg["virtualenv"] = __version__169 170 def setup_ignore_vcs(self):171 """Generate ignore instructions for version control systems."""172 # mark this folder to be ignored by VCS, handle https://www.python.org/dev/peps/pep-0610/#registered-vcs173 git_ignore = self.dest / ".gitignore"174 if not git_ignore.exists():175 git_ignore.write_text("# created by virtualenv automatically\n*\n", encoding="utf-8")176 # Mercurial - does not support the .hgignore file inside a subdirectory directly, but only if included via the177 # subinclude directive from root, at which point on might as well ignore the directory itself, see178 # https://www.selenic.com/mercurial/hgignore.5.html for more details179 # Bazaar - does not support ignore files in sub-directories, only at root level via .bzrignore180 # Subversion - does not support ignore files, requires direct manipulation with the svn tool181 182 @property183 def debug(self):184 """:return: debug information about the virtual environment (only valid after :meth:`create` has run)"""185 if self._debug is None and self.exe is not None:186 self._debug = get_env_debug_info(self.exe, self.debug_script(), self.app_data, self.env)187 return self._debug188 189 @staticmethod190 def debug_script():191 return DEBUG_SCRIPT192 193 194def get_env_debug_info(env_exe, debug_script, app_data, env):195 env = env.copy()196 env.pop("PYTHONPATH", None)197 198 with app_data.ensure_extracted(debug_script) as debug_script_extracted:199 cmd = [str(env_exe), str(debug_script_extracted)]200 logging.debug("debug via %r", LogCmd(cmd))201 code, out, err = run_cmd(cmd)202 203 try:204 if code != 0:205 if out:206 result = literal_eval(out)207 else:208 if code == 2 and "file" in err: # noqa: PLR2004209 # Re-raise FileNotFoundError from `run_cmd()`210 raise OSError(err) # noqa: TRY301211 raise Exception(err) # noqa: TRY002, TRY301212 else:213 result = json.loads(out)214 if err:215 result["err"] = err216 except Exception as exception: # noqa: BLE001217 return {"out": out, "err": err, "returncode": code, "exception": repr(exception)}218 if "sys" in result and "path" in result["sys"]:219 del result["sys"]["path"][0]220 return result221 222 223__all__ = [224 "Creator",225 "CreatorMeta",226]227 