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