subversion.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. from __future__ import annotations
  2. import logging
  3. import os
  4. import re
  5. from pip._internal.utils.misc import (
  6. HiddenText,
  7. display_path,
  8. is_console_interactive,
  9. is_installable_dir,
  10. split_auth_from_netloc,
  11. )
  12. from pip._internal.utils.subprocess import CommandArgs, make_command
  13. from pip._internal.vcs.versioncontrol import (
  14. AuthInfo,
  15. RemoteNotFoundError,
  16. RevOptions,
  17. VersionControl,
  18. vcs,
  19. )
  20. logger = logging.getLogger(__name__)
  21. _svn_xml_url_re = re.compile('url="([^"]+)"')
  22. _svn_rev_re = re.compile(r'committed-rev="(\d+)"')
  23. _svn_info_xml_rev_re = re.compile(r'\s*revision="(\d+)"')
  24. _svn_info_xml_url_re = re.compile(r"<url>(.*)</url>")
  25. class Subversion(VersionControl):
  26. name = "svn"
  27. dirname = ".svn"
  28. repo_name = "checkout"
  29. schemes = ("svn+ssh", "svn+http", "svn+https", "svn+svn", "svn+file")
  30. @classmethod
  31. def should_add_vcs_url_prefix(cls, remote_url: str) -> bool:
  32. return True
  33. @staticmethod
  34. def get_base_rev_args(rev: str) -> list[str]:
  35. return ["-r", rev]
  36. @classmethod
  37. def get_revision(cls, location: str) -> str:
  38. """
  39. Return the maximum revision for all files under a given location
  40. """
  41. # Note: taken from setuptools.command.egg_info
  42. revision = 0
  43. for base, dirs, _ in os.walk(location):
  44. if cls.dirname not in dirs:
  45. dirs[:] = []
  46. continue # no sense walking uncontrolled subdirs
  47. dirs.remove(cls.dirname)
  48. entries_fn = os.path.join(base, cls.dirname, "entries")
  49. if not os.path.exists(entries_fn):
  50. # FIXME: should we warn?
  51. continue
  52. dirurl, localrev = cls._get_svn_url_rev(base)
  53. if base == location:
  54. assert dirurl is not None
  55. base = dirurl + "/" # save the root url
  56. elif not dirurl or not dirurl.startswith(base):
  57. dirs[:] = []
  58. continue # not part of the same svn tree, skip it
  59. revision = max(revision, localrev)
  60. return str(revision)
  61. @classmethod
  62. def get_netloc_and_auth(
  63. cls, netloc: str, scheme: str
  64. ) -> tuple[str, tuple[str | None, str | None]]:
  65. """
  66. This override allows the auth information to be passed to svn via the
  67. --username and --password options instead of via the URL.
  68. """
  69. if scheme == "ssh":
  70. # The --username and --password options can't be used for
  71. # svn+ssh URLs, so keep the auth information in the URL.
  72. return super().get_netloc_and_auth(netloc, scheme)
  73. return split_auth_from_netloc(netloc)
  74. @classmethod
  75. def get_url_rev_and_auth(cls, url: str) -> tuple[str, str | None, AuthInfo]:
  76. # hotfix the URL scheme after removing svn+ from svn+ssh:// re-add it
  77. url, rev, user_pass = super().get_url_rev_and_auth(url)
  78. if url.startswith("ssh://"):
  79. url = "svn+" + url
  80. return url, rev, user_pass
  81. @staticmethod
  82. def make_rev_args(username: str | None, password: HiddenText | None) -> CommandArgs:
  83. extra_args: CommandArgs = []
  84. if username:
  85. extra_args += ["--username", username]
  86. if password:
  87. extra_args += ["--password", password]
  88. return extra_args
  89. @classmethod
  90. def get_remote_url(cls, location: str) -> str:
  91. # In cases where the source is in a subdirectory, we have to look up in
  92. # the location until we find a valid project root.
  93. orig_location = location
  94. while not is_installable_dir(location):
  95. last_location = location
  96. location = os.path.dirname(location)
  97. if location == last_location:
  98. # We've traversed up to the root of the filesystem without
  99. # finding a Python project.
  100. logger.warning(
  101. "Could not find Python project for directory %s (tried all "
  102. "parent directories)",
  103. orig_location,
  104. )
  105. raise RemoteNotFoundError
  106. url, _rev = cls._get_svn_url_rev(location)
  107. if url is None:
  108. raise RemoteNotFoundError
  109. return url
  110. @classmethod
  111. def _get_svn_url_rev(cls, location: str) -> tuple[str | None, int]:
  112. from pip._internal.exceptions import InstallationError
  113. entries_path = os.path.join(location, cls.dirname, "entries")
  114. if os.path.exists(entries_path):
  115. with open(entries_path) as f:
  116. data = f.read()
  117. else: # subversion >= 1.7 does not have the 'entries' file
  118. data = ""
  119. url = None
  120. if data.startswith(("8", "9", "10")):
  121. entries = list(map(str.splitlines, data.split("\n\x0c\n")))
  122. del entries[0][0] # get rid of the '8'
  123. url = entries[0][3]
  124. revs = [int(d[9]) for d in entries if len(d) > 9 and d[9]] + [0]
  125. elif data.startswith("<?xml"):
  126. match = _svn_xml_url_re.search(data)
  127. if not match:
  128. raise ValueError(f"Badly formatted data: {data!r}")
  129. url = match.group(1) # get repository URL
  130. revs = [int(m.group(1)) for m in _svn_rev_re.finditer(data)] + [0]
  131. else:
  132. try:
  133. # subversion >= 1.7
  134. # Note that using get_remote_call_options is not necessary here
  135. # because `svn info` is being run against a local directory.
  136. # We don't need to worry about making sure interactive mode
  137. # is being used to prompt for passwords, because passwords
  138. # are only potentially needed for remote server requests.
  139. xml = cls.run_command(
  140. ["info", "--xml", location],
  141. show_stdout=False,
  142. stdout_only=True,
  143. )
  144. match = _svn_info_xml_url_re.search(xml)
  145. assert match is not None
  146. url = match.group(1)
  147. revs = [int(m.group(1)) for m in _svn_info_xml_rev_re.finditer(xml)]
  148. except InstallationError:
  149. url, revs = None, []
  150. if revs:
  151. rev = max(revs)
  152. else:
  153. rev = 0
  154. return url, rev
  155. @classmethod
  156. def is_commit_id_equal(cls, dest: str, name: str | None) -> bool:
  157. """Always assume the versions don't match"""
  158. return False
  159. def __init__(self, use_interactive: bool | None = None) -> None:
  160. if use_interactive is None:
  161. use_interactive = is_console_interactive()
  162. self.use_interactive = use_interactive
  163. # This member is used to cache the fetched version of the current
  164. # ``svn`` client.
  165. # Special value definitions:
  166. # None: Not evaluated yet.
  167. # Empty tuple: Could not parse version.
  168. self._vcs_version: tuple[int, ...] | None = None
  169. super().__init__()
  170. def call_vcs_version(self) -> tuple[int, ...]:
  171. """Query the version of the currently installed Subversion client.
  172. :return: A tuple containing the parts of the version information or
  173. ``()`` if the version returned from ``svn`` could not be parsed.
  174. :raises: BadCommand: If ``svn`` is not installed.
  175. """
  176. # Example versions:
  177. # svn, version 1.10.3 (r1842928)
  178. # compiled Feb 25 2019, 14:20:39 on x86_64-apple-darwin17.0.0
  179. # svn, version 1.7.14 (r1542130)
  180. # compiled Mar 28 2018, 08:49:13 on x86_64-pc-linux-gnu
  181. # svn, version 1.12.0-SlikSvn (SlikSvn/1.12.0)
  182. # compiled May 28 2019, 13:44:56 on x86_64-microsoft-windows6.2
  183. version_prefix = "svn, version "
  184. version = self.run_command(["--version"], show_stdout=False, stdout_only=True)
  185. if not version.startswith(version_prefix):
  186. return ()
  187. version = version[len(version_prefix) :].split()[0]
  188. version_list = version.partition("-")[0].split(".")
  189. try:
  190. parsed_version = tuple(map(int, version_list))
  191. except ValueError:
  192. return ()
  193. return parsed_version
  194. def get_vcs_version(self) -> tuple[int, ...]:
  195. """Return the version of the currently installed Subversion client.
  196. If the version of the Subversion client has already been queried,
  197. a cached value will be used.
  198. :return: A tuple containing the parts of the version information or
  199. ``()`` if the version returned from ``svn`` could not be parsed.
  200. :raises: BadCommand: If ``svn`` is not installed.
  201. """
  202. if self._vcs_version is not None:
  203. # Use cached version, if available.
  204. # If parsing the version failed previously (empty tuple),
  205. # do not attempt to parse it again.
  206. return self._vcs_version
  207. vcs_version = self.call_vcs_version()
  208. self._vcs_version = vcs_version
  209. return vcs_version
  210. def get_remote_call_options(self) -> CommandArgs:
  211. """Return options to be used on calls to Subversion that contact the server.
  212. These options are applicable for the following ``svn`` subcommands used
  213. in this class.
  214. - checkout
  215. - switch
  216. - update
  217. :return: A list of command line arguments to pass to ``svn``.
  218. """
  219. if not self.use_interactive:
  220. # --non-interactive switch is available since Subversion 0.14.4.
  221. # Subversion < 1.8 runs in interactive mode by default.
  222. return ["--non-interactive"]
  223. svn_version = self.get_vcs_version()
  224. # By default, Subversion >= 1.8 runs in non-interactive mode if
  225. # stdin is not a TTY. Since that is how pip invokes SVN, in
  226. # call_subprocess(), pip must pass --force-interactive to ensure
  227. # the user can be prompted for a password, if required.
  228. # SVN added the --force-interactive option in SVN 1.8. Since
  229. # e.g. RHEL/CentOS 7, which is supported until 2024, ships with
  230. # SVN 1.7, pip should continue to support SVN 1.7. Therefore, pip
  231. # can't safely add the option if the SVN version is < 1.8 (or unknown).
  232. if svn_version >= (1, 8):
  233. return ["--force-interactive"]
  234. return []
  235. def fetch_new(
  236. self, dest: str, url: HiddenText, rev_options: RevOptions, verbosity: int
  237. ) -> None:
  238. rev_display = rev_options.to_display()
  239. logger.info(
  240. "Checking out %s%s to %s",
  241. url,
  242. rev_display,
  243. display_path(dest),
  244. )
  245. if verbosity <= 0:
  246. flags = ["--quiet"]
  247. else:
  248. flags = []
  249. cmd_args = make_command(
  250. "checkout",
  251. *flags,
  252. self.get_remote_call_options(),
  253. rev_options.to_args(),
  254. url,
  255. dest,
  256. )
  257. self.run_command(cmd_args)
  258. def switch(
  259. self,
  260. dest: str,
  261. url: HiddenText,
  262. rev_options: RevOptions,
  263. verbosity: int = 0,
  264. ) -> None:
  265. cmd_args = make_command(
  266. "switch",
  267. self.get_remote_call_options(),
  268. rev_options.to_args(),
  269. url,
  270. dest,
  271. )
  272. self.run_command(cmd_args)
  273. def update(
  274. self,
  275. dest: str,
  276. url: HiddenText,
  277. rev_options: RevOptions,
  278. verbosity: int = 0,
  279. ) -> None:
  280. cmd_args = make_command(
  281. "update",
  282. self.get_remote_call_options(),
  283. rev_options.to_args(),
  284. dest,
  285. )
  286. self.run_command(cmd_args)
  287. vcs.register(Subversion)