#!/usr/bin/env python3 # Copyright 2020-2022 David Robillard # 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) )