doc_status.py 16 KB

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