check-dependencies.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. '''
  2. This script is a helper on the dependencies management of the project.
  3. It can be used:
  4. - To check that the same version of a package is set across files.
  5. - To generate a Pipfile and requirements files with the latest version installables.
  6. - To display a summary of the dependencies to update.
  7. '''
  8. import glob
  9. import itertools
  10. import sys
  11. from dataclasses import dataclass, field
  12. from datetime import datetime
  13. from pathlib import Path
  14. from typing import Dict, List
  15. import tabulate
  16. import toml
  17. @dataclass
  18. class Release:
  19. """
  20. Information about a release of a package.
  21. """
  22. version: str
  23. upload_date: datetime.date
  24. @dataclass
  25. class Package:
  26. """
  27. Information about a package.
  28. """
  29. # Package name
  30. name: str
  31. # Min version of the package set as requirements.
  32. min_version: str
  33. # Max version of the package set as requirements.
  34. max_version: str
  35. # Setup installation markers of the package.
  36. # ex: ;python_version>="3.6"
  37. installation_markers: str
  38. # Taipy dependencies are ignored.
  39. is_taipy: bool
  40. # Optional dependencies
  41. extras_dependencies: List[str]
  42. # Files where the package is set as requirement.
  43. files: List[str]
  44. # List of releases of the package.
  45. releases: List[Release] = field(default_factory=list)
  46. # Min release of the package.
  47. # Also present in the releases list.
  48. min_release: Release = None
  49. # Max release of the package.
  50. # Also present in the releases list.
  51. max_release: Release = None
  52. # Latest version available on PyPI.
  53. latest_release: Release = None
  54. def __eq__(self, other):
  55. return self.name == other.name
  56. def __hash__(self):
  57. return hash(self.name)
  58. def load_releases(self):
  59. """
  60. Retrieve all releases of the package from PyPI.
  61. """
  62. import requests # pylint: disable=import-outside-toplevel
  63. releases = requests.get(
  64. f"https://pypi.org/pypi/{self.name}/json",
  65. timeout=5
  66. ).json().get('releases', {})
  67. for version, info in releases.items():
  68. # Ignore old releases without upload time.
  69. if not info:
  70. continue
  71. # Ignore pre and post releases.
  72. if any(str.isalpha(c) for c in version):
  73. continue
  74. date = datetime.strptime(info[0]['upload_time'], "%Y-%m-%dT%H:%M:%S").date()
  75. release = Release(version, date)
  76. self.releases.append(release)
  77. if self.min_version == version:
  78. self.min_release = release
  79. # Min and max version can be the same.
  80. if self.max_version == version:
  81. self.max_release = release
  82. self.releases.sort(key=lambda x: x.upload_date, reverse=True)
  83. self.latest_release = self.releases[0]
  84. def as_requirements_line(self, with_version: bool = True) -> str:
  85. """
  86. Return the package as a requirements line.
  87. """
  88. if self.is_taipy:
  89. return self.name
  90. name = self.name
  91. if self.extras_dependencies:
  92. name += f'[{",".join(self.extras_dependencies)}]'
  93. if with_version:
  94. if self.installation_markers:
  95. return f'{name}>={self.min_version},<={self.max_version};{self.installation_markers}'
  96. return f'{name}>={self.min_version},<={self.max_version}'
  97. if self.installation_markers:
  98. return f'{name};{self.installation_markers}'
  99. return name
  100. def as_pipfile_line(self) -> str:
  101. """
  102. Return the package as a pipfile line.
  103. If min_version is True, the min version is used.
  104. """
  105. line = f'"{self.name}" = {{version="=={self.max_version}"'
  106. if self.installation_markers:
  107. line += f', markers="{self.installation_markers}"'
  108. if self.extras_dependencies:
  109. dep = ','.join(f'"{p}"' for p in self.extras_dependencies)
  110. line += f', extras=[{dep}]'
  111. line += '}'
  112. return line
  113. @classmethod
  114. def check_format(cls, package: str):
  115. """
  116. Check if a package definition is correctly formatted.
  117. """
  118. if '>=' not in package or '<' not in package:
  119. # Only Taipy dependencies can be without version.
  120. if 'taipy' not in package:
  121. raise Exception(f"Invalid package: {package}")
  122. @classmethod
  123. def from_requirements(cls, package: str, filename: str):
  124. """
  125. Create a package from a requirements line.
  126. ex: "pandas>=1.0.0,<2.0.0;python_version<'3.9'"
  127. """
  128. try:
  129. # Lower the name to avoid case issues.
  130. name = extract_name(package).lower()
  131. is_taipy = 'taipy' in name
  132. return cls(
  133. name,
  134. extract_min_version(package) if not is_taipy else '',
  135. extract_max_version(package) if not is_taipy else '',
  136. extract_installation_markers(package) if not is_taipy else '',
  137. is_taipy,
  138. extract_extras_dependencies(package),
  139. [filename]
  140. )
  141. except Exception as e:
  142. print(f"Error while parsing package {package}: {e}") # noqa: T201
  143. raise
  144. def extract_installation_markers(package: str) -> str:
  145. """
  146. Extract the installation markers of a package from a requirements line.
  147. ex: "pandas>=1.0.0,<2.0.0;python_version<'3.9'" -> "python_version<'3.9'"
  148. """
  149. if ';' not in package:
  150. return ''
  151. return package.split(';')[1]
  152. def extract_min_version(package: str) -> str:
  153. """
  154. Extract the min version of a package from a requirements line.
  155. ex: "pandas>=1.0.0,<2.0.0;python_version<'3.9'" -> "1.0.0"
  156. """
  157. # The max version is the defined version if it is a fixed version.
  158. if '==' in package:
  159. version = package.split('==')[1]
  160. if ';' in version:
  161. # Remove installation markers.
  162. version = version.split(';')[0]
  163. return version
  164. return package.split('>=')[1].split(',')[0]
  165. def extract_max_version(package: str) -> str:
  166. """
  167. Extract the max version of a package from a requirements line.
  168. Ex:
  169. - pandas==1.0.0 -> 1.0.0
  170. - pandas>=1.0.0,<=2.0.0 -> 2.0.0
  171. - pandas==1.0.0;python_version<'3.9' -> 1.0.0
  172. - pandas>=1.0.0,<2.0.0;python_version<'3.9' -> 2.0.0
  173. """
  174. # The max version is the defined version if it is a fixed version.
  175. if '==' in package:
  176. version = package.split('==')[1]
  177. if ';' in version:
  178. # Remove installation markers.
  179. version = version.split(';')[0]
  180. return version
  181. version = None
  182. if ',<=' in package:
  183. version = package.split(',<=')[1]
  184. else:
  185. version = package.split(',<')[1]
  186. if ';' in version:
  187. # Remove installation markers.
  188. version = version.split(';')[0]
  189. return version
  190. def extract_name(package: str) -> str:
  191. """
  192. Extract the name of a package from a requirements line.
  193. Ex:
  194. - pandas==1.0.0 -> pandas
  195. - pandas>=1.0.0,<2.0.0 -> pandas
  196. - pandas==1.0.0;python_version<'3.9' -> pandas
  197. - pandas>=1.0.0,<2.0.0;python_version<'3.9' -> pandas
  198. """
  199. if '==' in package:
  200. return package.split('==')[0]
  201. name = package.split('>=')[0]
  202. # Remove optional dependencies.
  203. # Ex: "pandas[sql]" -> "pandas"
  204. if '[' in name:
  205. name = name.split('[')[0]
  206. return name
  207. def extract_extras_dependencies(package: str) -> List[str]:
  208. """
  209. Extract the extras dependencies of a package from a requirements line.
  210. Ex:
  211. - pymongo[srv]>=4.2.0,<=4.6.1 -> ["srv"]
  212. """
  213. if '[' not in package:
  214. return []
  215. return package.split('[')[1].split(']')[0].split(',')
  216. def load_dependencies(requirements_filenames: List[str], enforce_format: bool) -> Dict[str, Package]:
  217. """
  218. Load and concat dependencies from requirements files.
  219. """
  220. # Extracted dependencies from requirements files.
  221. dependencies = {}
  222. for filename in requirements_filenames:
  223. file_dependencies = Path(filename).read_text("UTF-8").split("\n")
  224. for package_requirements in file_dependencies:
  225. # Ignore empty lines.
  226. if not package_requirements:
  227. continue
  228. # Ensure the package is correctly formatted with born min and max.
  229. if enforce_format:
  230. Package.check_format(package_requirements)
  231. package = Package.from_requirements(package_requirements, filename)
  232. # dependencies may be present multiple times in different files.
  233. # In that case, do not load the releases again but ensure versions are the same.
  234. if package.name in dependencies:
  235. existing_package = dependencies[package.name]
  236. if not existing_package.min_version == package.min_version or \
  237. not existing_package.max_version == package.max_version:
  238. raise Exception(
  239. f"Inconsistent version of '{package.name}' between '{filename}' and {','.join(package.files)}."
  240. )
  241. # Add the file as dependency of the package.
  242. existing_package.files.append(filename)
  243. # Stop processing, package is already extracted.
  244. continue
  245. dependencies[package.name] = package
  246. return dependencies
  247. def display_dependencies_versions(dependencies: Dict[str, Package]):
  248. """
  249. Display dependencies information.
  250. """
  251. to_print = []
  252. for package_name, package in dependencies.items():
  253. if package.is_taipy:
  254. continue
  255. # Load the latest releases of the package.
  256. package.load_releases()
  257. to_print.append((
  258. package_name,
  259. f'{package.min_version} ({package.min_release.upload_date if package.min_release else "N.A."})',
  260. f'{package.max_version} ({package.max_release.upload_date if package.max_release else "N.C."})',
  261. f'{package.releases[0].version} ({package.releases[0].upload_date})',
  262. len(list(itertools.takewhile(lambda x: x.version != package.max_version, package.releases))), # noqa: B023
  263. ))
  264. to_print.sort(key=lambda x: x[0])
  265. h = ['name', 'version-min', 'version-max', 'current-version', 'nb-releases-behind']
  266. print(tabulate.tabulate(to_print, headers=h, tablefmt='pretty')) # noqa: T201
  267. def update_dependencies(
  268. # Dependencies installed in the environment.
  269. dependencies_installed: Dict[str, Package],
  270. # Dependencies set in requirements files.
  271. dependencies_set: Dict[str, Package],
  272. # Requirements files to update.
  273. requirements_filenames: List[str]
  274. ):
  275. """
  276. Display and updates dependencies.
  277. """
  278. to_print = []
  279. for name, ds in dependencies_set.items():
  280. if ds.is_taipy:
  281. continue
  282. # Find the package in use.
  283. di = dependencies_installed.get(name)
  284. # Some package as 'gitignore-parser' becomes 'gitignore_parser' during the installation.
  285. if not di:
  286. di = dependencies_installed.get(name.replace('-', '_'))
  287. if di:
  288. if di.max_version != ds.max_version:
  289. to_print.append((
  290. name,
  291. di.max_version,
  292. ','.join(f.split('/')[0] for f in ds.files)
  293. ))
  294. # Save the new dependency version.
  295. ds.max_version = di.max_version
  296. # Print the dependencies to update.
  297. to_print.sort(key=lambda x: x[0])
  298. print(tabulate.tabulate(to_print, headers=['name', 'version', 'files'], tablefmt='pretty')) # noqa: T201
  299. # Update requirements files.
  300. for fd in requirements_filenames:
  301. requirements = '\n'.join(
  302. d.as_requirements_line()
  303. for d in sorted(dependencies_set.values(), key=lambda d: d.name)
  304. if fd in d.files
  305. )
  306. # Add a new line at the end of the file.
  307. requirements += '\n'
  308. Path(fd).write_text(requirements, 'UTF-8')
  309. def generate_raw_requirements_txt(dependencies: Dict[str, Package]):
  310. """
  311. Print the dependencies as requirements lines without version.
  312. """
  313. for package in dependencies.values():
  314. if not package.is_taipy:
  315. print(package.as_requirements_line(with_version=False)) # noqa: T201
  316. def update_pipfile(pipfile: str, dependencies_version: Dict[str, Package]):
  317. """
  318. Update in place dependencies version of a Pipfile.
  319. Warning:
  320. Dependencies are loaded from requirements files without extras or markers.
  321. The Pipfile contains extras and markers information.
  322. """
  323. dependencies_str = ""
  324. pipfile_obj = toml.load(pipfile)
  325. packages = pipfile_obj.pop('packages')
  326. for name, dep in packages.items():
  327. # Find the package in use.
  328. rp = dependencies_version.get(name)
  329. # Some package as 'gitignore-parser' becomes 'gitignore_parser' during the installation.
  330. if not rp:
  331. rp = dependencies_version.get(name.replace('-', '_'))
  332. if rp:
  333. # Change for the real name of the package.
  334. rp.name = name
  335. if not rp:
  336. # Package not found. Can be due to python version.
  337. # Ex: backports.zoneinfo
  338. if isinstance(dep, dict):
  339. new_dep = ''
  340. # Format as a Pipfile line.
  341. new_dep = f'version="{dep["version"]}"'
  342. if dep.get('markers'):
  343. new_dep += f', markers="{dep["markers"]}"'
  344. if dep.get('extras'):
  345. new_dep += f', extras={dep["extras"]}'
  346. dep = f"{{{new_dep}}}"
  347. dependencies_str += f'"{name}" = {dep}\n'
  348. else:
  349. if isinstance(dep, dict):
  350. # Requirements does not have installation markers and extras.
  351. rp.installation_markers = dep.get('markers', '')
  352. rp.extras_dependencies = [dep.get('extras')[0]] if dep.get('extras') else []
  353. dependencies_str += f'{rp.as_pipfile_line()}\n'
  354. toml_str = toml.dumps(pipfile_obj)
  355. Path(pipfile).write_text(f'{toml_str}\n\n[packages]\n{dependencies_str}', 'UTF-8')
  356. if __name__ == '__main__':
  357. if sys.argv[1] == 'ensure-same-version':
  358. # Load dependencies from requirements files.
  359. # Verify that the same version is set for the same package across files.
  360. _requirements_filenames = glob.glob('taipy*/*requirements.txt')
  361. _dependencies = load_dependencies(_requirements_filenames, True)
  362. display_dependencies_versions(_dependencies)
  363. if sys.argv[1] == 'dependencies-summary':
  364. # Load and compare dependencies from requirements files.
  365. # The first file is the reference to the other.
  366. # Display the differences including new version available on Pypi.
  367. _requirements_filenames = glob.glob('taipy*/*requirements.txt')
  368. _dependencies_installed = load_dependencies([sys.argv[2]], False)
  369. _dependencies_set = load_dependencies(_requirements_filenames, False)
  370. update_dependencies(_dependencies_installed, _dependencies_set, _requirements_filenames)
  371. if sys.argv[1] == 'generate-raw-requirements':
  372. # Load dependencies from requirements files.
  373. # Print the dependencies as requirements lines without born.
  374. _requirements_filenames = glob.glob('taipy*/*requirements.txt')
  375. _dependencies = load_dependencies(_requirements_filenames, False)
  376. generate_raw_requirements_txt(_dependencies)
  377. if sys.argv[1] == 'generate-pipfile':
  378. # Generate a new Pipfile from requirements files using dependencies versions
  379. # set in the requirement file.
  380. _pipfile_path = sys.argv[2]
  381. _dependencies_version = load_dependencies([sys.argv[3]], False)
  382. update_pipfile(_pipfile_path, _dependencies_version)