creator.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. from __future__ import annotations
  2. import json
  3. import logging
  4. import os
  5. import sys
  6. import textwrap
  7. from abc import ABC, abstractmethod
  8. from argparse import ArgumentTypeError
  9. from ast import literal_eval
  10. from collections import OrderedDict
  11. from pathlib import Path
  12. from typing import TYPE_CHECKING
  13. if TYPE_CHECKING:
  14. from argparse import ArgumentParser
  15. from typing import Any, NoReturn
  16. from python_discovery import PythonInfo
  17. from virtualenv.app_data.base import AppData
  18. from virtualenv.config.cli.parser import VirtualEnvOptions
  19. from virtualenv.util.path import safe_delete
  20. from virtualenv.util.subprocess import LogCmd, run_cmd
  21. from virtualenv.version import __version__
  22. from .pyenv_cfg import PyEnvCfg
  23. HERE = Path(os.path.abspath(__file__)).parent
  24. DEBUG_SCRIPT = HERE / "debug.py"
  25. LOGGER = logging.getLogger(__name__)
  26. class CreatorMeta:
  27. def __init__(self) -> None:
  28. self.error = None
  29. class Creator(ABC):
  30. """A class that given a python Interpreter creates a virtual environment."""
  31. def __init__(self, options: VirtualEnvOptions, interpreter: PythonInfo) -> None:
  32. """Construct a new virtual environment creator.
  33. :param options: the CLI option as parsed from :meth:`add_parser_arguments`
  34. :param interpreter: the interpreter to create virtual environment from
  35. """
  36. self.interpreter = interpreter
  37. self._debug = None
  38. self.dest = Path(options.dest)
  39. self.clear = options.clear
  40. self.no_vcs_ignore = options.no_vcs_ignore
  41. self.pyenv_cfg = PyEnvCfg.from_folder(self.dest)
  42. self.app_data = options.app_data
  43. self.env = options.env
  44. self.prompt = getattr(options, "prompt", None)
  45. if TYPE_CHECKING:
  46. @property
  47. def exe(self) -> Path: ...
  48. @property
  49. def env_name(self) -> str: ...
  50. @property
  51. def bin_dir(self) -> Path: ...
  52. @property
  53. def script_dir(self) -> Path: ...
  54. @property
  55. def libs(self) -> list[Path]: ...
  56. @property
  57. def purelib(self) -> Path: ...
  58. @property
  59. def platlib(self) -> Path: ...
  60. def __repr__(self) -> str:
  61. return f"{self.__class__.__name__}({', '.join(f'{k}={v}' for k, v in self._args())})"
  62. def _args(self) -> list[tuple[str, Any]]:
  63. return [
  64. ("dest", str(self.dest)),
  65. ("clear", self.clear),
  66. ("no_vcs_ignore", self.no_vcs_ignore),
  67. ]
  68. @classmethod
  69. def can_create(cls, interpreter: PythonInfo) -> CreatorMeta | None: # noqa: ARG003
  70. """Determine if we can create a virtual environment.
  71. :param interpreter: the interpreter in question
  72. :returns: ``None`` if we can't create, any other object otherwise that will be forwarded to
  73. :meth:`add_parser_arguments`
  74. """
  75. return True # type: ignore[return-value]
  76. @classmethod
  77. def add_parser_arguments(
  78. cls,
  79. parser: ArgumentParser,
  80. interpreter: PythonInfo, # noqa: ARG003
  81. meta: CreatorMeta, # noqa: ARG003
  82. app_data: AppData, # noqa: ARG003
  83. ) -> None:
  84. """Add CLI arguments for the creator.
  85. :param parser: the CLI parser
  86. :param app_data: the application data folder
  87. :param interpreter: the interpreter we're asked to create virtual environment for
  88. :param meta: value as returned by :meth:`can_create`
  89. """
  90. parser.add_argument(
  91. "dest",
  92. help="directory to create virtualenv at",
  93. type=cls.validate_dest,
  94. )
  95. parser.add_argument(
  96. "--clear",
  97. dest="clear",
  98. action="store_true",
  99. help="remove the destination directory if exist before starting (will overwrite files otherwise)",
  100. default=False,
  101. )
  102. parser.add_argument(
  103. "--no-vcs-ignore",
  104. dest="no_vcs_ignore",
  105. action="store_true",
  106. help="don't create VCS ignore directive in the destination directory",
  107. default=False,
  108. )
  109. @abstractmethod
  110. def create(self) -> None:
  111. """Perform the virtual environment creation."""
  112. raise NotImplementedError
  113. @classmethod
  114. def validate_dest(cls, raw_value: str) -> str: # noqa: C901
  115. """No path separator in the path, valid chars and must be write-able."""
  116. def non_write_able(dest: Path, value: Path) -> NoReturn:
  117. common = Path(*os.path.commonprefix([value.parts, dest.parts]))
  118. msg = f"the destination {dest.relative_to(common)} is not write-able at {common}"
  119. raise ArgumentTypeError(msg)
  120. # the file system must be able to encode
  121. # note in newer CPython this is always utf-8 https://www.python.org/dev/peps/pep-0529/
  122. encoding = sys.getfilesystemencoding()
  123. refused = OrderedDict()
  124. kwargs = {"errors": "ignore"} if encoding != "mbcs" else {}
  125. for char in str(raw_value):
  126. try:
  127. trip = char.encode(encoding, **kwargs).decode(encoding)
  128. if trip == char:
  129. continue
  130. raise ValueError(trip) # noqa: TRY301
  131. except ValueError:
  132. refused[char] = None
  133. if refused:
  134. bad = "".join(refused.keys())
  135. msg = f"the file system codec ({encoding}) cannot handle characters {bad!r} within {raw_value!r}"
  136. raise ArgumentTypeError(msg)
  137. if os.pathsep in raw_value:
  138. msg = (
  139. f"destination {raw_value!r} must not contain the path separator ({os.pathsep})"
  140. f" as this would break the activation scripts"
  141. )
  142. raise ArgumentTypeError(msg)
  143. value = Path(raw_value)
  144. if value.exists() and value.is_file():
  145. msg = f"the destination {value} already exists and is a file"
  146. raise ArgumentTypeError(msg)
  147. dest = Path(os.path.abspath(str(value))).resolve() # on Windows absolute does not imply resolve so use both
  148. value = dest
  149. while dest:
  150. if dest.exists():
  151. if os.access(str(dest), os.W_OK):
  152. break
  153. non_write_able(dest, value)
  154. base, _ = dest.parent, dest.name
  155. if base == dest:
  156. non_write_able(dest, value) # pragma: no cover
  157. dest = base
  158. return str(value)
  159. def run(self) -> None:
  160. if self.dest.exists() and self.clear:
  161. LOGGER.debug("delete %s", self.dest)
  162. safe_delete(self.dest)
  163. self.create()
  164. self.add_cachedir_tag()
  165. self.set_pyenv_cfg()
  166. if not self.no_vcs_ignore:
  167. self.setup_ignore_vcs()
  168. def add_cachedir_tag(self) -> None:
  169. """Generate a file indicating that this is not meant to be backed up."""
  170. cachedir_tag_file = self.dest / "CACHEDIR.TAG"
  171. if not cachedir_tag_file.exists():
  172. cachedir_tag_text = textwrap.dedent("""
  173. Signature: 8a477f597d28d172789f06886806bc55
  174. # This file is a cache directory tag created by Python virtualenv.
  175. # For information about cache directory tags, see:
  176. # https://bford.info/cachedir/
  177. """).strip()
  178. cachedir_tag_file.write_text(cachedir_tag_text, encoding="utf-8")
  179. def set_pyenv_cfg(self) -> None:
  180. self.pyenv_cfg.content = OrderedDict()
  181. system_executable = self.interpreter.system_executable or self.interpreter.executable
  182. assert system_executable is not None # noqa: S101
  183. self.pyenv_cfg["home"] = os.path.dirname(os.path.abspath(system_executable))
  184. self.pyenv_cfg["implementation"] = self.interpreter.implementation
  185. self.pyenv_cfg["version_info"] = ".".join(str(i) for i in self.interpreter.version_info)
  186. self.pyenv_cfg["version"] = ".".join(str(i) for i in self.interpreter.version_info[:3])
  187. self.pyenv_cfg["executable"] = os.path.realpath(system_executable)
  188. self.pyenv_cfg["command"] = f"{sys.executable} -m virtualenv {self.dest}"
  189. self.pyenv_cfg["virtualenv"] = __version__
  190. if self.prompt is not None:
  191. prompt_value = os.path.basename(os.getcwd()) if self.prompt == "." else self.prompt
  192. self.pyenv_cfg["prompt"] = prompt_value
  193. def setup_ignore_vcs(self) -> None:
  194. """Generate ignore instructions for version control systems."""
  195. # mark this folder to be ignored by VCS, handle https://www.python.org/dev/peps/pep-0610/#registered-vcs
  196. git_ignore = self.dest / ".gitignore"
  197. if not git_ignore.exists():
  198. git_ignore.write_text("# created by virtualenv automatically\n*\n", encoding="utf-8")
  199. # Mercurial - does not support the .hgignore file inside a subdirectory directly, but only if included via the
  200. # subinclude directive from root, at which point on might as well ignore the directory itself, see
  201. # https://www.selenic.com/mercurial/hgignore.5.html for more details
  202. # Bazaar - does not support ignore files in sub-directories, only at root level via .bzrignore
  203. # Subversion - does not support ignore files, requires direct manipulation with the svn tool
  204. @property
  205. def debug(self) -> dict[str, Any] | None:
  206. """:returns: debug information about the virtual environment (only valid after :meth:`create` has run)"""
  207. if self._debug is None and self.exe is not None:
  208. self._debug = get_env_debug_info(self.exe, self.debug_script(), self.app_data, self.env)
  209. return self._debug
  210. @staticmethod
  211. def debug_script() -> Path:
  212. return DEBUG_SCRIPT
  213. def get_env_debug_info(env_exe: Path, debug_script: Path, app_data: AppData, env: dict[str, str]) -> dict[str, Any]:
  214. env = env.copy()
  215. env.pop("PYTHONPATH", None)
  216. with app_data.ensure_extracted(debug_script) as debug_script_extracted:
  217. cmd = [str(env_exe), str(debug_script_extracted)]
  218. LOGGER.debug("debug via %r", LogCmd(cmd))
  219. code, out, err = run_cmd(cmd)
  220. try:
  221. if code != 0:
  222. if out:
  223. result = literal_eval(out)
  224. else:
  225. if code == 2 and "file" in err: # noqa: PLR2004
  226. # Re-raise FileNotFoundError from `run_cmd()`
  227. raise OSError(err) # noqa: TRY301
  228. raise Exception(err) # noqa: TRY002, TRY301
  229. else:
  230. result = json.loads(out)
  231. if err:
  232. result["err"] = err
  233. except Exception as exception: # noqa: BLE001
  234. return {"out": out, "err": err, "returncode": code, "exception": repr(exception)}
  235. if "sys" in result and "path" in result["sys"]:
  236. del result["sys"]["path"][0]
  237. return result
  238. __all__ = [
  239. "Creator",
  240. "CreatorMeta",
  241. ]