diff options
author | David Robillard <d@drobilla.net> | 2022-07-07 18:59:06 -0400 |
---|---|---|
committer | David Robillard <d@drobilla.net> | 2022-07-17 18:13:53 -0400 |
commit | d4a970f6962dda28133290194832b726b566ddab (patch) | |
tree | cfe9747042d55388705371a8ce95505ffb702470 /scripts/lv2_check_specification.py | |
parent | 7f3a2651a3635232d94f7bf9ce23d6b575735732 (diff) | |
download | lv2-d4a970f6962dda28133290194832b726b566ddab.tar.xz |
Switch to meson build system
Diffstat (limited to 'scripts/lv2_check_specification.py')
-rwxr-xr-x | scripts/lv2_check_specification.py | 248 |
1 files changed, 248 insertions, 0 deletions
diff --git a/scripts/lv2_check_specification.py b/scripts/lv2_check_specification.py new file mode 100755 index 0000000..0cd296e --- /dev/null +++ b/scripts/lv2_check_specification.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 + +# Copyright 2020-2022 David Robillard <d@drobilla.net> +# SPDX-License-Identifier: ISC + +""" +Check an LV2 specification for issues. +""" + +import argparse +import os +import sys + +import rdflib + +foaf = rdflib.Namespace("http://xmlns.com/foaf/0.1/") +lv2 = rdflib.Namespace("http://lv2plug.in/ns/lv2core#") +owl = rdflib.Namespace("http://www.w3.org/2002/07/owl#") +rdf = rdflib.Namespace("http://www.w3.org/1999/02/22-rdf-syntax-ns#") +rdfs = rdflib.Namespace("http://www.w3.org/2000/01/rdf-schema#") + + +class Checker: + "A callable that checks conditions and records pass/fail counts." + + def __init__(self, verbose=False): + self.num_checks = 0 + self.num_errors = 0 + self.verbose = verbose + + def __call__(self, condition, name): + if not condition: + sys.stderr.write(f"error: Unmet condition: {name}\n") + self.num_errors += 1 + elif self.verbose: + sys.stderr.write(f"note: {name}\n") + + self.num_checks += 1 + return condition + + def print_summary(self): + "Print a summary (if verbose) when all checks are finished." + + if self.verbose: + if self.num_errors: + sys.stderr.write(f"note: Failed {self.num_errors}/") + else: + sys.stderr.write("note: Passed all ") + + sys.stderr.write(f"{self.num_checks} checks\n") + + +def _check(condition, name): + "Check that condition is true, returning 1 on failure." + + if not condition: + sys.stderr.write(f"error: Unmet condition: {name}\n") + return 1 + + return 0 + + +def _has_statement(model, pattern): + "Return true if model contains a triple matching pattern." + + for _ in model.triples(pattern): + return True + + return False + + +def _has_property(model, subject, predicate): + "Return true if subject has any value for predicate in model." + + return model.value(subject, predicate, None) is not None + + +def _check_version(checker, model, spec, is_stable): + "Check that the version of a specification is present and valid." + + minor = model.value(spec, lv2.minorVersion, None, any=False) + checker(minor is not None, f"{spec} has a lv2:minorVersion") + + micro = model.value(spec, lv2.microVersion, None, any=False) + checker(micro is not None, f"{spec} has a lv2:microVersion") + + if is_stable: + checker(int(minor) > 0, f"{spec} has a non-zero minor version") + checker(int(micro) % 2 == 0, f"{spec} has an even micro version") + + +def _check_specification(checker, spec_dir, is_stable=False): + "Check all specification data for errors and omissions." + + # Load manifest + manifest_path = os.path.join(spec_dir, "manifest.ttl") + model = rdflib.Graph() + model.parse(manifest_path, format="n3") + + # Get the specification URI from the manifest + spec_uri = model.value(None, rdf.type, lv2.Specification, any=False) + if not checker( + spec_uri is not None, + manifest_path + " declares an lv2:Specification", + ): + return 1 + + # Check that the manifest declares a valid version + _check_version(checker, model, spec_uri, is_stable) + + # Get the link to the main document from the manifest + document = model.value(spec_uri, rdfs.seeAlso, None, any=False) + if not checker( + document is not None, + manifest_path + " has one rdfs:seeAlso link to the definition", + ): + return 1 + + # Load main document into the model + model.parse(document, format="n3") + + # Check that the main data files aren't bloated with extended documentation + checker( + not _has_statement(model, [None, lv2.documentation, None]), + f"{document} has no lv2:documentation", + ) + + # Load all other directly linked data files (for any other subjects) + for link in sorted(model.triples([None, rdfs.seeAlso, None])): + if link[2] != document and link[2].endswith(".ttl"): + model.parse(link[2], format="n3") + + # Check that all properties have a more specific type + for typing in sorted(model.triples([None, rdf.type, rdf.Property])): + subject = typing[0] + + checker(isinstance(subject, rdflib.term.URIRef), f"{subject} is a URI") + + if str(subject) == "http://lv2plug.in/ns/ext/patch#value": + continue # patch:value is just a "promiscuous" rdf:Property + + types = list(model.objects(subject, rdf.type)) + + checker( + (owl.DatatypeProperty in types) + or (owl.ObjectProperty in types) + or (owl.AnnotationProperty in types), + f"{subject} is a Datatype, Object, or Annotation property", + ) + + # Get all subjects that have an explicit rdf:type + typed_subjects = set() + for typing in model.triples([None, rdf.type, None]): + typed_subjects.add(typing[0]) + + # Check that all named and typed resources have labels and comments + for subject in typed_subjects: + if isinstance( + subject, rdflib.term.BNode + ) or foaf.Person in model.objects(subject, rdf.type): + continue + + if checker( + _has_property(model, subject, rdfs.label), + f"{subject} has a rdfs:label", + ): + label = str(model.value(subject, rdfs.label, None)) + + checker( + not label.endswith("."), + f"{subject} label has no trailing '.'", + ) + checker( + label.find("\n") == -1, + f"{subject} label is a single line", + ) + checker( + label == label.strip(), + f"{subject} label has stripped whitespace", + ) + + if checker( + _has_property(model, subject, rdfs.comment), + f"{subject} has a rdfs:comment", + ): + comment = str(model.value(subject, rdfs.comment, None)) + + checker( + comment.endswith("."), + f"{subject} comment has a trailing '.'", + ) + checker( + comment.find("\n") == -1 and comment.find("\r"), + f"{subject} comment is a single line", + ) + checker( + comment == comment.strip(), + f"{subject} comment has stripped whitespace", + ) + + # Check that lv2:documentation, if present, is proper Markdown + documentation = model.value(subject, lv2.documentation, None) + if documentation is not None: + checker( + documentation.datatype == lv2.Markdown, + f"{subject} documentation is explicitly Markdown", + ) + checker( + str(documentation).startswith("\n\n"), + f"{subject} documentation starts with blank line", + ) + checker( + str(documentation).endswith("\n\n"), + f"{subject} documentation ends with blank line", + ) + + return checker.num_errors + + +if __name__ == "__main__": + ap = argparse.ArgumentParser( + usage="%(prog)s [OPTION]... BUNDLE", + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + ap.add_argument( + "--stable", + action="store_true", + help="enable checks for stable release versions", + ) + + ap.add_argument( + "-v", "--verbose", action="store_true", help="print successful checks" + ) + + ap.add_argument( + "BUNDLE", help="path to specification bundle or manifest.ttl" + ) + + args = ap.parse_args(sys.argv[1:]) + + if os.path.basename(args.BUNDLE): + args.BUNDLE = os.path.dirname(args.BUNDLE) + + sys.exit( + _check_specification(Checker(args.verbose), args.BUNDLE, args.stable) + ) |