gooderp18绿色标准版
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

517 linhas
17KB

  1. #!/usr/bin/env python
  2. """
  3. Checks versions from the requirements files against distribution-provided
  4. versions, taking distribution's Python version in account e.g. if checking
  5. against a release which bundles Python 3.5, checks the 3.5 version of
  6. requirements.
  7. * only shows requirements for which at least one release diverges from the
  8. matching requirements version
  9. * empty or green cells mean that specific release matches its requirement (happens when
  10. checking multiple releases: one of the other releases may mismatch the its
  11. requirements necessating showing the row)
  12. This script was heavily reworked but is not in a final version:
  13. TODO:
  14. - add legends
  15. - better management of cache
  16. - add meta info on cells (mainly to genearte a better html report)
  17. - warn/ko reason
  18. - wheel + link
  19. - original debian package name + link
  20. ...
  21. """
  22. import argparse
  23. import gzip
  24. import itertools
  25. import json
  26. import os
  27. import re
  28. import shutil
  29. import tempfile
  30. try:
  31. import ansitoimg
  32. except ImportError:
  33. ansitoimg = None
  34. from abc import ABC, abstractmethod
  35. from pathlib import Path
  36. from sys import stderr, stdout
  37. from typing import Dict, List, Optional, Tuple
  38. from urllib.request import HTTPError
  39. from urllib.request import urlopen as _urlopen
  40. from packaging.markers import Marker
  41. from packaging.requirements import Requirement
  42. from packaging.tags import mac_platforms # noqa: PLC2701
  43. from packaging.utils import canonicalize_name
  44. from pip._internal.index.package_finder import (
  45. LinkEvaluator, # noqa: PLC2701
  46. )
  47. from pip._internal.models.link import Link # noqa: PLC2701
  48. from pip._internal.models.target_python import TargetPython # noqa: PLC2701
  49. Version = Tuple[int, ...]
  50. # shared beween debian and ubuntu
  51. SPECIAL = {
  52. 'pytz': 'tz',
  53. 'libsass': 'libsass-python',
  54. }
  55. SUPPORTED_FORMATS = ('txt', 'ansi', 'svg', 'html', 'json')
  56. PLATFORM_CODES = ('linux', 'win32', 'darwin')
  57. PLATFORM_NAMES = ('Linux', 'Win', 'OSX')
  58. def urlopen(url):
  59. file_name = "".join(c if c.isalnum() else '_' for c in url)
  60. os.makedirs('/tmp/package_versions_cache/', exist_ok=True)
  61. file_path = f'/tmp/package_versions_cache/{file_name}'
  62. if not os.path.isfile(file_path):
  63. response = _urlopen(url)
  64. with open(file_path, 'wb') as fw:
  65. fw.write(response.read())
  66. return open(file_path, 'rb') # noqa: SIM115
  67. def parse_version(vstring: str) -> Optional[Version]:
  68. if not vstring:
  69. return None
  70. return tuple(map(int, vstring.split('.')))
  71. def cleanup_debian_version(s: str) -> str:
  72. """ Try to strip the garbage from the version string, just remove everything
  73. following the first `+`, `~` or `-`
  74. """
  75. return re.match(r'''
  76. (?:\d+:)? # debian crud prefix
  77. (.*?) # the shit we actually want
  78. (?:~|\+|-|\.dfsg)
  79. .*
  80. ''', s, flags=re.VERBOSE)[1]
  81. class PipPackage:
  82. def __init__(self, name):
  83. self.name = name
  84. infos = json.load(urlopen(f'https://pypi.org/pypi/{name}/json'))
  85. self.info = infos['info']
  86. self.last_serial = infos['last_serial']
  87. self.releases = infos['releases']
  88. self.urls = infos['urls']
  89. self.vulnerabilities = infos['vulnerabilities']
  90. def has_wheel_for(self, version, python_version, platform):
  91. if version is None:
  92. return (False, False, False)
  93. py_version_info = python_version.split('.')
  94. if len(py_version_info) == 2:
  95. py_version_info = (py_version_info[0], py_version_info[1], 0)
  96. releases = self.releases
  97. has_wheel_for_version = False
  98. has_any_wheel = False
  99. has_wheel_in_another_version = False
  100. platforms = None
  101. if platform == 'darwin':
  102. platforms = list(mac_platforms((15, 0), 'x86_64'))
  103. elif platform == 'win32':
  104. platforms = ['win32', 'win-amd64']
  105. else:
  106. assert platform == 'linux'
  107. target_python = TargetPython(
  108. platforms=platforms,
  109. py_version_info=py_version_info,
  110. abis=None,
  111. implementation=None,
  112. )
  113. le = LinkEvaluator(
  114. project_name=self.name,
  115. canonical_name=canonicalize_name(self.name),
  116. formats={"binary", "source"},
  117. target_python=target_python,
  118. allow_yanked=True,
  119. ignore_requires_python=False,
  120. )
  121. for release in releases[version]:
  122. if release['filename'].endswith('.whl'):
  123. has_any_wheel = True
  124. is_candidate, _result = le.evaluate_link(Link(
  125. comes_from=None,
  126. url=release['url'],
  127. requires_python=release['requires_python'],
  128. yanked_reason=release['yanked_reason'],
  129. ))
  130. if is_candidate:
  131. if release['filename'].endswith('.whl'):
  132. has_wheel_for_version = has_wheel_in_another_version = True
  133. break
  134. if not has_wheel_for_version and has_any_wheel:
  135. # TODO, we should prefer a version matching the one from a distro
  136. for rel_version, rel in releases.items():
  137. for release in rel:
  138. if not release['filename'].endswith('.whl'):
  139. continue
  140. if any(not s.isdigit() for s in rel_version.split('.')) or parse_version(rel_version) <= parse_version(version):
  141. continue
  142. is_candidate, _result = le.evaluate_link(Link(
  143. comes_from=None,
  144. url=release['url'],
  145. requires_python=release['requires_python'],
  146. yanked_reason=release['yanked_reason'],
  147. ))
  148. if is_candidate:
  149. has_wheel_in_another_version = True
  150. stderr.write(f'WARNING: Wheel found for {self.name} ({python_version} {platform}) in {rel_version}\n')
  151. return (has_wheel_for_version, has_any_wheel, has_wheel_in_another_version)
  152. return (has_wheel_for_version, has_any_wheel, has_wheel_in_another_version)
  153. class Distribution(ABC):
  154. def __init__(self, release):
  155. self._release = release
  156. @abstractmethod
  157. def get_version(self, package: str) -> Optional[Version]:
  158. ...
  159. def __str__(self):
  160. return f'{type(self).__name__.lower()} {self._release}'
  161. @classmethod
  162. def get(cls, name):
  163. try:
  164. return next(
  165. c
  166. for c in cls.__subclasses__()
  167. if c.__name__.lower() == name
  168. )
  169. except StopIteration:
  170. msg = f"Unknown distribution {name!r}"
  171. raise ValueError(msg)
  172. class Debian(Distribution):
  173. def get_version(self, package):
  174. """ Try to find which version of ``package`` is in Debian release {release}
  175. """
  176. package = SPECIAL.get(package, package)
  177. # try the python prefix first: some packages have a native of foreign $X and
  178. # either the bindings or a python equivalent at python-X, or just a name
  179. # collision
  180. prefixes = ['python-', '']
  181. if package.startswith('python'):
  182. prefixes = ['']
  183. for prefix in prefixes:
  184. try:
  185. res = json.load(urlopen(f'https://sources.debian.org/api/src/{prefix}{package}/'))
  186. except HTTPError:
  187. return 'failed'
  188. if res.get('error') is None:
  189. break
  190. if res.get('error'):
  191. return
  192. try:
  193. return next(
  194. parse_version(cleanup_debian_version(distr['version']))
  195. for distr in res['versions']
  196. if distr['area'] == 'main'
  197. if self._release.lower() in distr['suites']
  198. )
  199. except StopIteration:
  200. return
  201. class Ubuntu(Distribution):
  202. """ Ubuntu doesn't have an API, instead it has a huge text file
  203. """
  204. def __init__(self, release):
  205. super().__init__(release)
  206. self._packages = {}
  207. # ideally we should request the proper Content-Encoding but PUC
  208. # apparently does not care, and returns a somewhat funky
  209. # content-encoding (x-gzip) anyway
  210. data = gzip.open(
  211. urlopen(f'https://packages.ubuntu.com/source/{release}/allpackages?format=txt.gz'),
  212. mode='rt', encoding='utf-8',
  213. )
  214. for line in itertools.islice(data, 6, None): # first 6 lines is garbage header
  215. # ignore the restricted, security, universe, multiverse tags
  216. m = re.match(r'(\S+) \(([^)]+)\)', line.strip())
  217. assert m, f"invalid line {line.strip()!r}"
  218. self._packages[m[1]] = m[2]
  219. def get_version(self, package):
  220. package = SPECIAL.get(package, package)
  221. for prefix in ['python3-', 'python-', '']:
  222. v = self._packages.get(f'{prefix}{package}')
  223. if v:
  224. return parse_version(cleanup_debian_version(v))
  225. return None
  226. def _strip_comment(line):
  227. return line.split('#', 1)[0].strip()
  228. def parse_requirements(reqpath: Path) -> Dict[str, List[Tuple[str, Marker]]]:
  229. """ Parses a requirement file to a dict of {package: [(version, markers)]}
  230. The env markers express *whether* that specific dep applies.
  231. """
  232. reqs = {}
  233. with reqpath.open('r', encoding='utf-8') as f:
  234. for req_line in f:
  235. req_line = _strip_comment(req_line)
  236. if not req_line:
  237. continue
  238. requirement = Requirement(req_line)
  239. version = None
  240. if requirement.specifier:
  241. if len(requirement.specifier) > 1:
  242. raise NotImplementedError('multi spec not supported yet')
  243. version = next(iter(requirement.specifier)).version
  244. reqs.setdefault(requirement.name, []).append((version, requirement.marker))
  245. return reqs
  246. def ok(text):
  247. return f'\033[92m{text}\033[39m'
  248. def em(text):
  249. return f'\033[94m{text}\033[39m'
  250. def warn(text):
  251. return f'\033[93m{text}\033[39m'
  252. def ko(text):
  253. return f'\033[91m{text}\033[39m'
  254. def default(text):
  255. return text
  256. def main(args):
  257. checkers = [
  258. Distribution.get(distro)(release)
  259. for version in args.release
  260. for (distro, release) in [version.split(':')]
  261. ]
  262. stderr.write("Fetch Python versions...\n")
  263. pyvers = [
  264. '.'.join(map(str, checker.get_version('python3-defaults')[:2]))
  265. for checker in checkers
  266. ]
  267. uniq = sorted(set(pyvers), key=parse_version)
  268. platforms = PLATFORM_NAMES if args.check_pypi else PLATFORM_NAMES[:1]
  269. platform_codes = PLATFORM_CODES if args.check_pypi else PLATFORM_CODES[:1]
  270. platform_headers = ['']
  271. python_headers = ['']
  272. table = [platform_headers, python_headers]
  273. # requirements headers
  274. for v in uniq:
  275. for p in platforms:
  276. platform_headers.append(p)
  277. python_headers.append(v)
  278. # distro headers
  279. for checker, version in zip(checkers, pyvers):
  280. platform_headers.append(checker._release[:5])
  281. python_headers.append(version)
  282. reqs = parse_requirements((Path.cwd() / __file__).parent.parent / 'requirements.txt')
  283. if args.filter:
  284. reqs = {r: o for r, o in reqs.items() if any(f in r for f in args.filter.split(','))}
  285. for req, options in reqs.items():
  286. if args.check_pypi:
  287. pip_infos = PipPackage(req)
  288. row = [req]
  289. seps = [' || ']
  290. byver = {}
  291. for pyver in uniq:
  292. # FIXME: when multiple options apply, check which pip uses
  293. # (first-matching. best-matching, latest, ...)
  294. seps[-1] = ' || '
  295. for platform in platform_codes:
  296. platform_version = 'none'
  297. for version, markers in options:
  298. if not markers or markers.evaluate({
  299. 'python_version': pyver,
  300. 'sys_platform': platform,
  301. }):
  302. if platform == 'linux':
  303. byver[pyver] = version
  304. platform_version = version
  305. break
  306. deco = None
  307. if args.check_pypi:
  308. if platform_version == 'none':
  309. deco = 'ok'
  310. else:
  311. has_wheel_for_version, has_any_wheel, has_wheel_in_another_version = pip_infos.has_wheel_for(platform_version, pyver, platform)
  312. if has_wheel_for_version:
  313. deco = 'ok'
  314. elif has_wheel_in_another_version:
  315. deco = 'ko'
  316. elif has_any_wheel:
  317. deco = 'warn'
  318. if deco in ("ok", None):
  319. if byver.get(pyver, 'none') != platform_version:
  320. deco = 'em'
  321. req_ver = platform_version or 'any'
  322. row.append((req_ver, deco))
  323. seps.append(' | ')
  324. seps[-1] = ' |#| '
  325. # this requirement doesn't apply, ignore
  326. if not byver and not args.all:
  327. continue
  328. for i, c in enumerate(checkers):
  329. req_version = byver.get(pyvers[i], 'none') or 'any'
  330. check_version = '.'.join(map(str, c.get_version(req.lower()) or [])) or None
  331. if req_version != check_version:
  332. deco = 'ko'
  333. if req_version == 'none':
  334. deco = 'ok'
  335. elif req_version == 'any':
  336. if check_version is None:
  337. deco = 'ok'
  338. elif check_version is None:
  339. deco = 'ko'
  340. elif parse_version(req_version) >= parse_version(check_version):
  341. deco = 'warn'
  342. row.append((check_version or '</>', deco))
  343. elif args.all:
  344. row.append((check_version or '</>', 'ok'))
  345. else:
  346. row.append('')
  347. seps.append(' |#| ')
  348. table.append(row)
  349. seps[-1] = ' ' # remove last column separator
  350. stderr.write('\n')
  351. # evaluate width of columns
  352. sizes = [0] * len(table[0])
  353. for row in table:
  354. sizes = [
  355. max(s, len(cell[0] if isinstance(cell, tuple) else cell))
  356. for s, cell in zip(sizes, row)
  357. ]
  358. output_format = 'ansi'
  359. if args.format:
  360. output_format = args.format
  361. assert format in SUPPORTED_FORMATS
  362. elif args.output:
  363. output_format = 'txt'
  364. ext = args.output.split('.')[-1]
  365. if ext in SUPPORTED_FORMATS:
  366. output_format = ext
  367. if output_format == 'json':
  368. output = json.dumps(table)
  369. else:
  370. output = ''
  371. # format table
  372. for row in table:
  373. output += ' '
  374. for cell, width, sep in zip(row, sizes, seps):
  375. cell_content = cell
  376. deco = default
  377. if isinstance(cell, tuple):
  378. cell_content, level = cell
  379. if output_format == 'txt' or level is None:
  380. deco = default
  381. elif level == 'ok':
  382. deco = ok
  383. elif level == 'em':
  384. deco = em
  385. elif level == 'warn':
  386. deco = warn
  387. else:
  388. deco = ko
  389. output += deco(f'{cell_content:<{width}}') + sep
  390. output += '\n'
  391. if output_format in ('svg', 'html'):
  392. if not ansitoimg:
  393. output_format = 'ansi'
  394. stderr.write(f'Missing ansitoimg for {output_format} format, switching to ansi')
  395. else:
  396. convert = ansitoimg.ansiToSVG
  397. if output_format == 'html':
  398. convert = ansitoimg.ansiToHTML
  399. with tempfile.NamedTemporaryFile() as tmp:
  400. convert(output, tmp.name, width=(sum(sizes) + sum(len(sep) for sep in seps)), title='requirements-check.py')
  401. output = tmp.read().decode()
  402. # remove mac like bullets
  403. output = output.replace('''<g transform="translate(26,22)">
  404. <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
  405. <circle cx="22" cy="0" r="7" fill="#febc2e"/>
  406. <circle cx="44" cy="0" r="7" fill="#28c840"/>
  407. </g>''', "") #
  408. if args.output:
  409. with open(args.output, 'w', encoding='utf8') as f:
  410. f.write(output)
  411. else:
  412. stdout.write(output)
  413. if __name__ == '__main__':
  414. parser = argparse.ArgumentParser(
  415. description=__doc__,
  416. formatter_class=argparse.RawDescriptionHelpFormatter,
  417. )
  418. parser.add_argument(
  419. 'release', nargs='+',
  420. help="Release to check against, should use the format '{distro}:{release}' e.g. 'debian:sid'"
  421. )
  422. parser.add_argument(
  423. '-a', '--all', action="store_true",
  424. help="Display all requirements even if it matches",
  425. )
  426. parser.add_argument(
  427. '-o', '--output', help="output path",
  428. )
  429. parser.add_argument(
  430. '-f', '--format', help=f"Supported format: {', '.join(SUPPORTED_FORMATS)}",
  431. )
  432. parser.add_argument(
  433. '--update-cache', action="store_true",
  434. help="Ignore the existing package version cache and update them",
  435. )
  436. parser.add_argument(
  437. '--check-pypi', action="store_true",
  438. help="Check wheel packages",
  439. )
  440. parser.add_argument(
  441. '--filter',
  442. help="Comma sepaated list of package to check",
  443. )
  444. args = parser.parse_args()
  445. if args.update_cache:
  446. shutil.rmtree('/tmp/package_versions_cache/')
  447. main(args)
上海开阖软件有限公司 沪ICP备12045867号-1