common.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  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. # Common artifacts used by the other scripts located in this directory.
  13. # --------------------------------------------------------------------------------------------------
  14. import os
  15. import re
  16. import subprocess
  17. import typing as t
  18. from dataclasses import asdict, dataclass
  19. import requests
  20. # These are the base name of the sub packages taipy-*
  21. # They also are the names of the directory where their code belongs, under the 'taipy' directory
  22. # in the root of the Taipy repository.
  23. PACKAGES = ["common", "core", "gui", "rest", "templates"]
  24. # --------------------------------------------------------------------------------------------------
  25. @dataclass(order=True)
  26. class Version:
  27. """Helps manipulate version numbers."""
  28. major: int
  29. minor: int
  30. patch: int = 0
  31. ext: t.Optional[str] = None
  32. # Unknown version constant
  33. UNKNOWN: t.ClassVar["Version"]
  34. @property
  35. def name(self) -> str:
  36. """Returns a string representation of this Version without the extension part."""
  37. return f"{self.major}.{self.minor}.{self.patch}"
  38. @property
  39. def full_name(self) -> str:
  40. """Returns a full string representation of this Version."""
  41. return f"{self.name}.{self.ext}" if self.ext else self.name
  42. def __str__(self) -> str:
  43. """Returns a string representation of this version."""
  44. return self.full_name
  45. def __repr__(self) -> str:
  46. """Returns a full string representation of this version."""
  47. ext = f".{self.ext}" if self.ext else ""
  48. return f"Version({self.major}.{self.minor}.{self.patch}{ext})"
  49. @classmethod
  50. def from_string(cls, version: str):
  51. """Creates a Version from a string.
  52. Parameters:
  53. version: a version name as a string.<br/>
  54. The format should be "<major>.<minor>[.<patch>[.<extension>]] where
  55. - <major> must be a number, indicating the major number of the version
  56. - <minor> must be a number, indicating the minor number of the version
  57. - <patch> must be a number, indicating the patch level of the version. Optional.
  58. - <extension> must be a string. It is common practice that <extension> ends with a
  59. number, but it is not required. Optional.
  60. Returns:
  61. A new Version object with the appropriate values that were parsed.
  62. """
  63. match = re.fullmatch(r"(\d+)\.(\d+)(?:\.(\d+))?(?:\.([^\s]+))?", version)
  64. if match:
  65. major = int(match[1])
  66. minor = int(match[2])
  67. patch = int(match[3]) if match[3] else 0
  68. ext = match[4]
  69. return cls(major=major, minor=minor, patch=patch, ext=ext)
  70. else:
  71. raise ValueError(f"String not in expected format: {version}")
  72. def to_dict(self) -> dict[str, str]:
  73. """Returns this Version as a dictionary."""
  74. return {k: v for k, v in asdict(self).items() if v is not None}
  75. def bump_ext_version(self) -> "Version":
  76. """Returns a new Version object where the extension part version was incremented.
  77. If this Version has no extension part, this method returns *self*.
  78. """
  79. if not self.ext or (m := re.search(r"([0-9]+)$", self.ext)) is None:
  80. return self
  81. ext_ver = int(m[1]) + 1
  82. return Version(self.major, self.minor, self.patch, f"{self.ext[: m.start(1)]}{ext_ver}")
  83. def validate_extension(self, ext="dev"):
  84. """Returns True if the extension part of this Version is the one queried."""
  85. return self._split_ext()[0] == ext
  86. def _split_ext(self) -> t.Tuple[str, int]:
  87. """Splits extension into the (identifier, index) tuple
  88. Returns:
  89. ("", -1) if there is no extension.
  90. (extension, -1) if there is no extension index.
  91. (extension, index) if there is an extension index (e.g. "dev3").
  92. """
  93. if not self.ext or (match := re.fullmatch(r"(.*?)(\d+)?", self.ext)) is None:
  94. return ("", -1) # No extension
  95. # Potentially no index
  96. return (match[1], int(match[2]) if match[2] else -1)
  97. def is_compatible(self, version: "Version") -> bool:
  98. """Checks if this version is compatible with another.
  99. Version v1 is defined as being compatible with version v2 if a package built with version v1
  100. can safely depend on another package built with version v2.<br/>
  101. Here are the conditions set when checking whether v1 is compatible with v2:
  102. - If v1 and v2 have different major or minor numbers, they are not compatible.
  103. - If v1 has no extension, it is compatible only with v2 that have no extension.
  104. - If v1 has an extension, it is compatible with any v2 that has the same extension, no
  105. matter the extension index.
  106. I.e.:
  107. package-1.[m].[t] is NOT compatible with any sub-package-[M].* where M != 1
  108. package-1.2.[t] is NOT compatible with any sub-package-1.[m].* where m != 2
  109. package-1.2.[t] is compatible with all sub-package-1.2.*
  110. package-1.2.[t].ext[X] is compatible with all sub-package-1.2.*.ext*
  111. package-1.2.3 is NOT compatible with any sub-package-1.2.*.*
  112. package-1.2.3.extA is NOT compatible with any sub-package-1.2.*.extB if extA != extB,
  113. independently of a potential extension index.
  114. Parameters:
  115. version: the version to check compatibility against.
  116. Returns:
  117. True is this Version is compatible with *version* and False if it is not.
  118. """
  119. if self.major != version.major or self.minor != version.minor:
  120. return False
  121. if self.patch > version.patch:
  122. return True
  123. # No extensions on either → Compatible
  124. if not self.ext and not version.ext:
  125. return True
  126. # self has extension, version doesn't → Compatible
  127. if self.ext and not version.ext:
  128. return True
  129. # Version has extension, self doesn't → Not compatible
  130. if not self.ext and version.ext:
  131. return False
  132. # Both have extensions → check identifiers. Dissimilar identifiers → Not compatible
  133. self_prefix, _ = self._split_ext()
  134. other_prefix, _ = version._split_ext()
  135. if self_prefix != other_prefix:
  136. return False
  137. # Same identifiers → Compatible
  138. return True
  139. Version.UNKNOWN = Version(0, 0)
  140. # --------------------------------------------------------------------------------------------------
  141. class Package:
  142. """Information on any Taipy package and sub-package."""
  143. def __init__(self, package: str) -> None:
  144. self._name = package
  145. if package == "taipy":
  146. self._short = package
  147. else:
  148. if package.startswith("taipy-"):
  149. self._short = package[6:]
  150. else:
  151. self._name = f"taipy-{package}"
  152. self._short = package
  153. if self._short not in PACKAGES:
  154. raise ValueError(f"Invalid package name {package}.")
  155. @property
  156. def name(self) -> str:
  157. """The full package name."""
  158. return self._name
  159. @property
  160. def short_name(self) -> str:
  161. """The short package name."""
  162. return self._short
  163. @property
  164. def package_dir(self) -> str:
  165. return "taipy" if self._name == "taipy" else os.path.join("taipy", self._short)
  166. def __str__(self) -> str:
  167. """Returns a string representation of this package."""
  168. return self.name
  169. def __repr__(self) -> str:
  170. """Returns a full string representation of this package."""
  171. return f"Package({self.name})"
  172. def __eq__(self, other):
  173. return isinstance(other, Package) and (self._short, self._short) == (other._short, other._short)
  174. def __hash__(self):
  175. return hash(self._short)
  176. # --------------------------------------------------------------------------------------------------
  177. def retrieve_github_path() -> t.Optional[str]:
  178. # Retrieve current Git branch remote URL
  179. def run(*args) -> str:
  180. return subprocess.run(args, stdout=subprocess.PIPE, text=True, check=True).stdout.strip()
  181. branch_name = run("git", "branch", "--show-current")
  182. remote_name = run("git", "config", f"branch.{branch_name}.remote")
  183. url = run("git", "remote", "get-url", remote_name)
  184. if match := re.fullmatch(r"git@github.com:(.*)\.git", url):
  185. return match[1]
  186. if match := re.fullmatch(r"https://github.com/(.*)$", url):
  187. return match[1]
  188. print("ERROR - Could not retrieve GibHub branch path") # noqa: T201
  189. return None
  190. # --------------------------------------------------------------------------------------------------
  191. def fetch_github_releases(gh_path: t.Optional[str] = None) -> dict[Package, list[Version]]:
  192. # Retrieve all available releases (potentially paginating results) for all packages.
  193. # Returns a dictionary of package_short_name-Value pairs.
  194. # Note for reviewers: using a Package as the dictionary is is cumbersome in the rest of the
  195. # code.
  196. all_releases: dict[str, list[Version]] = {}
  197. if gh_path is None:
  198. gh_path = retrieve_github_path()
  199. if gh_path is None:
  200. raise ValueError("Couldn't figure out GitHub branch path.")
  201. url = f"https://api.github.com/repos/{gh_path}/releases"
  202. page = 1
  203. # Read all release versions and store them in a package_name - list[Version] dictionary
  204. while url:
  205. response = requests.get(url, params={"per_page": 50, "page": page})
  206. response.raise_for_status() # Raise error for bad responses
  207. for release in response.json():
  208. tag_name = release["tag_name"]
  209. pkg_ver, pkg = tag_name.split("-") if "-" in tag_name else (tag_name, "taipy")
  210. # Drop legacy packages (config...)
  211. if pkg != "taipy" and pkg not in PACKAGES:
  212. continue
  213. # Exception for legacy version: v1.0.0 -> 1.0.0
  214. if pkg_ver == "v1.0.0":
  215. pkg_ver = pkg_ver[1:]
  216. version = Version.from_string(pkg_ver)
  217. if versions := all_releases.get(pkg):
  218. versions.append(version)
  219. else:
  220. all_releases[pkg] = [version]
  221. # Check for pagination in the `Link` header
  222. link_header = response.headers.get("Link", "")
  223. if 'rel="next"' in link_header:
  224. url = link_header.split(";")[0].strip("<>") # Extract next page URL
  225. page += 1
  226. else:
  227. url = None # No more pages
  228. # Build and return the dictionary using Package instances
  229. return {Package(p): v for p, v in all_releases.items()}
  230. # --------------------------------------------------------------------------------------------------
  231. def fetch_latest_github_taipy_releases(
  232. all_releases: t.Optional[dict[Package, list[Version]]] = None, gh_path: t.Optional[str] = None
  233. ) -> Version:
  234. # Retrieve all available releases if necessary
  235. if all_releases is None:
  236. all_releases = fetch_github_releases(gh_path)
  237. # Find the latest 'taipy' version that has no extension
  238. latest_taipy_version = Version.UNKNOWN
  239. if versions := all_releases.get(Package("taipy")):
  240. latest_taipy_version = max(filter(lambda v: v.ext is None, versions))
  241. return latest_taipy_version