subversion.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. import logging
  2. import os
  3. import re
  4. from typing import List, Optional, Tuple
  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[Optional[str], Optional[str]]]:
  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, Optional[str], 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(
  83. username: Optional[str], password: Optional[HiddenText]
  84. ) -> CommandArgs:
  85. extra_args: CommandArgs = []
  86. if username:
  87. extra_args += ["--username", username]
  88. if password:
  89. extra_args += ["--password", password]
  90. return extra_args
  91. @classmethod
  92. def get_remote_url(cls, location: str) -> str:
  93. # In cases where the source is in a subdirectory, we have to look up in
  94. # the location until we find a valid project root.
  95. orig_location = location
  96. while not is_installable_dir(location):
  97. last_location = location
  98. location = os.path.dirname(location)
  99. if location == last_location:
  100. # We've traversed up to the root of the filesystem without
  101. # finding a Python project.
  102. logger.warning(
  103. "Could not find Python project for directory %s (tried all "
  104. "parent directories)",
  105. orig_location,
  106. )
  107. raise RemoteNotFoundError
  108. url, _rev = cls._get_svn_url_rev(location)
  109. if url is None:
  110. raise RemoteNotFoundError
  111. return url
  112. @classmethod
  113. def _get_svn_url_rev(cls, location: str) -> Tuple[Optional[str], int]:
  114. from pip._internal.exceptions import InstallationError
  115. entries_path = os.path.join(location, cls.dirname, "entries")
  116. if os.path.exists(entries_path):
  117. with open(entries_path) as f:
  118. data = f.read()
  119. else: # subversion >= 1.7 does not have the 'entries' file
  120. data = ""
  121. url = None
  122. if data.startswith("8") or data.startswith("9") or data.startswith("10"):
  123. entries = list(map(str.splitlines, data.split("\n\x0c\n")))
  124. del entries[0][0] # get rid of the '8'
  125. url = entries[0][3]
  126. revs = [int(d[9]) for d in entries if len(d) > 9 and d[9]] + [0]
  127. elif data.startswith("<?xml"):
  128. match = _svn_xml_url_re.search(data)
  129. if not match:
  130. raise ValueError(f"Badly formatted data: {data!r}")
  131. url = match.group(1) # get repository URL
  132. revs = [int(m.group(1)) for m in _svn_rev_re.finditer(data)] + [0]
  133. else:
  134. try:
  135. # subversion >= 1.7
  136. # Note that using get_remote_call_options is not necessary here
  137. # because `svn info` is being run against a local directory.
  138. # We don't need to worry about making sure interactive mode
  139. # is being used to prompt for passwords, because passwords
  140. # are only potentially needed for remote server requests.
  141. xml = cls.run_command(
  142. ["info", "--xml", location],
  143. show_stdout=False,
  144. stdout_only=True,
  145. )
  146. match = _svn_info_xml_url_re.search(xml)
  147. assert match is not None
  148. url = match.group(1)
  149. revs = [int(m.group(1)) for m in _svn_info_xml_rev_re.finditer(xml)]
  150. except InstallationError:
  151. url, revs = None, []
  152. if revs:
  153. rev = max(revs)
  154. else:
  155. rev = 0
  156. return url, rev
  157. @classmethod
  158. def is_commit_id_equal(cls, dest: str, name: Optional[str]) -> bool:
  159. """Always assume the versions don't match"""
  160. return False
  161. def __init__(self, use_interactive: Optional[bool] = None) -> None:
  162. if use_interactive is None:
  163. use_interactive = is_console_interactive()
  164. self.use_interactive = use_interactive
  165. # This member is used to cache the fetched version of the current
  166. # ``svn`` client.
  167. # Special value definitions:
  168. # None: Not evaluated yet.
  169. # Empty tuple: Could not parse version.
  170. self._vcs_version: Optional[Tuple[int, ...]] = None
  171. super().__init__()
  172. def call_vcs_version(self) -> Tuple[int, ...]:
  173. """Query the version of the currently installed Subversion client.
  174. :return: A tuple containing the parts of the version information or
  175. ``()`` if the version returned from ``svn`` could not be parsed.
  176. :raises: BadCommand: If ``svn`` is not installed.
  177. """
  178. # Example versions:
  179. # svn, version 1.10.3 (r1842928)
  180. # compiled Feb 25 2019, 14:20:39 on x86_64-apple-darwin17.0.0
  181. # svn, version 1.7.14 (r1542130)
  182. # compiled Mar 28 2018, 08:49:13 on x86_64-pc-linux-gnu
  183. # svn, version 1.12.0-SlikSvn (SlikSvn/1.12.0)
  184. # compiled May 28 2019, 13:44:56 on x86_64-microsoft-windows6.2
  185. version_prefix = "svn, version "
  186. version = self.run_command(["--version"], show_stdout=False, stdout_only=True)
  187. if not version.startswith(version_prefix):
  188. return ()
  189. version = version[len(version_prefix) :].split()[0]
  190. version_list = version.partition("-")[0].split(".")
  191. try:
  192. parsed_version = tuple(map(int, version_list))
  193. except ValueError:
  194. return ()
  195. return parsed_version
  196. def get_vcs_version(self) -> Tuple[int, ...]:
  197. """Return the version of the currently installed Subversion client.
  198. If the version of the Subversion client has already been queried,
  199. a cached value will be used.
  200. :return: A tuple containing the parts of the version information or
  201. ``()`` if the version returned from ``svn`` could not be parsed.
  202. :raises: BadCommand: If ``svn`` is not installed.
  203. """
  204. if self._vcs_version is not None:
  205. # Use cached version, if available.
  206. # If parsing the version failed previously (empty tuple),
  207. # do not attempt to parse it again.
  208. return self._vcs_version
  209. vcs_version = self.call_vcs_version()
  210. self._vcs_version = vcs_version
  211. return vcs_version
  212. def get_remote_call_options(self) -> CommandArgs:
  213. """Return options to be used on calls to Subversion that contact the server.
  214. These options are applicable for the following ``svn`` subcommands used
  215. in this class.
  216. - checkout
  217. - switch
  218. - update
  219. :return: A list of command line arguments to pass to ``svn``.
  220. """
  221. if not self.use_interactive:
  222. # --non-interactive switch is available since Subversion 0.14.4.
  223. # Subversion < 1.8 runs in interactive mode by default.
  224. return ["--non-interactive"]
  225. svn_version = self.get_vcs_version()
  226. # By default, Subversion >= 1.8 runs in non-interactive mode if
  227. # stdin is not a TTY. Since that is how pip invokes SVN, in
  228. # call_subprocess(), pip must pass --force-interactive to ensure
  229. # the user can be prompted for a password, if required.
  230. # SVN added the --force-interactive option in SVN 1.8. Since
  231. # e.g. RHEL/CentOS 7, which is supported until 2024, ships with
  232. # SVN 1.7, pip should continue to support SVN 1.7. Therefore, pip
  233. # can't safely add the option if the SVN version is < 1.8 (or unknown).
  234. if svn_version >= (1, 8):
  235. return ["--force-interactive"]
  236. return []
  237. def fetch_new(
  238. self, dest: str, url: HiddenText, rev_options: RevOptions, verbosity: int
  239. ) -> None:
  240. rev_display = rev_options.to_display()
  241. logger.info(
  242. "Checking out %s%s to %s",
  243. url,
  244. rev_display,
  245. display_path(dest),
  246. )
  247. if verbosity <= 0:
  248. flag = "--quiet"
  249. else:
  250. flag = ""
  251. cmd_args = make_command(
  252. "checkout",
  253. flag,
  254. self.get_remote_call_options(),
  255. rev_options.to_args(),
  256. url,
  257. dest,
  258. )
  259. self.run_command(cmd_args)
  260. def switch(self, dest: str, url: HiddenText, rev_options: RevOptions) -> None:
  261. cmd_args = make_command(
  262. "switch",
  263. self.get_remote_call_options(),
  264. rev_options.to_args(),
  265. url,
  266. dest,
  267. )
  268. self.run_command(cmd_args)
  269. def update(self, dest: str, url: HiddenText, rev_options: RevOptions) -> None:
  270. cmd_args = make_command(
  271. "update",
  272. self.get_remote_call_options(),
  273. rev_options.to_args(),
  274. dest,
  275. )
  276. self.run_command(cmd_args)
  277. vcs.register(Subversion)