doc_status.py 16 KB

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