Commit a145b9c8 authored by Mark Canterbury's avatar Mark Canterbury
Browse files

Initial commit

parents
Loading
Loading
Loading
Loading

LICENSE

0 → 100644
+21 −0
Original line number Original line Diff line number Diff line
The MIT License (MIT)

Copyright (c) 2024 Mark Canterbury

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

README.md

0 → 100644
+2 −0
Original line number Original line Diff line number Diff line
TODO - fill in this README to explain what package does.
 No newline at end of file

pyproject.toml

0 → 100644
+24 −0
Original line number Original line Diff line number Diff line
[build-system]
requires = ["flit_core >=3.2,<4",
            "xmlschema >= 2.0.0"]
build-backend = "flit_core.buildapi"

[project]
name = "xmltest"
authors = [{name = "Mark Canterbury", email = "markc@tencastle.com"}]
license = {file = "LICENSE"}
classifiers = ["License :: OSI Approved :: MIT License"]
dynamic = ["version", "description"]

[project.scripts]
xmltest = "xmltest:xmltest_console"

[project.optional-dependencies]
test = [
    "pytest"
]

testpaths = [
    "tests",
]
 No newline at end of file
+275 −0
Original line number Original line Diff line number Diff line
"""XMLTest XML validation helper"""

__version__ = "0.0.1"

from typing import Sequence
from argparse import ArgumentParser
from pathlib import Path
from json import loads, dumps

from .validator import build_schema
from .instance_docs import test_instance_docs, validate


def _print_aligned_result (condition: bool, slug: str, if_true:str, if_false: str, width = 50, padchar = "."):
    print (f"{slug}{if_true.rjust(width-len(slug), padchar) if condition else if_false.rjust(width-len(slug), padchar)}")

def _error_to_str(error: Exception, verbose = False):
    if verbose:
        return str(error)
    if hasattr(error, "message"):
        return error.message
    elif hasattr(error, "msg"):
        return error.msg
    else:
        return f"{error!r}"

def _expand_paths(paths : Sequence[str], glob_str : str = None) -> Sequence[Path]:
    out_paths = []
    for str_path in paths:
        path = Path(str_path)
        if path.is_dir():
            for file in path.rglob(glob_str):
                out_paths.append(file)
        else:
            out_paths.append(path)
    return out_paths


def xmltest_console():
    parser = ArgumentParser(description="Build a schema set and use it to validate zero or more instance documents. Returns 1 if the schema build fails, 2 if any of the instance documents fail to validate, or 0 otherwise. If JSON output is selected via the -j switch, it is always 0")
    arg_group = parser.add_mutually_exclusive_group(required=True)
    arg_group.add_argument("-c", "--config", help="Specifies the location of a JSON config file")
    arg_group.add_argument("-s", "--coreschema", help="Specifies the location of the core XSD schema file")
    parser.add_argument("-u", "--supportingschema", action="append", help="Specifies the location of any supporting schemas required. If a directory is specified, xmltest will search and add any XSD files recursively within the directory")
    parser.add_argument("-i", "--instancedoc", action="append", help="Instance XML document to validate against the schema. If a directory is specified, xmltest will search and add any XML files recursively within the directory")
    parser.add_argument("-j", "--jsonoutput", action="store_true", help="Output JSON instead of text. Return code will always be zro")
    parser.add_argument("-q", "--quiet", action="store_true", help="Suppress output")
    parser.add_argument("-v", action="count", help="Verbose. Can be specified multiple times to get more detailed output")
    parser.add_argument("-z", action="store_true", help="Force non-rich-format output (pip install rich to get pretty output)")
    pargs = parser.parse_args()

    verbosity = pargs.v if pargs.v else 0

    if pargs.config:
        json_config = loads(Path(pargs.config).read_text())
    else:
        if pargs.supportingschema:
            supporting_schemas = _expand_paths(pargs.supportingschema, "*.xsd")
        else:
            supporting_schemas = []
        if pargs.instancedoc:
            instance_docs = _expand_paths(pargs.instancedoc, "*.xml")
        else:
            instance_docs = []

        json_config = {
            "coreSchema" : pargs.coreschema,
            "supportingSchemas" : supporting_schemas,
            "exampleFiles" : instance_docs,
        }

    results = list(validate(json_config))

    total_fatal_build_failures = 0
    total_build_errors = 0
    total_validation_failures = 0
    total_instance_docs = 0

    for target in results:
        if not target.build_ok:
            total_fatal_build_failures += 1
        for build_result in target.build_results:
            if not build_result.ok:
                total_build_errors += len(build_result.errors)
        else:
            for v_result in target.validation_results:
                total_instance_docs += 1
                if not v_result.ok:
                    total_validation_failures += len(v_result.errors)

    if pargs.jsonoutput:
        from json import JSONEncoder
        from dataclasses import is_dataclass, asdict
        class jx(JSONEncoder):
            def default(self, o):
                if is_dataclass(o):
                    return asdict(o)
                if isinstance(o, Path):
                    return str(o.resolve())
                if isinstance(o, Exception):
                    return str(o)
                return super().default(o)
        
        j = dumps(results, cls=jx)
        rprint(j)
        exit(0)

    try:
        if pargs.z:
            raise ModuleNotFoundError("Aborting attempt to import rich")
        from rich import print as rprint, box
        from rich.console import Console, Group
        from rich.panel import Panel
        from rich.table import Table
        from rich.rule import Rule

        console = Console()
        use_rich = True
    except ModuleNotFoundError:
        use_rich = False
        if (verbosity > 3):
            print ("Rich not found, falling back to regular console (use 'pip install rich' to make this prettier)")

    if not pargs.quiet:
        from os import get_terminal_size
        width = get_terminal_size().columns
        if verbosity > 2:
            if use_rich:
                for target in results:
                    table = Table(show_header=False, expand=True, box=box.ROUNDED)
                    table.add_column("String")
                    table.add_column("Status", width=4)
                    table.add_row(f"Building [yellow]{target.core_schema_path}[/]",
                                  "[green]OK[/]" if target.build_ok else "[red]Fail[/]")
                    table.add_section()
                    for build_result in target.build_results:
                        table.add_row(str(build_result.file), "[green]OK[/]" if build_result.ok else "[red]Fail[/]")
                        for error in build_result.errors:
                            table.add_row(f"[red]{_error_to_str(error, verbosity > 3)}[/]","")
                    table.add_section()
                    table.add_row(f"Validating instance documents","")
                    table.add_section()
                    for vresult in target.validation_results:
                        table.add_row(f"{vresult.file.resolve()}",
                                      "[green]OK[/]" if vresult.ok else "[red]Fail[/]")
                        for error in vresult.errors:
                            table.add_row(f"[red]{_error_to_str(error, verbosity > 3)}[/]","")
                    console.print(table)
            else:
                for target in results:
                    build_errors = 0
                    validation_failures = 0

                    print ("=" * width)
                    print (f"Testing target {target.core_schema_path}")
                    print ("-" * width)
                    _print_aligned_result(target.build_ok, "  Build", "[OK]", "[FAIL]", width=width)
                    for build_result in target.build_results:
                        build_errors += len(build_result.errors)
                        _print_aligned_result(build_result.ok, "    " + str(build_result.file.resolve()), "[OK]", "[FAIL]", width=width)
                        for error in build_result.errors:
                            print(f"      {_error_to_str(error, verbosity > 3)}")
                    print ("-" * width)
                    print(f"  Validating {len(target.validation_results)} instance documents")

                    for vresult in target.validation_results:
                        validation_failures += len(vresult.errors)
                        _print_aligned_result(vresult.ok, "    " + str(vresult.file.resolve()), "[OK]", "[FAIL]", width=width)
                        for error in vresult.errors:
                            print(f"      {_error_to_str(error, verbosity > 3)}")         
        if verbosity > 1:
            if use_rich:
                table = Table(show_header=True, expand=True, box=box.ROUNDED)
                table.add_column("File")
                table.add_column("Built")
                table.add_column("XSD")
                table.add_column("Err")
                table.add_column("XML")
                table.add_column("Err")
                for target in results:
                    build_errors = 0
                    validation_failures = 0

                    build_files = len(target.build_results)
                    build_error_count = sum([len(error.errors) for error in target.build_results])
                    instance_files = len(target.validation_results)
                    validation_error_count = sum([len(error.errors) for error in target.validation_results])

                    table.add_row(str(target.core_schema_path.name),
                                  "[green]OK[/]" if target.build_ok else "[red]Fail[/]",
                                  str(build_files),
                                  f"{"[green]" if build_error_count == 0 else "[red]"}{build_error_count}[/]",
                                  str(instance_files),
                                  f"{"[green]" if validation_error_count == 0 else "[red]"}{validation_error_count}[/]")
                console.print(table)
            else:
                print ("=" * width)
                print ("Build summary (Core file [status] [XSD files/errors] [XML files/errors])")
                for target in results:
                    build_errors = 0
                    validation_failures = 0

                    build_files = len(target.build_results)
                    build_error_count = sum([len(error.errors) for error in target.build_results])
                    instance_files = len(target.validation_results)
                    validation_error_count = sum([len(error.errors) for error in target.validation_results])

                    status_line = f"{"[OK]  " if target.build_ok else "[FAIL] "} [{build_files}/{build_error_count}] [{instance_files}/{validation_error_count}]"
                    print(f"  {target.core_schema_path}{status_line.rjust(width - len(str(target.core_schema_path)) - 2, ".")}")

        if verbosity > 0:
            if use_rich:
                table = Table(show_header=False, expand=True, box=box.ROUNDED)
                if (total_build_errors + total_fatal_build_failures + total_validation_failures) == 0:
                    table.add_row(f"Issues{"[green]None[/]".rjust(width, " ")}")
                else:
                    table.add_row(f"Issues")
                    for target in results:
                        for result in target.build_results:
                            for error in result.errors:
                                table.add_section()
                                table.add_row(f"Build error in [red]{result.file}[/]")
                                table.add_row(f"  {_error_to_str(error, verbosity > 1)}")
                        for result in target.validation_results:
                            for error in result.errors:
                                table.add_section()
                                table.add_row(f"Validation error in [red]{result.file}[/]")
                                table.add_row(f"against {target.core_schema_path}")
                                table.add_row(f"[yellow]{_error_to_str(error, verbosity > 1)}[/]")
                console.print(table)
            else:
                print ("=" * width)

                if (total_build_errors + total_fatal_build_failures + total_validation_failures) == 0:
                    print (f"Issues{"None".rjust(width-10," ")}")
                else:
                    print ("Issues")
                    for target in results:
                        for result in target.build_results:
                            for error in result.errors:
                                print ("-" * width)
                                print (f"Build error in {result.file}")
                                print (f"  {_error_to_str(error, verbosity > 1)}")
                        for result in target.validation_results:
                            for error in result.errors:
                                print ("-" * width)
                                print (f"Validation error in {result.file}")
                                print (f"  (against {target.core_schema_path})")
                                print (f"  {_error_to_str(error, verbosity > 1)}")

        if use_rich:
            table = Table(show_header=False, expand=True, box=box.ROUNDED)
            table.add_column("Field", width=8, justify="left")
            table.add_column("Value", justify="left")
            table.add_row("Validation targets", str(len(results)))
            table.add_row("Build failures", f"{"[green]" if total_fatal_build_failures == 0 else "[red]"}{total_fatal_build_failures}[/] fatal, {"[green]" if total_build_errors == 0 else "[yellow]"}{total_build_errors}[/] non-fatal")
            table.add_row("Validation instance docs", str(total_instance_docs))
            table.add_row("Validation failures", f"{"[green]" if total_validation_failures == 0 else "[red]"}{str(total_validation_failures)}[/]")
            table.add_section()
            table.add_row("Outcome", "[green]OK[/]" if total_fatal_build_failures + total_validation_failures == 0 else "[red]FAIL[/]")
            console.print(table)
        else:
            print ("=" * width)
            print (f"Validation targets.........{len(results)}")
            print (f"Build failures.............{total_fatal_build_failures} fatal, {total_build_errors} non-fatal")
            print (f"Validation instance docs...{total_instance_docs}")
            print (f"Validation failures........{total_validation_failures}")
            print (f"Outcome....................{"OK" if total_fatal_build_failures + total_validation_failures == 0 else "FAIL"}")
            print ("=" * width)

    if total_fatal_build_failures > 0:
        exit(1)
    if total_validation_failures > 0:
        exit(2)
 No newline at end of file
+16.3 KiB

File added.

No diff preview for this file type.