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