5036dfb0b6
Also removes the first divider string which cleans up the extraneous dashes at the top of the page.
508 lines
17 KiB
Python
Executable file
508 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):
|
|
descr = sub_tag.find("description")
|
|
increment = (descr is not None) and (descr.text is not None) and len(descr.text.strip()) > 0
|
|
status.progresses[tag.tag].increment(increment)
|
|
elif tag.tag in ["constants", "members", "theme_items"]:
|
|
for sub_tag in list(tag):
|
|
if not sub_tag.text is None:
|
|
status.progresses[tag.tag].increment(len(sub_tag.text.strip()) > 0)
|
|
|
|
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 "version" not in doc.attrib:
|
|
print('Version missing from "doc"')
|
|
sys.exit(255)
|
|
|
|
version = doc.attrib["version"]
|
|
|
|
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") + "!")
|