creator.py 9.0 KB

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