doc_status.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. #!/usr/bin/env python3
  2. from __future__ import annotations
  3. import fnmatch
  4. import math
  5. import os
  6. import re
  7. import sys
  8. import xml.etree.ElementTree as ET
  9. sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../"))
  10. from misc.utility.color import Ansi, force_stdout_color, is_stdout_color
  11. ################################################################################
  12. # Config #
  13. ################################################################################
  14. flags = {
  15. "c": is_stdout_color(),
  16. "b": False,
  17. "g": False,
  18. "s": False,
  19. "u": False,
  20. "h": False,
  21. "p": False,
  22. "o": True,
  23. "i": False,
  24. "a": True,
  25. "e": False,
  26. }
  27. flag_descriptions = {
  28. "c": "Toggle colors when outputting.",
  29. "b": "Toggle showing only not fully described classes.",
  30. "g": "Toggle showing only completed classes.",
  31. "s": "Toggle showing comments about the status.",
  32. "u": "Toggle URLs to docs.",
  33. "h": "Show help and exit.",
  34. "p": "Toggle showing percentage as well as counts.",
  35. "o": "Toggle overall column.",
  36. "i": "Toggle collapse of class items columns.",
  37. "a": "Toggle showing all items.",
  38. "e": "Toggle hiding empty items.",
  39. }
  40. long_flags = {
  41. "colors": "c",
  42. "use-colors": "c",
  43. "bad": "b",
  44. "only-bad": "b",
  45. "good": "g",
  46. "only-good": "g",
  47. "comments": "s",
  48. "status": "s",
  49. "urls": "u",
  50. "gen-url": "u",
  51. "help": "h",
  52. "percent": "p",
  53. "use-percentages": "p",
  54. "overall": "o",
  55. "use-overall": "o",
  56. "items": "i",
  57. "collapse": "i",
  58. "all": "a",
  59. "empty": "e",
  60. }
  61. table_columns = [
  62. "name",
  63. "brief_description",
  64. "description",
  65. "methods",
  66. "constants",
  67. "members",
  68. "theme_items",
  69. "signals",
  70. "operators",
  71. "constructors",
  72. ]
  73. table_column_names = [
  74. "Name",
  75. "Brief Desc.",
  76. "Desc.",
  77. "Methods",
  78. "Constants",
  79. "Members",
  80. "Theme Items",
  81. "Signals",
  82. "Operators",
  83. "Constructors",
  84. ]
  85. colors = {
  86. "name": [Ansi.CYAN], # cyan
  87. "part_big_problem": [Ansi.RED, Ansi.UNDERLINE], # underline, red
  88. "part_problem": [Ansi.RED], # red
  89. "part_mostly_good": [Ansi.YELLOW], # yellow
  90. "part_good": [Ansi.GREEN], # green
  91. "url": [Ansi.BLUE, Ansi.UNDERLINE], # underline, blue
  92. "section": [Ansi.BOLD, Ansi.UNDERLINE], # bold, underline
  93. "state_off": [Ansi.CYAN], # cyan
  94. "state_on": [Ansi.BOLD, Ansi.MAGENTA], # bold, magenta/plum
  95. "bold": [Ansi.BOLD], # bold
  96. }
  97. overall_progress_description_weight = 10
  98. ################################################################################
  99. # Utils #
  100. ################################################################################
  101. def validate_tag(elem: ET.Element, tag: str) -> None:
  102. if elem.tag != tag:
  103. print('Tag mismatch, expected "' + tag + '", got ' + elem.tag)
  104. sys.exit(255)
  105. def color(color: str, string: str) -> str:
  106. if not is_stdout_color():
  107. return string
  108. color_format = "".join([str(x) for x in colors[color]])
  109. return f"{color_format}{string}{Ansi.RESET}"
  110. ansi_escape = re.compile(r"\x1b[^m]*m")
  111. def nonescape_len(s: str) -> int:
  112. return len(ansi_escape.sub("", s))
  113. ################################################################################
  114. # Classes #
  115. ################################################################################
  116. class ClassStatusProgress:
  117. def __init__(self, described: int = 0, total: int = 0):
  118. self.described: int = described
  119. self.total: int = total
  120. def __add__(self, other: ClassStatusProgress):
  121. return ClassStatusProgress(self.described + other.described, self.total + other.total)
  122. def increment(self, described: bool):
  123. if described:
  124. self.described += 1
  125. self.total += 1
  126. def is_ok(self):
  127. return self.described >= self.total
  128. def to_configured_colored_string(self):
  129. if flags["p"]:
  130. return self.to_colored_string("{percent}% ({has}/{total})", "{pad_percent}{pad_described}{s}{pad_total}")
  131. else:
  132. return self.to_colored_string()
  133. def to_colored_string(self, format: str = "{has}/{total}", pad_format: str = "{pad_described}{s}{pad_total}"):
  134. ratio = float(self.described) / float(self.total) if self.total != 0 else 1
  135. percent = int(round(100 * ratio))
  136. s = format.format(has=str(self.described), total=str(self.total), percent=str(percent))
  137. if self.described >= self.total:
  138. s = color("part_good", s)
  139. elif self.described >= self.total / 4 * 3:
  140. s = color("part_mostly_good", s)
  141. elif self.described > 0:
  142. s = color("part_problem", s)
  143. else:
  144. s = color("part_big_problem", s)
  145. pad_size = max(len(str(self.described)), len(str(self.total)))
  146. pad_described = "".ljust(pad_size - len(str(self.described)))
  147. pad_percent = "".ljust(3 - len(str(percent)))
  148. pad_total = "".ljust(pad_size - len(str(self.total)))
  149. return pad_format.format(pad_described=pad_described, pad_total=pad_total, pad_percent=pad_percent, s=s)
  150. class ClassStatus:
  151. def __init__(self, name: str = ""):
  152. self.name: str = name
  153. self.has_brief_description: bool = True
  154. self.has_description: bool = True
  155. self.progresses: dict[str, ClassStatusProgress] = {
  156. "methods": ClassStatusProgress(),
  157. "constants": ClassStatusProgress(),
  158. "members": ClassStatusProgress(),
  159. "theme_items": ClassStatusProgress(),
  160. "signals": ClassStatusProgress(),
  161. "operators": ClassStatusProgress(),
  162. "constructors": ClassStatusProgress(),
  163. }
  164. def __add__(self, other: ClassStatus):
  165. new_status = ClassStatus()
  166. new_status.name = self.name
  167. new_status.has_brief_description = self.has_brief_description and other.has_brief_description
  168. new_status.has_description = self.has_description and other.has_description
  169. for k in self.progresses:
  170. new_status.progresses[k] = self.progresses[k] + other.progresses[k]
  171. return new_status
  172. def is_ok(self):
  173. ok = True
  174. ok = ok and self.has_brief_description
  175. ok = ok and self.has_description
  176. for k in self.progresses:
  177. ok = ok and self.progresses[k].is_ok()
  178. return ok
  179. def is_empty(self):
  180. sum = 0
  181. for k in self.progresses:
  182. if self.progresses[k].is_ok():
  183. continue
  184. sum += self.progresses[k].total
  185. return sum < 1
  186. def make_output(self) -> dict[str, str]:
  187. output: dict[str, str] = {}
  188. output["name"] = color("name", self.name)
  189. ok_string = color("part_good", "OK")
  190. missing_string = color("part_big_problem", "MISSING")
  191. output["brief_description"] = ok_string if self.has_brief_description else missing_string
  192. output["description"] = ok_string if self.has_description else missing_string
  193. description_progress = ClassStatusProgress(
  194. (self.has_brief_description + self.has_description) * overall_progress_description_weight,
  195. 2 * overall_progress_description_weight,
  196. )
  197. items_progress = ClassStatusProgress()
  198. for k in ["methods", "constants", "members", "theme_items", "signals", "constructors", "operators"]:
  199. items_progress += self.progresses[k]
  200. output[k] = self.progresses[k].to_configured_colored_string()
  201. output["items"] = items_progress.to_configured_colored_string()
  202. output["overall"] = (description_progress + items_progress).to_colored_string(
  203. color("bold", "{percent}%"), "{pad_percent}{s}"
  204. )
  205. if self.name.startswith("Total"):
  206. output["url"] = color("url", "https://docs.godotengine.org/en/latest/classes/")
  207. if flags["s"]:
  208. output["comment"] = color("part_good", "ALL OK")
  209. else:
  210. output["url"] = color(
  211. "url", "https://docs.godotengine.org/en/latest/classes/class_{name}.html".format(name=self.name.lower())
  212. )
  213. if flags["s"] and not flags["g"] and self.is_ok():
  214. output["comment"] = color("part_good", "ALL OK")
  215. return output
  216. @staticmethod
  217. def generate_for_class(c: ET.Element):
  218. status = ClassStatus()
  219. status.name = c.attrib["name"]
  220. for tag in list(c):
  221. len_tag_text = 0 if (tag.text is None) else len(tag.text.strip())
  222. if tag.tag == "brief_description":
  223. status.has_brief_description = len_tag_text > 0
  224. elif tag.tag == "description":
  225. status.has_description = len_tag_text > 0
  226. elif tag.tag in ["methods", "signals", "operators", "constructors"]:
  227. for sub_tag in list(tag):
  228. is_deprecated = "deprecated" in sub_tag.attrib
  229. is_experimental = "experimental" in sub_tag.attrib
  230. descr = sub_tag.find("description")
  231. has_descr = (descr is not None) and (descr.text is not None) and len(descr.text.strip()) > 0
  232. status.progresses[tag.tag].increment(is_deprecated or is_experimental or has_descr)
  233. elif tag.tag in ["constants", "members", "theme_items"]:
  234. for sub_tag in list(tag):
  235. if sub_tag.text is not None:
  236. is_deprecated = "deprecated" in sub_tag.attrib
  237. is_experimental = "experimental" in sub_tag.attrib
  238. has_descr = len(sub_tag.text.strip()) > 0
  239. status.progresses[tag.tag].increment(is_deprecated or is_experimental or has_descr)
  240. elif tag.tag in ["tutorials"]:
  241. pass # Ignore those tags for now
  242. else:
  243. print(tag.tag, tag.attrib)
  244. return status
  245. ################################################################################
  246. # Arguments #
  247. ################################################################################
  248. input_file_list: list[str] = []
  249. input_class_list: list[str] = []
  250. merged_file: str = ""
  251. for arg in sys.argv[1:]:
  252. try:
  253. if arg.startswith("--"):
  254. flags[long_flags[arg[2:]]] = not flags[long_flags[arg[2:]]]
  255. elif arg.startswith("-"):
  256. for f in arg[1:]:
  257. flags[f] = not flags[f]
  258. elif os.path.isdir(arg):
  259. for f in os.listdir(arg):
  260. if f.endswith(".xml"):
  261. input_file_list.append(os.path.join(arg, f))
  262. else:
  263. input_class_list.append(arg)
  264. except KeyError:
  265. print("Unknown command line flag: " + arg)
  266. sys.exit(1)
  267. if flags["i"]:
  268. for r in ["methods", "constants", "members", "signals", "theme_items"]:
  269. index = table_columns.index(r)
  270. del table_column_names[index]
  271. del table_columns[index]
  272. table_column_names.append("Items")
  273. table_columns.append("items")
  274. if flags["o"] == (not flags["i"]):
  275. table_column_names.append(color("bold", "Overall"))
  276. table_columns.append("overall")
  277. if flags["u"]:
  278. table_column_names.append("Docs URL")
  279. table_columns.append("url")
  280. force_stdout_color(flags["c"])
  281. ################################################################################
  282. # Help #
  283. ################################################################################
  284. if len(input_file_list) < 1 or flags["h"]:
  285. if not flags["h"]:
  286. print(color("section", "Invalid usage") + ": Please specify a classes directory")
  287. print(color("section", "Usage") + ": doc_status.py [flags] <classes_dir> [class names]")
  288. print("\t< and > signify required parameters, while [ and ] signify optional parameters.")
  289. print(color("section", "Available flags") + ":")
  290. possible_synonym_list = list(long_flags)
  291. possible_synonym_list.sort()
  292. flag_list = list(flags)
  293. flag_list.sort()
  294. for flag in flag_list:
  295. synonyms = [color("name", "-" + flag)]
  296. for synonym in possible_synonym_list:
  297. if long_flags[synonym] == flag:
  298. synonyms.append(color("name", "--" + synonym))
  299. print(
  300. (
  301. "{synonyms} (Currently "
  302. + color("state_" + ("on" if flags[flag] else "off"), "{value}")
  303. + ")\n\t{description}"
  304. ).format(
  305. synonyms=", ".join(synonyms),
  306. value=("on" if flags[flag] else "off"),
  307. description=flag_descriptions[flag],
  308. )
  309. )
  310. sys.exit(0)
  311. ################################################################################
  312. # Parse class list #
  313. ################################################################################
  314. class_names: list[str] = []
  315. classes: dict[str, ET.Element] = {}
  316. for file in input_file_list:
  317. tree = ET.parse(file)
  318. doc = tree.getroot()
  319. if doc.attrib["name"] in class_names:
  320. continue
  321. class_names.append(doc.attrib["name"])
  322. classes[doc.attrib["name"]] = doc
  323. class_names.sort()
  324. if len(input_class_list) < 1:
  325. input_class_list = ["*"]
  326. filtered_classes_set: set[str] = set()
  327. for pattern in input_class_list:
  328. filtered_classes_set |= set(fnmatch.filter(class_names, pattern))
  329. filtered_classes = list(filtered_classes_set)
  330. filtered_classes.sort()
  331. ################################################################################
  332. # Make output table #
  333. ################################################################################
  334. table = [table_column_names]
  335. table_row_chars = "| - "
  336. table_column_chars = "|"
  337. total_status = ClassStatus("Total")
  338. for cn in filtered_classes:
  339. c = classes[cn]
  340. validate_tag(c, "class")
  341. status = ClassStatus.generate_for_class(c)
  342. total_status = total_status + status
  343. if (flags["b"] and status.is_ok()) or (flags["g"] and not status.is_ok()) or (not flags["a"]):
  344. continue
  345. if flags["e"] and status.is_empty():
  346. continue
  347. out = status.make_output()
  348. row: list[str] = []
  349. for column in table_columns:
  350. if column in out:
  351. row.append(out[column])
  352. else:
  353. row.append("")
  354. if "comment" in out and out["comment"] != "":
  355. row.append(out["comment"])
  356. table.append(row)
  357. ################################################################################
  358. # Print output table #
  359. ################################################################################
  360. if len(table) == 1 and flags["a"]:
  361. print(color("part_big_problem", "No classes suitable for printing!"))
  362. sys.exit(0)
  363. if len(table) > 2 or not flags["a"]:
  364. total_status.name = "Total = {0}".format(len(table) - 1)
  365. out = total_status.make_output()
  366. row = []
  367. for column in table_columns:
  368. if column in out:
  369. row.append(out[column])
  370. else:
  371. row.append("")
  372. table.append(row)
  373. if flags["a"]:
  374. # Duplicate the headers at the bottom of the table so they can be viewed
  375. # without having to scroll back to the top.
  376. table.append(table_column_names)
  377. table_column_sizes: list[int] = []
  378. for row in table:
  379. for cell_i, cell in enumerate(row):
  380. if cell_i >= len(table_column_sizes):
  381. table_column_sizes.append(0)
  382. table_column_sizes[cell_i] = max(nonescape_len(cell), table_column_sizes[cell_i])
  383. divider_string = table_row_chars[0]
  384. for cell_i in range(len(table[0])):
  385. divider_string += (
  386. table_row_chars[1] + table_row_chars[2] * (table_column_sizes[cell_i]) + table_row_chars[1] + table_row_chars[0]
  387. )
  388. for row_i, row in enumerate(table):
  389. row_string = table_column_chars
  390. for cell_i, cell in enumerate(row):
  391. padding_needed = table_column_sizes[cell_i] - nonescape_len(cell) + 2
  392. if cell_i == 0:
  393. row_string += table_row_chars[3] + cell + table_row_chars[3] * (padding_needed - 1)
  394. else:
  395. row_string += (
  396. table_row_chars[3] * int(math.floor(float(padding_needed) / 2))
  397. + cell
  398. + table_row_chars[3] * int(math.ceil(float(padding_needed) / 2))
  399. )
  400. row_string += table_column_chars
  401. print(row_string)
  402. # Account for the possible double header (if the `a` flag is enabled).
  403. # No need to have a condition for the flag, as this will behave correctly
  404. # if the flag is disabled.
  405. if row_i == 0 or row_i == len(table) - 3 or row_i == len(table) - 2:
  406. print(divider_string)
  407. print(divider_string)
  408. if total_status.is_ok() and not flags["g"]:
  409. print("All listed classes are " + color("part_good", "OK") + "!")