1
0

check-dependencies.py 16 KB

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