common.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. # Copyright 2021-2025 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. # Common artifacts used by the other scripts located in this directory.
  13. # --------------------------------------------------------------------------------------------------
  14. import argparse
  15. import json
  16. import os
  17. import re
  18. import subprocess
  19. import typing as t
  20. from dataclasses import asdict, dataclass
  21. from datetime import datetime
  22. from pathlib import Path
  23. from functools import total_ordering
  24. import requests
  25. # --------------------------------------------------------------------------------------------------
  26. @dataclass(frozen=True)
  27. class Version:
  28. """Helps manipulate version numbers."""
  29. major: int
  30. minor: int
  31. patch: int = 0
  32. ext: t.Optional[str] = None
  33. # Matching level
  34. MAJOR: t.ClassVar[int] = 1
  35. MINOR: t.ClassVar[int] = 2
  36. PATCH: t.ClassVar[int] = 3
  37. # Unknown version constant
  38. UNKNOWN: t.ClassVar["Version"]
  39. @property
  40. def name(self) -> str:
  41. """Returns a string representation of this Version without the extension part."""
  42. return f"{self.major}.{self.minor}.{self.patch}"
  43. @property
  44. def full_name(self) -> str:
  45. """Returns a full string representation of this Version."""
  46. return f"{self.name}.{self.ext}" if self.ext else self.name
  47. def __str__(self) -> str:
  48. """Returns a string representation of this version."""
  49. return self.full_name
  50. def __repr__(self) -> str:
  51. """Returns a full string representation of this version."""
  52. ext = f".{self.ext}" if self.ext else ""
  53. return f"Version({self.major}.{self.minor}.{self.patch}{ext})"
  54. @classmethod
  55. def from_string(cls, version: str):
  56. """Creates a Version from a string.
  57. Parameters:
  58. version: a version name as a string.<br/>
  59. The format should be "<major>.<minor>[.<patch>[.<extension>]] where
  60. - <major> must be a number, indicating the major number of the version
  61. - <minor> must be a number, indicating the minor number of the version
  62. - <patch> must be a number, indicating the patch level of the version. Optional.
  63. - <extension> must be a string. It is common practice that <extension> ends with a
  64. number, but it is not required. Optional.
  65. Returns:
  66. A new Version object with the appropriate values that were parsed.
  67. """
  68. match = re.fullmatch(r"(\d+)\.(\d+)(?:\.(\d+))?(?:\.([^\s]+))?", version)
  69. if match:
  70. major = int(match[1])
  71. minor = int(match[2])
  72. patch = int(match[3]) if match[3] else 0
  73. ext = match[4]
  74. return cls(major=major, minor=minor, patch=patch, ext=ext)
  75. else:
  76. raise ValueError(f"String not in expected format: {version}")
  77. def to_dict(self) -> dict[str, str]:
  78. """Returns this Version as a dictionary."""
  79. return {k: v for k, v in asdict(self).items() if v is not None}
  80. @staticmethod
  81. def check_argument(value: str) -> "Version":
  82. """Checks version parameter in an argparse context."""
  83. try:
  84. version = Version.from_string(value)
  85. except Exception as e:
  86. raise argparse.ArgumentTypeError(f"'{value}' is not a valid version number.") from e
  87. return version
  88. def validate_extension(self, ext="dev"):
  89. """Returns True if the extension part of this Version is the one queried."""
  90. return self.split_ext()[0] == ext
  91. def split_ext(self) -> t.Tuple[str, int]:
  92. """Splits extension into the (identifier, index) tuple
  93. Returns:
  94. ("", -1) if there is no extension.
  95. (extension, -1) if there is no extension index.
  96. (extension, index) if there is an extension index (e.g. "dev3").
  97. """
  98. if not self.ext or (match := re.fullmatch(r"(.*?)(\d+)?", self.ext)) is None:
  99. return ("", -1) # No extension
  100. # Potentially no index
  101. return (match[1], int(match[2]) if match[2] else -1)
  102. def is_compatible(self, version: "Version") -> bool:
  103. """Checks if this version is compatible with another.
  104. Version v1 is defined as being compatible with version v2 if a package built with version v1
  105. can safely depend on another package built with version v2.<br/>
  106. Here are the conditions set when checking whether v1 is compatible with v2:
  107. - If v1 and v2 have different major or minor numbers, they are not compatible.
  108. - If v1 has no extension, it is compatible only with v2 that have no extension.
  109. - If v1 has an extension, it is compatible with any v2 that has the same extension, no
  110. matter the extension index.
  111. I.e.:
  112. package-1.[m].[t] is NOT compatible with any sub-package-[M].* where M != 1
  113. package-1.2.[t] is NOT compatible with any sub-package-1.[m].* where m != 2
  114. package-1.2.[t] is compatible with all sub-package-1.2.*
  115. package-1.2.[t].ext[X] is compatible with all sub-package-1.2.*.ext*
  116. package-1.2.3 is NOT compatible with any sub-package-1.2.*.*
  117. package-1.2.3.extA is NOT compatible with any sub-package-1.2.*.extB if extA != extB,
  118. independently of a potential extension index.
  119. Parameters:
  120. version: the version to check compatibility against.
  121. Returns:
  122. True is this Version is compatible with *version* and False if it is not.
  123. """
  124. if self.major != version.major or self.minor != version.minor:
  125. return False
  126. if self.patch > version.patch:
  127. return True
  128. # No extensions on either → Compatible
  129. if not self.ext and not version.ext:
  130. return True
  131. # self has extension, version doesn't → Compatible
  132. if self.ext and not version.ext:
  133. return True
  134. # Version has extension, self doesn't → Not compatible
  135. if not self.ext and version.ext:
  136. return False
  137. # Both have extensions → check identifiers. Dissimilar identifiers → Not compatible
  138. self_prefix, _ = self.split_ext()
  139. other_prefix, _ = version.split_ext()
  140. if self_prefix != other_prefix:
  141. return False
  142. # Same identifiers → Compatible
  143. return True
  144. def matches(self, version: "Version", level: int = PATCH) -> bool:
  145. """Checks whether this version matches another, up to some level.
  146. Arguments:
  147. version: The version to check against.
  148. level: The level of precision for the match:
  149. - Version.MAJOR: compare only the major version;
  150. - Version.MINOR: compare major and minor versions;
  151. - Version.PATCH: compare major, minor, and patch versions.
  152. Returns:
  153. True if the versions match up to the given level, False otherwise.
  154. """
  155. if self.major != version.major:
  156. return False
  157. if level >= self.MINOR and self.minor != version.minor:
  158. return False
  159. if level >= self.PATCH and self.patch != version.patch:
  160. return False
  161. return True
  162. def __lt__(self, other: "Version") -> bool:
  163. if not isinstance(other, Version):
  164. return NotImplemented
  165. # Compare major, minor, patch
  166. self_tuple = (self.major, self.minor, self.patch)
  167. other_tuple = (other.major, other.minor, other.patch)
  168. if self_tuple != other_tuple:
  169. return self_tuple < other_tuple
  170. # Same version number, now compare extensions
  171. return self._ext_sort_key() < other._ext_sort_key()
  172. def _ext_sort_key(self) -> t.Tuple[int, str, int]:
  173. """
  174. Defines ordering for extensions.
  175. Final versions (None) are considered greater than prereleases.
  176. Example sort order:
  177. 1.0.0.dev1 < 1.0.0.rc1 < 1.0.0 < 1.0.1
  178. """
  179. if self.ext is None:
  180. return (2, "", 0) # Final release — highest priority
  181. # Parse extension like "dev1" into prefix + number
  182. match = re.match(r"([a-zA-Z]+)(\d*)", self.ext)
  183. if match:
  184. label, num = match.groups()
  185. num_val = int(num) if num else 0
  186. return (1, label, num_val) # Pre-release
  187. else:
  188. return (0, self.ext, 0) # Unknown extension format — lowest priority
  189. Version.UNKNOWN = Version(0, 0)
  190. # --------------------------------------------------------------------------------------------------
  191. class Package:
  192. """Information on any Taipy package and sub-package."""
  193. # Base names of the sub packages taipy-*
  194. # They also are the names of the directory where their code belongs, under the 'taipy' directory,
  195. # in the root of the Taipy repository.
  196. # Order is important: package that are dependent of others must appear first.
  197. NAMES = ["common", "core", "gui", "rest", "templates"]
  198. _packages = {}
  199. def __new__(cls, name: str) -> "Package":
  200. if name.startswith("taipy-"):
  201. name = name[6:]
  202. if name in cls._packages:
  203. return cls._packages[name]
  204. package = super().__new__(cls)
  205. cls._packages[name] = package
  206. return package
  207. def __init__(self, package: str) -> None:
  208. self._name = package
  209. if package == "taipy":
  210. self._short = package
  211. else:
  212. if package.startswith("taipy-"):
  213. self._short = package[6:]
  214. else:
  215. self._name = f"taipy-{package}"
  216. self._short = package
  217. if self._short not in Package.NAMES:
  218. raise ValueError(f"Invalid package name '{package}'.")
  219. @classmethod
  220. def names(cls, add_taipy=False) -> list[str]:
  221. return cls.NAMES + (["taipy"] if add_taipy else [])
  222. @staticmethod
  223. def check_argument(value: str) -> str:
  224. """Checks package parameter in an argparse context."""
  225. n_value = value.lower()
  226. if n_value in Package.names(True) or value == "all":
  227. return n_value
  228. raise argparse.ArgumentTypeError(f"'{value}' is not a valid Taipy package name.")
  229. @property
  230. def name(self) -> str:
  231. """The full package name."""
  232. return self._name
  233. @property
  234. def short_name(self) -> str:
  235. """The short package name."""
  236. return self._short
  237. @property
  238. def package_dir(self) -> str:
  239. return "taipy" if self._name == "taipy" else os.path.join("taipy", self._short)
  240. def load_version(self) -> Version:
  241. """
  242. Returns the Version defined in this package's version.json content.
  243. """
  244. with open(Path(self.package_dir) / "version.json") as version_file:
  245. data = json.load(version_file)
  246. return Version(**data)
  247. def save_version(self, version: Version) -> None:
  248. """
  249. Saves the Version to this package's version.json file.
  250. """
  251. with open(os.path.join(Path(self.package_dir), "version.json"), "w") as version_file:
  252. json.dump(version.to_dict(), version_file)
  253. def __str__(self) -> str:
  254. """Returns a string representation of this package."""
  255. return self.name
  256. def __repr__(self) -> str:
  257. """Returns a full string representation of this package."""
  258. return f"Package({self.name})"
  259. def __eq__(self, other):
  260. return isinstance(other, Package) and (self._short, self._short) == (other._short, other._short)
  261. def __hash__(self):
  262. return hash(self._short)
  263. # --------------------------------------------------------------------------------------------------
  264. def run_command(*args) -> str:
  265. return subprocess.run(args, stdout=subprocess.PIPE, text=True, check=True).stdout.strip()
  266. # --------------------------------------------------------------------------------------------------
  267. class Git:
  268. @staticmethod
  269. def get_current_branch() -> str:
  270. return run_command("git", "branch", "--show-current")
  271. @staticmethod
  272. def get_github_path() -> t.Optional[str]:
  273. """Retrieve current Git path (<owner>/<repo>)."""
  274. branch_name = Git.get_current_branch()
  275. remote_name = run_command("git", "config", f"branch.{branch_name}.remote")
  276. url = run_command("git", "remote", "get-url", remote_name)
  277. if match := re.fullmatch(r"(?:git@github\.com:|https://github\.com/)(.*)\.git", url):
  278. return match[1]
  279. print("ERROR - Could not retrieve GibHub branch path") # noqa: T201
  280. return None
  281. # --------------------------------------------------------------------------------------------------
  282. class Release(t.TypedDict):
  283. version: Version
  284. id: str
  285. tag: str
  286. published_at: str
  287. def fetch_github_releases(gh_path: t.Optional[str] = None) -> dict[Package, list[Release]]:
  288. # Retrieve all available releases (potentially paginating results) for all packages.
  289. # Returns a dictionary of package_short_name/list-of-releases pairs.
  290. # A 'release' is a dictionary where "version" if the package version, "id" is the release id and
  291. # "tag" is the release tag name.
  292. headers = {"Accept": "application/vnd.github+json"}
  293. all_releases: dict[str, list[Release]] = {}
  294. if gh_path is None:
  295. gh_path = Git.get_github_path()
  296. if gh_path is None:
  297. raise ValueError("Couldn't figure out GitHub branch path.")
  298. url = f"https://api.github.com/repos/{gh_path}/releases"
  299. page = 1
  300. # Read all release versions and store them in a package_name - list[Version] dictionary
  301. while url:
  302. response = requests.get(url, params={"per_page": 50, "page": page}, headers=headers)
  303. response.raise_for_status() # Raise error for bad responses
  304. for release in response.json():
  305. release_id = release["id"]
  306. tag = release["tag_name"]
  307. published_at = release["published_at"]
  308. pkg_ver, pkg = tag.split("-") if "-" in tag else (tag, "taipy")
  309. # Drop legacy packages (config...)
  310. if pkg != "taipy" and pkg not in Package.NAMES:
  311. continue
  312. # Exception for legacy version: v1.0.0 -> 1.0.0
  313. if pkg_ver == "v1.0.0":
  314. pkg_ver = pkg_ver[1:]
  315. version = Version.from_string(pkg_ver)
  316. new_release: Release = {"version": version, "id": release_id, "tag": tag, "published_at": published_at}
  317. if releases := all_releases.get(pkg):
  318. releases.append(new_release)
  319. else:
  320. all_releases[pkg] = [new_release]
  321. # Check for pagination in the `Link` header
  322. link_header = response.headers.get("Link", "")
  323. if 'rel="next"' in link_header:
  324. url = link_header.split(";")[0].strip("<>") # Extract next page URL
  325. page += 1
  326. else:
  327. url = None # No more pages
  328. # Sort all releases for all packages by publishing date (most recent first)
  329. for p in all_releases.keys():
  330. all_releases[p].sort(
  331. key=lambda r: datetime.fromisoformat(r["published_at"].replace("Z", "+00:00")), reverse=True
  332. )
  333. # Build and return the dictionary using Package instances
  334. return {Package(p): v for p, v in all_releases.items()}
  335. # --------------------------------------------------------------------------------------------------
  336. def fetch_latest_github_taipy_releases(
  337. all_releases: t.Optional[dict[Package, list[Release]]] = None, gh_path: t.Optional[str] = None
  338. ) -> Version:
  339. # Retrieve all available releases if necessary
  340. if all_releases is None:
  341. all_releases = fetch_github_releases(gh_path)
  342. # Find the latest 'taipy' version that has no extension
  343. latest_taipy_version = Version.UNKNOWN
  344. releases = all_releases.get(Package("taipy"))
  345. if releases := all_releases.get(Package("taipy")):
  346. # Retrieve all non-dev releases
  347. versions = [release["version"] for release in releases if release["version"].ext is None]
  348. # Find the latest
  349. if versions:
  350. latest_taipy_version = max(versions)
  351. return latest_taipy_version