507 lines
17 KiB
Python
Executable file
507 lines
17 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
|
|
import fnmatch
|
|
import os
|
|
import sys
|
|
import re
|
|
import math
|
|
import platform
|
|
import xml.etree.ElementTree as ET
|
|
from typing import Dict, List, Set
|
|
|
|
################################################################################
|
|
# Config #
|
|
################################################################################
|
|
|
|
flags = {
|
|
"c": platform.platform() != "Windows", # Disable by default on windows, since we use ANSI escape codes
|
|
"b": False,
|
|
"g": False,
|
|
"s": False,
|
|
"u": False,
|
|
"h": False,
|
|
"p": False,
|
|
"o": True,
|
|
"i": False,
|
|
"a": True,
|
|
"e": False,
|
|
}
|
|
flag_descriptions = {
|
|
"c": "Toggle colors when outputting.",
|
|
"b": "Toggle showing only not fully described classes.",
|
|
"g": "Toggle showing only completed classes.",
|
|
"s": "Toggle showing comments about the status.",
|
|
"u": "Toggle URLs to docs.",
|
|
"h": "Show help and exit.",
|
|
"p": "Toggle showing percentage as well as counts.",
|
|
"o": "Toggle overall column.",
|
|
"i": "Toggle collapse of class items columns.",
|
|
"a": "Toggle showing all items.",
|
|
"e": "Toggle hiding empty items.",
|
|
}
|
|
long_flags = {
|
|
"colors": "c",
|
|
"use-colors": "c",
|
|
"bad": "b",
|
|
"only-bad": "b",
|
|
"good": "g",
|
|
"only-good": "g",
|
|
"comments": "s",
|
|
"status": "s",
|
|
"urls": "u",
|
|
"gen-url": "u",
|
|
"help": "h",
|
|
"percent": "p",
|
|
"use-percentages": "p",
|
|
"overall": "o",
|
|
"use-overall": "o",
|
|
"items": "i",
|
|
"collapse": "i",
|
|
"all": "a",
|
|
"empty": "e",
|
|
}
|
|
table_columns = [
|
|
"name",
|
|
"brief_description",
|
|
"description",
|
|
"methods",
|
|
"constants",
|
|
"members",
|
|
"theme_items",
|
|
"signals",
|
|
"operators",
|
|
"constructors",
|
|
]
|
|
table_column_names = [
|
|
"Name",
|
|
"Brief Desc.",
|
|
"Desc.",
|
|
"Methods",
|
|
"Constants",
|
|
"Members",
|
|
"Theme Items",
|
|
"Signals",
|
|
"Operators",
|
|
"Constructors",
|
|
]
|
|
colors = {
|
|
"name": [36], # cyan
|
|
"part_big_problem": [4, 31], # underline, red
|
|
"part_problem": [31], # red
|
|
"part_mostly_good": [33], # yellow
|
|
"part_good": [32], # green
|
|
"url": [4, 34], # underline, blue
|
|
"section": [1, 4], # bold, underline
|
|
"state_off": [36], # cyan
|
|
"state_on": [1, 35], # bold, magenta/plum
|
|
"bold": [1], # bold
|
|
}
|
|
overall_progress_description_weight = 10
|
|
|
|
|
|
################################################################################
|
|
# Utils #
|
|
################################################################################
|
|
|
|
|
|
def validate_tag(elem: ET.Element, tag: str) -> None:
|
|
if elem.tag != tag:
|
|
print('Tag mismatch, expected "' + tag + '", got ' + elem.tag)
|
|
sys.exit(255)
|
|
|
|
|
|
def color(color: str, string: str) -> str:
|
|
if flags["c"] and terminal_supports_color():
|
|
color_format = ""
|
|
for code in colors[color]:
|
|
color_format += "\033[" + str(code) + "m"
|
|
return color_format + string + "\033[0m"
|
|
else:
|
|
return string
|
|
|
|
|
|
ansi_escape = re.compile(r"\x1b[^m]*m")
|
|
|
|
|
|
def nonescape_len(s: str) -> int:
|
|
return len(ansi_escape.sub("", s))
|
|
|
|
|
|
def terminal_supports_color():
|
|
p = sys.platform
|
|
supported_platform = p != "Pocket PC" and (p != "win32" or "ANSICON" in os.environ)
|
|
|
|
is_a_tty = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
|
if not supported_platform or not is_a_tty:
|
|
return False
|
|
return True
|
|
|
|
|
|
################################################################################
|
|
# Classes #
|
|
################################################################################
|
|
|
|
|
|
class ClassStatusProgress:
|
|
def __init__(self, described: int = 0, total: int = 0):
|
|
self.described: int = described
|
|
self.total: int = total
|
|
|
|
def __add__(self, other: "ClassStatusProgress"):
|
|
return ClassStatusProgress(self.described + other.described, self.total + other.total)
|
|
|
|
def increment(self, described: bool):
|
|
if described:
|
|
self.described += 1
|
|
self.total += 1
|
|
|
|
def is_ok(self):
|
|
return self.described >= self.total
|
|
|
|
def to_configured_colored_string(self):
|
|
if flags["p"]:
|
|
return self.to_colored_string("{percent}% ({has}/{total})", "{pad_percent}{pad_described}{s}{pad_total}")
|
|
else:
|
|
return self.to_colored_string()
|
|
|
|
def to_colored_string(self, format: str = "{has}/{total}", pad_format: str = "{pad_described}{s}{pad_total}"):
|
|
ratio = float(self.described) / float(self.total) if self.total != 0 else 1
|
|
percent = int(round(100 * ratio))
|
|
s = format.format(has=str(self.described), total=str(self.total), percent=str(percent))
|
|
if self.described >= self.total:
|
|
s = color("part_good", s)
|
|
elif self.described >= self.total / 4 * 3:
|
|
s = color("part_mostly_good", s)
|
|
elif self.described > 0:
|
|
s = color("part_problem", s)
|
|
else:
|
|
s = color("part_big_problem", s)
|
|
pad_size = max(len(str(self.described)), len(str(self.total)))
|
|
pad_described = "".ljust(pad_size - len(str(self.described)))
|
|
pad_percent = "".ljust(3 - len(str(percent)))
|
|
pad_total = "".ljust(pad_size - len(str(self.total)))
|
|
return pad_format.format(pad_described=pad_described, pad_total=pad_total, pad_percent=pad_percent, s=s)
|
|
|
|
|
|
class ClassStatus:
|
|
def __init__(self, name: str = ""):
|
|
self.name: str = name
|
|
self.has_brief_description: bool = True
|
|
self.has_description: bool = True
|
|
self.progresses: Dict[str, ClassStatusProgress] = {
|
|
"methods": ClassStatusProgress(),
|
|
"constants": ClassStatusProgress(),
|
|
"members": ClassStatusProgress(),
|
|
"theme_items": ClassStatusProgress(),
|
|
"signals": ClassStatusProgress(),
|
|
"operators": ClassStatusProgress(),
|
|
"constructors": ClassStatusProgress(),
|
|
}
|
|
|
|
def __add__(self, other: "ClassStatus"):
|
|
new_status = ClassStatus()
|
|
new_status.name = self.name
|
|
new_status.has_brief_description = self.has_brief_description and other.has_brief_description
|
|
new_status.has_description = self.has_description and other.has_description
|
|
for k in self.progresses:
|
|
new_status.progresses[k] = self.progresses[k] + other.progresses[k]
|
|
return new_status
|
|
|
|
def is_ok(self):
|
|
ok = True
|
|
ok = ok and self.has_brief_description
|
|
ok = ok and self.has_description
|
|
for k in self.progresses:
|
|
ok = ok and self.progresses[k].is_ok()
|
|
return ok
|
|
|
|
def is_empty(self):
|
|
sum = 0
|
|
for k in self.progresses:
|
|
if self.progresses[k].is_ok():
|
|
continue
|
|
sum += self.progresses[k].total
|
|
return sum < 1
|
|
|
|
def make_output(self) -> Dict[str, str]:
|
|
output: Dict[str, str] = {}
|
|
output["name"] = color("name", self.name)
|
|
|
|
ok_string = color("part_good", "OK")
|
|
missing_string = color("part_big_problem", "MISSING")
|
|
|
|
output["brief_description"] = ok_string if self.has_brief_description else missing_string
|
|
output["description"] = ok_string if self.has_description else missing_string
|
|
|
|
description_progress = ClassStatusProgress(
|
|
(self.has_brief_description + self.has_description) * overall_progress_description_weight,
|
|
2 * overall_progress_description_weight,
|
|
)
|
|
items_progress = ClassStatusProgress()
|
|
|
|
for k in ["methods", "constants", "members", "theme_items", "signals", "constructors", "operators"]:
|
|
items_progress += self.progresses[k]
|
|
output[k] = self.progresses[k].to_configured_colored_string()
|
|
|
|
output["items"] = items_progress.to_configured_colored_string()
|
|
|
|
output["overall"] = (description_progress + items_progress).to_colored_string(
|
|
color("bold", "{percent}%"), "{pad_percent}{s}"
|
|
)
|
|
|
|
if self.name.startswith("Total"):
|
|
output["url"] = color("url", "https://docs.godotengine.org/en/latest/classes/")
|
|
if flags["s"]:
|
|
output["comment"] = color("part_good", "ALL OK")
|
|
else:
|
|
output["url"] = color(
|
|
"url", "https://docs.godotengine.org/en/latest/classes/class_{name}.html".format(name=self.name.lower())
|
|
)
|
|
|
|
if flags["s"] and not flags["g"] and self.is_ok():
|
|
output["comment"] = color("part_good", "ALL OK")
|
|
|
|
return output
|
|
|
|
@staticmethod
|
|
def generate_for_class(c: ET.Element):
|
|
status = ClassStatus()
|
|
status.name = c.attrib["name"]
|
|
|
|
for tag in list(c):
|
|
len_tag_text = 0 if (tag.text is None) else len(tag.text.strip())
|
|
|
|
if tag.tag == "brief_description":
|
|
status.has_brief_description = len_tag_text > 0
|
|
|
|
elif tag.tag == "description":
|
|
status.has_description = len_tag_text > 0
|
|
|
|
elif tag.tag in ["methods", "signals", "operators", "constructors"]:
|
|
for sub_tag in list(tag):
|
|
is_deprecated = "deprecated" in sub_tag.attrib
|
|
is_experimental = "experimental" in sub_tag.attrib
|
|
descr = sub_tag.find("description")
|
|
has_descr = (descr is not None) and (descr.text is not None) and len(descr.text.strip()) > 0
|
|
status.progresses[tag.tag].increment(is_deprecated or is_experimental or has_descr)
|
|
elif tag.tag in ["constants", "members", "theme_items"]:
|
|
for sub_tag in list(tag):
|
|
if not sub_tag.text is None:
|
|
is_deprecated = "deprecated" in sub_tag.attrib
|
|
is_experimental = "experimental" in sub_tag.attrib
|
|
has_descr = len(sub_tag.text.strip()) > 0
|
|
status.progresses[tag.tag].increment(is_deprecated or is_experimental or has_descr)
|
|
|
|
elif tag.tag in ["tutorials"]:
|
|
pass # Ignore those tags for now
|
|
|
|
else:
|
|
print(tag.tag, tag.attrib)
|
|
|
|
return status
|
|
|
|
|
|
################################################################################
|
|
# Arguments #
|
|
################################################################################
|
|
|
|
input_file_list: List[str] = []
|
|
input_class_list: List[str] = []
|
|
merged_file: str = ""
|
|
|
|
for arg in sys.argv[1:]:
|
|
try:
|
|
if arg.startswith("--"):
|
|
flags[long_flags[arg[2:]]] = not flags[long_flags[arg[2:]]]
|
|
elif arg.startswith("-"):
|
|
for f in arg[1:]:
|
|
flags[f] = not flags[f]
|
|
elif os.path.isdir(arg):
|
|
for f in os.listdir(arg):
|
|
if f.endswith(".xml"):
|
|
input_file_list.append(os.path.join(arg, f))
|
|
else:
|
|
input_class_list.append(arg)
|
|
except KeyError:
|
|
print("Unknown command line flag: " + arg)
|
|
sys.exit(1)
|
|
|
|
if flags["i"]:
|
|
for r in ["methods", "constants", "members", "signals", "theme_items"]:
|
|
index = table_columns.index(r)
|
|
del table_column_names[index]
|
|
del table_columns[index]
|
|
table_column_names.append("Items")
|
|
table_columns.append("items")
|
|
|
|
if flags["o"] == (not flags["i"]):
|
|
table_column_names.append(color("bold", "Overall"))
|
|
table_columns.append("overall")
|
|
|
|
if flags["u"]:
|
|
table_column_names.append("Docs URL")
|
|
table_columns.append("url")
|
|
|
|
|
|
################################################################################
|
|
# Help #
|
|
################################################################################
|
|
|
|
if len(input_file_list) < 1 or flags["h"]:
|
|
if not flags["h"]:
|
|
print(color("section", "Invalid usage") + ": Please specify a classes directory")
|
|
print(color("section", "Usage") + ": doc_status.py [flags] <classes_dir> [class names]")
|
|
print("\t< and > signify required parameters, while [ and ] signify optional parameters.")
|
|
print(color("section", "Available flags") + ":")
|
|
possible_synonym_list = list(long_flags)
|
|
possible_synonym_list.sort()
|
|
flag_list = list(flags)
|
|
flag_list.sort()
|
|
for flag in flag_list:
|
|
synonyms = [color("name", "-" + flag)]
|
|
for synonym in possible_synonym_list:
|
|
if long_flags[synonym] == flag:
|
|
synonyms.append(color("name", "--" + synonym))
|
|
|
|
print(
|
|
(
|
|
"{synonyms} (Currently "
|
|
+ color("state_" + ("on" if flags[flag] else "off"), "{value}")
|
|
+ ")\n\t{description}"
|
|
).format(
|
|
synonyms=", ".join(synonyms),
|
|
value=("on" if flags[flag] else "off"),
|
|
description=flag_descriptions[flag],
|
|
)
|
|
)
|
|
sys.exit(0)
|
|
|
|
|
|
################################################################################
|
|
# Parse class list #
|
|
################################################################################
|
|
|
|
class_names: List[str] = []
|
|
classes: Dict[str, ET.Element] = {}
|
|
|
|
for file in input_file_list:
|
|
tree = ET.parse(file)
|
|
doc = tree.getroot()
|
|
|
|
if doc.attrib["name"] in class_names:
|
|
continue
|
|
class_names.append(doc.attrib["name"])
|
|
classes[doc.attrib["name"]] = doc
|
|
|
|
class_names.sort()
|
|
|
|
if len(input_class_list) < 1:
|
|
input_class_list = ["*"]
|
|
|
|
filtered_classes_set: Set[str] = set()
|
|
for pattern in input_class_list:
|
|
filtered_classes_set |= set(fnmatch.filter(class_names, pattern))
|
|
filtered_classes = list(filtered_classes_set)
|
|
filtered_classes.sort()
|
|
|
|
################################################################################
|
|
# Make output table #
|
|
################################################################################
|
|
|
|
table = [table_column_names]
|
|
table_row_chars = "| - "
|
|
table_column_chars = "|"
|
|
|
|
total_status = ClassStatus("Total")
|
|
|
|
for cn in filtered_classes:
|
|
c = classes[cn]
|
|
validate_tag(c, "class")
|
|
status = ClassStatus.generate_for_class(c)
|
|
|
|
total_status = total_status + status
|
|
|
|
if (flags["b"] and status.is_ok()) or (flags["g"] and not status.is_ok()) or (not flags["a"]):
|
|
continue
|
|
|
|
if flags["e"] and status.is_empty():
|
|
continue
|
|
|
|
out = status.make_output()
|
|
row: List[str] = []
|
|
for column in table_columns:
|
|
if column in out:
|
|
row.append(out[column])
|
|
else:
|
|
row.append("")
|
|
|
|
if "comment" in out and out["comment"] != "":
|
|
row.append(out["comment"])
|
|
|
|
table.append(row)
|
|
|
|
|
|
################################################################################
|
|
# Print output table #
|
|
################################################################################
|
|
|
|
if len(table) == 1 and flags["a"]:
|
|
print(color("part_big_problem", "No classes suitable for printing!"))
|
|
sys.exit(0)
|
|
|
|
if len(table) > 2 or not flags["a"]:
|
|
total_status.name = "Total = {0}".format(len(table) - 1)
|
|
out = total_status.make_output()
|
|
row = []
|
|
for column in table_columns:
|
|
if column in out:
|
|
row.append(out[column])
|
|
else:
|
|
row.append("")
|
|
table.append(row)
|
|
|
|
if flags["a"]:
|
|
# Duplicate the headers at the bottom of the table so they can be viewed
|
|
# without having to scroll back to the top.
|
|
table.append(table_column_names)
|
|
|
|
table_column_sizes: List[int] = []
|
|
for row in table:
|
|
for cell_i, cell in enumerate(row):
|
|
if cell_i >= len(table_column_sizes):
|
|
table_column_sizes.append(0)
|
|
|
|
table_column_sizes[cell_i] = max(nonescape_len(cell), table_column_sizes[cell_i])
|
|
|
|
divider_string = table_row_chars[0]
|
|
for cell_i in range(len(table[0])):
|
|
divider_string += (
|
|
table_row_chars[1] + table_row_chars[2] * (table_column_sizes[cell_i]) + table_row_chars[1] + table_row_chars[0]
|
|
)
|
|
|
|
for row_i, row in enumerate(table):
|
|
row_string = table_column_chars
|
|
for cell_i, cell in enumerate(row):
|
|
padding_needed = table_column_sizes[cell_i] - nonescape_len(cell) + 2
|
|
if cell_i == 0:
|
|
row_string += table_row_chars[3] + cell + table_row_chars[3] * (padding_needed - 1)
|
|
else:
|
|
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))
|
|
)
|
|
row_string += table_column_chars
|
|
|
|
print(row_string)
|
|
|
|
# Account for the possible double header (if the `a` flag is enabled).
|
|
# No need to have a condition for the flag, as this will behave correctly
|
|
# if the flag is disabled.
|
|
if row_i == 0 or row_i == len(table) - 3 or row_i == len(table) - 2:
|
|
print(divider_string)
|
|
|
|
print(divider_string)
|
|
|
|
if total_status.is_ok() and not flags["g"]:
|
|
print("All listed classes are " + color("part_good", "OK") + "!")
|