doc_status.py 17 KB

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