349 lines
10 KiB
Python
Executable File
349 lines
10 KiB
Python
Executable File
#!/usr/bin/env python
|
|
# (c) 2023 Perro Tuerto <hi@perrotuerto.blog>.
|
|
# Founded by Mexican Academy of Language <https://academia.org.mx>.
|
|
# Licensed under GPLv3 <https://www.gnu.org/licenses/gpl-3.0.en.html>.
|
|
|
|
import sys
|
|
import yaml
|
|
import argparse
|
|
from pathlib import Path
|
|
from bs4 import BeautifulSoup
|
|
from bs4.formatter import XMLFormatter
|
|
|
|
|
|
class YASD:
|
|
"""
|
|
Performs YASD actions.
|
|
"""
|
|
|
|
def do(quiet, log, samples, action, filepath):
|
|
yasd = YASD(quiet, log, samples, filepath)
|
|
if action == "convert":
|
|
yasd.convert()
|
|
elif action == "sample":
|
|
yasd.sample()
|
|
elif action == "document":
|
|
yasd.document()
|
|
|
|
def __init__(self, quiet=False, log=False, samples=1, filepath=None):
|
|
"""
|
|
Inits YASD object.
|
|
|
|
:param quiet: If messages are print or not; 'False' by default
|
|
:type quiet: False or True
|
|
:param log: If messages are write in a file or not; 'False' by default
|
|
:type log: False or True
|
|
:param int samples: Quantity of XML samples; '1' by default
|
|
:param filepath: YASD file path; 'None' by default
|
|
:type filepath: None or Path
|
|
"""
|
|
self.msgr = YASDMessenger(quiet=quiet, log=log)
|
|
valid_input = YASDCheck(self.msgr, filepath)
|
|
self.filepath = valid_input.filepath
|
|
self.yaml = valid_input.yaml
|
|
self.samples = samples
|
|
self.soups = {
|
|
"schema": "",
|
|
"elements": "",
|
|
"attributes": "",
|
|
"groups": "",
|
|
}
|
|
|
|
def convert(self):
|
|
"""
|
|
Converts YASD to XSD.
|
|
"""
|
|
self.__build_schema()
|
|
self.__build_elements()
|
|
self.__build_attributes()
|
|
self.__write()
|
|
|
|
def sample(self):
|
|
"""
|
|
Generates XML samples from YASD.
|
|
"""
|
|
print(f"TODO: {self.samples} samples")
|
|
|
|
def document(self):
|
|
"""
|
|
Generates MD documentation
|
|
"""
|
|
print("TODO: MD document from :", self.__dict__)
|
|
|
|
def __build_schema(self):
|
|
unwanted = "version schemaLocation".split()
|
|
for key in unwanted:
|
|
self.yaml["schema"].pop(key)
|
|
soup = BeautifulSoup(parser="xml")
|
|
schema = soup.new_tag("schema", nsprefix="xs")
|
|
schema["xmlns:xs"] = "http://www.w3.org/2001/XMLSchema"
|
|
soup.append(schema)
|
|
for key, val in self.yaml["schema"].items():
|
|
schema[key] = val
|
|
self.soups["schema"] = soup
|
|
|
|
def __build_elements(self):
|
|
soup = BeautifulSoup(parser="xml")
|
|
for el in self.yaml["elements"]:
|
|
el = self.__sanitize(el)
|
|
{
|
|
"simple": self.__build_element_simple(soup, el),
|
|
"empty": self.__build_element_empty(soup, el),
|
|
"no_text": self.__build_element_no_text(soup, el),
|
|
"no_elements": self.__build_element_no_elements(soup, el),
|
|
"mixed": self.__build_element_mixed(soup, el),
|
|
}[el["type"]]
|
|
if len(soup.contents) > 0:
|
|
print(len(soup.contents))
|
|
self.soups["elements"] = soup
|
|
|
|
def __build_element_simple(self, main_soup, el):
|
|
# element = self.__build_simple(el)
|
|
# main_soup.append(element)
|
|
...
|
|
|
|
def __build_element_empty(self, main_soup, el):
|
|
...
|
|
|
|
def __build_element_no_text(self, main_soup, el):
|
|
...
|
|
|
|
def __build_element_no_elements(self, main_soup, el):
|
|
...
|
|
|
|
def __build_element_mixed(self, main_soup, el):
|
|
...
|
|
|
|
def __build_attributes(self):
|
|
soup = BeautifulSoup(parser="xml")
|
|
for el in self.yaml["attributes"]:
|
|
element = self.__build_simple(self.__sanitize(el), tag="attribute")
|
|
soup.append(element)
|
|
self.soups["attributes"] = soup
|
|
|
|
def __build_simple(self, el, tag="element"):
|
|
soup = BeautifulSoup(parser="xml")
|
|
element = soup.new_tag(tag, nsprefix="xs")
|
|
soup.append(element)
|
|
for key, val in el.items():
|
|
if key == "datatype":
|
|
element["type"] = f"xs:{val}"
|
|
elif key == "restriction":
|
|
self.__build_restriction(element, val)
|
|
else:
|
|
element[key] = val
|
|
return soup
|
|
|
|
def __build_restriction(self, root, elements):
|
|
soup = BeautifulSoup(parser="xml")
|
|
simple_type = soup.new_tag("simpleType", nsprefix="xs")
|
|
restriction = soup.new_tag("restriction", nsprefix="xs")
|
|
restriction["base"] = self.__get_base(list(elements.keys())[0])
|
|
for key, val in elements.items():
|
|
constrain = soup.new_tag(key, nsprefix="xs", value=val)
|
|
restriction.append(constrain)
|
|
simple_type.append(restriction)
|
|
root.append(simple_type)
|
|
|
|
def __sanitize(self, el):
|
|
"""
|
|
Prepares element or attribute for conversion.
|
|
|
|
It eliminates 'description' key.
|
|
|
|
:param dict el: Element or attribute as a dictionary
|
|
:return: Sanitized element or attribute
|
|
:rtype: dict
|
|
"""
|
|
if "description" in el.keys():
|
|
del el["description"]
|
|
return el
|
|
|
|
def __get_base(self, key):
|
|
"""
|
|
Gets restriction data type.
|
|
|
|
:param str key: Type of restriction
|
|
:return: 'xs:string' or 'xs:integer'
|
|
:rtype: str
|
|
"""
|
|
strings = "enumeration pattern whiteSpace length minLength maxLength"
|
|
if key in strings.split():
|
|
return "xs:string"
|
|
else:
|
|
return "xs:integer"
|
|
|
|
def __write(self):
|
|
"""
|
|
Writes XSD into a file.
|
|
"""
|
|
filename = Path(self.filepath.parent / f"{self.filepath.stem}.xsd")
|
|
formatter = XMLFormatter(indent=2)
|
|
for key, val in self.soups.items():
|
|
if key == "schema":
|
|
xsd = val.schema
|
|
else:
|
|
xsd.append(val)
|
|
filename.write_text(xsd.prettify(formatter=formatter))
|
|
|
|
|
|
class YASDCheck:
|
|
"""
|
|
Verifies YASD file.
|
|
"""
|
|
|
|
def __init__(self, messenger, filepath):
|
|
"""
|
|
Inits YASD validator.
|
|
|
|
:param YASDMessenger messenger: Object for print or save messages
|
|
:param filepath: YASD file path
|
|
:type filepath: None or Path
|
|
"""
|
|
self.msgr = messenger
|
|
self.__check_file(filepath)
|
|
self.__parse_file()
|
|
# TODO: do extra checks
|
|
|
|
def __check_file(self, filepath):
|
|
"""
|
|
Verifies YASD file.
|
|
|
|
:param filepath: YASD file path
|
|
:type filepath: None or Path
|
|
"""
|
|
if filepath is None:
|
|
self.msgr.run("no_input", level="error")
|
|
elif not filepath.exists() or not filepath.is_file():
|
|
self.msgr.run("invalid_input", level="error", file=filepath)
|
|
self.filepath = filepath.resolve()
|
|
|
|
def __parse_file(self):
|
|
"""
|
|
Attempts YASD file parsing.
|
|
"""
|
|
raw = self.filepath.read_text(encoding="utf8")
|
|
try:
|
|
self.yaml = yaml.safe_load(raw)
|
|
except yaml.YAMLError:
|
|
# TODO: should be log class
|
|
self.msgr.run("invalid_yaml", level="error")
|
|
|
|
|
|
class YASDMessenger:
|
|
"""
|
|
Prints or saves YASD messages.
|
|
"""
|
|
|
|
def keys():
|
|
"""
|
|
Messages keys dictionary.
|
|
"""
|
|
return {
|
|
"invalid_level": "Invalid log level '@lvl'",
|
|
"no_input": "Input file needed.",
|
|
"invalid_input": "Invalid file '@file'",
|
|
"invalid_yaml": "Invalid YAML structure",
|
|
}
|
|
|
|
def __init__(self, quiet=False, log=False):
|
|
"""
|
|
Inits YASD Messenger.
|
|
"""
|
|
self.quiet = quiet
|
|
self.log = log
|
|
|
|
def run(self, key="", level="info", **kwargs):
|
|
"""
|
|
Prints or writes messages.
|
|
|
|
'**kwargs' are the keys for message text replacements.
|
|
|
|
:param str key: Message key
|
|
:param str level: Log level; 'info' by default
|
|
"""
|
|
self.__check_level(level)
|
|
msg = self.__get_msg(key, **kwargs)
|
|
msg = f"[{level.upper()}] {msg}"
|
|
# TODO: print or save depending on self.quiet and self.log
|
|
print(msg)
|
|
if level in ["error", "fatal"]:
|
|
sys.exit(1)
|
|
|
|
def __check_level(self, level):
|
|
"""
|
|
Verifies log level.
|
|
|
|
Prints warning if log level doesn't exist.
|
|
|
|
:param str level: Log level
|
|
"""
|
|
if not level in ["trace", "debug", "info", "warn", "error", "fatal"]:
|
|
YASDMessenger().run("invalid_level", level="warn", lvl=level)
|
|
|
|
def __get_msg(self, key, **kwargs):
|
|
"""
|
|
Gets message based on key.
|
|
|
|
'**kwargs' are the keys for message text replacements.
|
|
|
|
:param str key: Message key
|
|
:return: Message or key if message key doesn't exist.
|
|
:rtype: str
|
|
"""
|
|
if key in YASDMessenger.keys().keys():
|
|
msg = YASDMessenger.keys()[key]
|
|
for key, value in kwargs.items():
|
|
msg = msg.replace(f"@{key}", str(value))
|
|
return msg
|
|
else:
|
|
return key
|
|
|
|
|
|
def main():
|
|
"""
|
|
Gets and parses argv, then calls YASD.
|
|
"""
|
|
parser = argparse.ArgumentParser(
|
|
prog="yasd",
|
|
description="""
|
|
YASD, Yet Another Schema Definition.
|
|
YASD is a YAML format for human writable XSDs (XML Schema Definition),
|
|
humans declare what is indispensable, leaving the machines to do the
|
|
rest of the unreadable <syntaxis who_can_read_this="?" />.
|
|
""",
|
|
epilog="""
|
|
(c) 2023 Perro Tuerto <hi@perrotuerto.blog>.
|
|
Founded by Mexican Academy of Language <https://academia.org.mx>.
|
|
Licensed under GPLv3 <https://www.gnu.org/licenses/gpl-3.0.en.html>.
|
|
""",
|
|
)
|
|
parser.add_argument(
|
|
"action",
|
|
choices=["convert", "check", "sample", "document", "man"],
|
|
help="action to perform",
|
|
)
|
|
parser.add_argument(
|
|
"file",
|
|
type=Path,
|
|
nargs="?",
|
|
default=None,
|
|
help="file in YAML format",
|
|
)
|
|
parser.add_argument(
|
|
"-q", "--quiet", action="store_true", help="enable quiet mode"
|
|
)
|
|
parser.add_argument("-l", "--log", action="store_true", help="write log")
|
|
parser.add_argument(
|
|
"-n", "--num", default=1, help="number of XML samples; 1 by default"
|
|
)
|
|
args = parser.parse_args()
|
|
if args.action == "man":
|
|
print("MAN")
|
|
else:
|
|
YASD.do(args.quiet, args.log, args.num, args.action, args.file)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|