pipchecker.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. # -*- coding: utf-8 -*-
  2. import json
  3. import os
  4. import re
  5. from distutils.version import LooseVersion
  6. from urllib.parse import urlparse
  7. from urllib.error import HTTPError
  8. from urllib.request import Request, urlopen
  9. from xmlrpc.client import ServerProxy
  10. import pip
  11. from django.core.management.base import BaseCommand, CommandError
  12. from django_extensions.management.color import color_style
  13. from django_extensions.management.utils import signalcommand
  14. from pip._internal.req import InstallRequirement
  15. if LooseVersion(pip.__version__) >= LooseVersion('19.0'):
  16. from pip._internal.req.constructors import install_req_from_line # noqa
  17. try:
  18. try:
  19. from pip._internal.network.session import PipSession
  20. except ImportError:
  21. from pip._internal.download import PipSession
  22. from pip._internal.req.req_file import parse_requirements
  23. from pip._internal.utils.misc import get_installed_distributions
  24. except ImportError:
  25. # pip < 10
  26. try:
  27. from pip import get_installed_distributions
  28. from pip.download import PipSession
  29. from pip.req import parse_requirements
  30. except ImportError:
  31. raise CommandError("Pip version 6 or higher is required")
  32. try:
  33. import requests
  34. HAS_REQUESTS = True
  35. except ImportError:
  36. HAS_REQUESTS = False
  37. class Command(BaseCommand):
  38. help = "Scan pip requirement files for out-of-date packages."
  39. def add_arguments(self, parser):
  40. super().add_arguments(parser)
  41. parser.add_argument(
  42. "-t", "--github-api-token", action="store",
  43. dest="github_api_token", help="A github api authentication token."
  44. )
  45. parser.add_argument(
  46. "-r", "--requirement", action="append", dest="requirements",
  47. default=[], metavar="FILENAME",
  48. help="Check all the packages listed in the given requirements "
  49. "file. This option can be used multiple times."
  50. ),
  51. parser.add_argument(
  52. "-n", "--newer", action="store_true", dest="show_newer",
  53. help="Also show when newer version then available is installed."
  54. )
  55. @signalcommand
  56. def handle(self, *args, **options):
  57. self.style = color_style()
  58. self.options = options
  59. if options["requirements"]:
  60. req_files = options["requirements"]
  61. elif os.path.exists("requirements.txt"):
  62. req_files = ["requirements.txt"]
  63. elif os.path.exists("requirements"):
  64. req_files = [
  65. "requirements/{0}".format(f) for f in os.listdir("requirements")
  66. if os.path.isfile(os.path.join("requirements", f)) and f.lower().endswith(".txt")
  67. ]
  68. elif os.path.exists("requirements-dev.txt"):
  69. req_files = ["requirements-dev.txt"]
  70. elif os.path.exists("requirements-prod.txt"):
  71. req_files = ["requirements-prod.txt"]
  72. else:
  73. raise CommandError("Requirements file(s) not found")
  74. self.reqs = {}
  75. with PipSession() as session:
  76. for filename in req_files:
  77. for req in parse_requirements(filename, session=session):
  78. if not isinstance(req, InstallRequirement):
  79. req = install_req_from_line(req.requirement)
  80. name = req.name if req.name else req.link.filename
  81. # url attribute changed to link in pip version 6.1.0 and above
  82. if LooseVersion(pip.__version__) > LooseVersion('6.0.8'):
  83. self.reqs[name] = {
  84. "pip_req": req,
  85. "url": req.link,
  86. }
  87. else:
  88. self.reqs[name] = {
  89. "pip_req": req,
  90. "url": req.url,
  91. }
  92. if options["github_api_token"]:
  93. self.github_api_token = options["github_api_token"]
  94. elif os.environ.get("GITHUB_API_TOKEN"):
  95. self.github_api_token = os.environ.get("GITHUB_API_TOKEN")
  96. else:
  97. self.github_api_token = None # only 50 requests per hour
  98. self.check_pypi()
  99. if HAS_REQUESTS:
  100. self.check_github()
  101. else:
  102. self.stdout.write(self.style.ERROR("Cannot check github urls. The requests library is not installed. ( pip install requests )"))
  103. self.check_other()
  104. def _urlopen_as_json(self, url, headers=None):
  105. """Shorcut for return contents as json"""
  106. req = Request(url, headers=headers)
  107. return json.loads(urlopen(req).read())
  108. def _is_stable(self, version):
  109. return not re.search(r'([ab]|rc|dev)\d+$', str(version))
  110. def _available_version(self, dist_version, available):
  111. if self._is_stable(dist_version):
  112. stable = [v for v in available if self._is_stable(LooseVersion(v))]
  113. if stable:
  114. return LooseVersion(stable[0])
  115. return LooseVersion(available[0]) if available else None
  116. def check_pypi(self):
  117. """If the requirement is frozen to pypi, check for a new version."""
  118. for dist in get_installed_distributions():
  119. name = dist.project_name
  120. if name in self.reqs.keys():
  121. self.reqs[name]["dist"] = dist
  122. pypi = ServerProxy("https://pypi.python.org/pypi")
  123. for name, req in list(self.reqs.items()):
  124. if req["url"]:
  125. continue # skipping github packages.
  126. elif "dist" in req:
  127. dist = req["dist"]
  128. dist_version = LooseVersion(dist.version)
  129. available = pypi.package_releases(req["pip_req"].name, True) or pypi.package_releases(req["pip_req"].name.replace('-', '_'), True)
  130. available_version = self._available_version(dist_version, available)
  131. if not available_version:
  132. msg = self.style.WARN("release is not on pypi (check capitalization and/or --extra-index-url)")
  133. elif self.options['show_newer'] and dist_version > available_version:
  134. msg = self.style.INFO("{0} available (newer installed)".format(available_version))
  135. elif available_version > dist_version:
  136. msg = self.style.INFO("{0} available".format(available_version))
  137. else:
  138. msg = "up to date"
  139. del self.reqs[name]
  140. continue
  141. pkg_info = self.style.BOLD("{dist.project_name} {dist.version}".format(dist=dist))
  142. else:
  143. msg = "not installed"
  144. pkg_info = name
  145. self.stdout.write("{pkg_info:40} {msg}".format(pkg_info=pkg_info, msg=msg))
  146. del self.reqs[name]
  147. def check_github(self):
  148. """
  149. If the requirement is frozen to a github url, check for new commits.
  150. API Tokens
  151. ----------
  152. For more than 50 github api calls per hour, pipchecker requires
  153. authentication with the github api by settings the environemnt
  154. variable ``GITHUB_API_TOKEN`` or setting the command flag
  155. --github-api-token='mytoken'``.
  156. To create a github api token for use at the command line::
  157. curl -u 'rizumu' -d '{"scopes":["repo"], "note":"pipchecker"}' https://api.github.com/authorizations
  158. For more info on github api tokens:
  159. https://help.github.com/articles/creating-an-oauth-token-for-command-line-use
  160. http://developer.github.com/v3/oauth/#oauth-authorizations-api
  161. Requirement Format
  162. ------------------
  163. Pipchecker gets the sha of frozen repo and checks if it is
  164. found at the head of any branches. If it is not found then
  165. the requirement is considered to be out of date.
  166. Therefore, freezing at the commit hash will provide the expected
  167. results, but if freezing at a branch or tag name, pipchecker will
  168. not be able to determine with certainty if the repo is out of date.
  169. Freeze at the commit hash (sha)::
  170. git+git://github.com/django/django.git@393c268e725f5b229ecb554f3fac02cfc250d2df#egg=Django
  171. https://github.com/django/django/archive/393c268e725f5b229ecb554f3fac02cfc250d2df.tar.gz#egg=Django
  172. https://github.com/django/django/archive/393c268e725f5b229ecb554f3fac02cfc250d2df.zip#egg=Django
  173. Freeze with a branch name::
  174. git+git://github.com/django/django.git@master#egg=Django
  175. https://github.com/django/django/archive/master.tar.gz#egg=Django
  176. https://github.com/django/django/archive/master.zip#egg=Django
  177. Freeze with a tag::
  178. git+git://github.com/django/django.git@1.5b2#egg=Django
  179. https://github.com/django/django/archive/1.5b2.tar.gz#egg=Django
  180. https://github.com/django/django/archive/1.5b2.zip#egg=Django
  181. Do not freeze::
  182. git+git://github.com/django/django.git#egg=Django
  183. """
  184. for name, req in list(self.reqs.items()):
  185. req_url = req["url"]
  186. if not req_url:
  187. continue
  188. req_url = str(req_url)
  189. if req_url.startswith("git") and "github.com/" not in req_url:
  190. continue
  191. if req_url.endswith((".tar.gz", ".tar.bz2", ".zip")):
  192. continue
  193. headers = {
  194. "content-type": "application/json",
  195. }
  196. if self.github_api_token:
  197. headers["Authorization"] = "token {0}".format(self.github_api_token)
  198. try:
  199. path_parts = urlparse(req_url).path.split("#", 1)[0].strip("/").rstrip("/").split("/")
  200. if len(path_parts) == 2:
  201. user, repo = path_parts
  202. elif 'archive' in path_parts:
  203. # Supports URL of format:
  204. # https://github.com/django/django/archive/master.tar.gz#egg=Django
  205. # https://github.com/django/django/archive/master.zip#egg=Django
  206. user, repo = path_parts[:2]
  207. repo += '@' + path_parts[-1].replace('.tar.gz', '').replace('.zip', '')
  208. else:
  209. self.style.ERROR("\nFailed to parse %r\n" % (req_url, ))
  210. continue
  211. except (ValueError, IndexError) as e:
  212. self.stdout.write(self.style.ERROR("\nFailed to parse %r: %s\n" % (req_url, e)))
  213. continue
  214. try:
  215. test_auth = requests.get("https://api.github.com/django/", headers=headers).json()
  216. except HTTPError as e:
  217. self.stdout.write("\n%s\n" % str(e))
  218. return
  219. if "message" in test_auth and test_auth["message"] == "Bad credentials":
  220. self.stdout.write(self.style.ERROR("\nGithub API: Bad credentials. Aborting!\n"))
  221. return
  222. elif "message" in test_auth and test_auth["message"].startswith("API Rate Limit Exceeded"):
  223. self.stdout.write(self.style.ERROR("\nGithub API: Rate Limit Exceeded. Aborting!\n"))
  224. return
  225. frozen_commit_sha = None
  226. if ".git" in repo:
  227. repo_name, frozen_commit_full = repo.split(".git")
  228. if frozen_commit_full.startswith("@"):
  229. frozen_commit_sha = frozen_commit_full[1:]
  230. elif "@" in repo:
  231. repo_name, frozen_commit_sha = repo.split("@")
  232. if frozen_commit_sha is None:
  233. msg = self.style.ERROR("repo is not frozen")
  234. if frozen_commit_sha:
  235. branch_url = "https://api.github.com/repos/{0}/{1}/branches".format(user, repo_name)
  236. branch_data = requests.get(branch_url, headers=headers).json()
  237. frozen_commit_url = "https://api.github.com/repos/{0}/{1}/commits/{2}".format(
  238. user, repo_name, frozen_commit_sha
  239. )
  240. frozen_commit_data = requests.get(frozen_commit_url, headers=headers).json()
  241. if "message" in frozen_commit_data and frozen_commit_data["message"] == "Not Found":
  242. msg = self.style.ERROR("{0} not found in {1}. Repo may be private.".format(frozen_commit_sha[:10], name))
  243. elif frozen_commit_data["sha"] in [branch["commit"]["sha"] for branch in branch_data]:
  244. msg = self.style.BOLD("up to date")
  245. else:
  246. msg = self.style.INFO("{0} is not the head of any branch".format(frozen_commit_data["sha"][:10]))
  247. if "dist" in req:
  248. pkg_info = "{dist.project_name} {dist.version}".format(dist=req["dist"])
  249. elif frozen_commit_sha is None:
  250. pkg_info = name
  251. else:
  252. pkg_info = "{0} {1}".format(name, frozen_commit_sha[:10])
  253. self.stdout.write("{pkg_info:40} {msg}".format(pkg_info=pkg_info, msg=msg))
  254. del self.reqs[name]
  255. def check_other(self):
  256. """
  257. If the requirement is frozen somewhere other than pypi or github, skip.
  258. If you have a private pypi or use --extra-index-url, consider contributing
  259. support here.
  260. """
  261. if self.reqs:
  262. self.stdout.write(self.style.ERROR("\nOnly pypi and github based requirements are supported:"))
  263. for name, req in self.reqs.items():
  264. if "dist" in req:
  265. pkg_info = "{dist.project_name} {dist.version}".format(dist=req["dist"])
  266. elif "url" in req:
  267. pkg_info = "{url}".format(url=req["url"])
  268. else:
  269. pkg_info = "unknown package"
  270. self.stdout.write(self.style.BOLD("{pkg_info:40} is not a pypi or github requirement".format(pkg_info=pkg_info)))