yasd/yasd.py

457 lines
14 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:
"""
YASD actions performer.
"""
def do(action="check", indata=None, outfile=None, quiet=False, log=False):
"""
Performs YASD actions directly.
Intented for YASDCLI, but can also be used programmatically.
:param str action: YASD action to perform; 'check' by default
:param indata: YASD input; 'None' by default
:type indata: None or Path or dict
:param outfile: YASD output file path; 'None' by default
:type outfile: None or Path
:param quiet: If messages are print or not; 'False' by default
:type quiet: True or False
:param log: If messages are write in a file or not; 'False' by default
:type log: True or False
"""
yasd = YASD(indata, outfile, quiet, log)
yasd.msgr.run(f"action_{action}")
if action == "convert":
yasd.convert(stdout=True)
elif action == "sample":
yasd.sample(stdout=True)
elif action == "document":
yasd.document(stdout=True)
def __init__(self, indata=None, outfile=None, quiet=False, log=False):
"""
Inits YASD object.
:param indata: YASD input; 'None' by default
:type indata: None or Path or dict
:param outfile: YASD output file path; 'None' by default
:type outfile: None or Path
:param quiet: If messages are print or not; 'False' by default
:type quiet: True or False
:param log: If messages are write in a file or not; 'False' by default
:type log: True or False
"""
self.msgr = YASDMessenger(quiet=quiet, log=log)
self.yaml = YASDCheck(indata, self.msgr).yaml
self.formatter = XMLFormatter(indent=2)
self.soups = self.__get_soups()
self.outfile = outfile
self.out = ""
def convert(self, stdout=False):
"""
Converts YASD to XSD.
:param stdout: if conversion goes to stdout or not; 'False' by default
:type stdout: True or False
"""
self.__build_schema()
self.__build_elements()
self.__build_attributes()
self.__stringify_xsd()
if stdout:
self.__output()
else:
return self.out
def sample(self, stdout=False):
"""
Generates XML sample from YASD.
:param stdout: if sample goes to stdout or not; 'False' by default
:type stdout: True or False
"""
# TODO: XML sample
self.out = "TODO: XML sample"
if stdout:
self.__output()
else:
return self.out
def document(self, stdout=False):
"""
Generates RST documentation
:param stdout: if document goes to stdout or not; 'False' by default
:type stdout: True or False
"""
# TODO: RST document
self.out = f"TODO: RST document from :{self.__dict__}"
if stdout:
self.__output()
else:
return self.out
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_soups(self):
"""
Gets soups structures.
"""
return {
"schema": "",
"elements": "",
"attributes": "",
"groups": "",
}
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 __stringify_xsd(self):
"""
Converts BeautifulSoups to pretty text format.
"""
xsd = self.soups["schema"]
del self.soups["schema"]
for key, val in self.soups.items():
xsd.append(val)
self.out = xsd.prettify(formatter=self.formatter)
def __output(self, extname=".xsd"):
"""
Prints in the terminal or writes into a file.
"""
if self.outfile is None:
print(self.out)
else:
suffix = self.outfile.suffix
if len(suffix) > 0 and suffix == suffix.replace(" ", ""):
extname = suffix
filename = f"{self.outfile.stem}{extname}"
filename = Path(self.outfile.parent / filename)
filename.write_text(self.out)
class YASDCheck:
"""
YASD input validator.
"""
def __init__(self, indata=None, messenger=None):
"""
Inits YASD validator.
:param indata: YASD input
:type indata: None or Path or dict
:param messenger: Object for print or save messages
:type messenger: None or YASDMessenger
"""
if messenger is None:
self.msgr = YASDMessenger()
else:
self.msgr = messenger
if type(indata) is dict:
self.yaml = indata
else:
self.yaml = self.parse_file(self.check_file(indata))
self.check_structure()
def check_file(self, filepath):
"""
Verifies YASD file.
:param filepath: YASD file path
:type filepath: None or Path
"""
if type(filepath).__module__ != "pathlib":
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)
return filepath.resolve()
def parse_file(self, filepath):
"""
Attempts YASD file parsing.
:param filepath: YASD file path
:type filepath: Path
"""
raw = filepath.read_text(encoding="utf8")
try:
return yaml.safe_load(raw)
except yaml.YAMLError:
self.msgr.run("invalid_yaml", level="error")
def check_structure(self):
"""
Verifies YASD structure.
:return: YASD structure
:rtype: dict
"""
# TODO: extra checks for self.yaml
...
class YASDMessenger:
"""
YASD printer or writer.
"""
def keys():
"""
Messages keys dictionary.
Here multilang support could be implemented with:
https://github.com/sectasy0/pyi18n
"""
return {
"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>.
""",
"help_action": "action to perform",
"help_input": "input file in YAML format",
"help_output": "output file",
"help_quiet": "enable quiet mode",
"help_log": "write log",
"action_convert": "Creating XSD schema",
"action_check": "Checking YASD",
"action_sample": "Creating XML sample",
"action_document": "Creating RST documentation",
"invalid_level": "Invalid log level '@lvl'",
"invalid_input": "Invalid file '@file'",
"invalid_yaml": "Invalid YAML structure",
"no_input": "Input file needed.",
}
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 level not 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
class YASDCLI:
"""
YASD command-line interface.
"""
def __init__(self):
"""
Inits YASD CLI.
"""
self.__init_parser()
args = self.parser.parse_args()
if args.action == "man":
# TODO: print man from README
print("TODO: MAN")
else:
YASD.do(args.action, args.input, args.output, args.quiet, args.log)
def __init_parser(self):
"""
Inits argument parser.
"""
msg = YASDMessenger.keys()
self.parser = argparse.ArgumentParser(
prog="yasd",
description=msg["description"],
epilog=msg["epilog"],
)
self.parser.add_argument(
"action",
choices=["convert", "check", "sample", "document", "man"],
help=msg["help_action"],
)
self.parser.add_argument(
"input",
type=Path,
nargs="?",
default=None,
help=msg["help_input"],
)
self.parser.add_argument(
"-q", "--quiet", action="store_true", help=msg["help_quiet"]
)
self.parser.add_argument(
"-l", "--log", action="store_true", help=msg["help_log"]
)
self.parser.add_argument(
"-o",
"--output",
type=Path,
default=None,
help=msg["help_output"],
)
if __name__ == "__main__":
YASDCLI()