xref: /trunk/test/aoo-test/src/aootest/checkers/validate.py (revision 6e223505681eb073927a3bbffc2acd512d92ba3f)
12cb6d6f1SJim Jagielski#**************************************************************
22cb6d6f1SJim Jagielski#
32cb6d6f1SJim Jagielski#  Licensed to the Apache Software Foundation (ASF) under one
42cb6d6f1SJim Jagielski#  or more contributor license agreements.  See the NOTICE file
52cb6d6f1SJim Jagielski#  distributed with this work for additional information
62cb6d6f1SJim Jagielski#  regarding copyright ownership.  The ASF licenses this file
72cb6d6f1SJim Jagielski#  to you under the Apache License, Version 2.0 (the
82cb6d6f1SJim Jagielski#  "License"); you may not use this file except in compliance
92cb6d6f1SJim Jagielski#  with the License.  You may obtain a copy of the License at
102cb6d6f1SJim Jagielski#
112cb6d6f1SJim Jagielski#    http://www.apache.org/licenses/LICENSE-2.0
122cb6d6f1SJim Jagielski#
132cb6d6f1SJim Jagielski#  Unless required by applicable law or agreed to in writing,
142cb6d6f1SJim Jagielski#  software distributed under the License is distributed on an
152cb6d6f1SJim Jagielski#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
162cb6d6f1SJim Jagielski#  KIND, either express or implied.  See the License for the
172cb6d6f1SJim Jagielski#  specific language governing permissions and limitations
182cb6d6f1SJim Jagielski#  under the License.
192cb6d6f1SJim Jagielski#
202cb6d6f1SJim Jagielski#**************************************************************
212cb6d6f1SJim Jagielski
222cb6d6f1SJim Jagielski# usage: python -m aootest.checkers.validate <outdir> <val.json>   (needs AOO_JING and AOO_SCHEMA_DIR; tests call check())
23*6e223505SJim Jagielski# Validates every package in <outdir> against ODF 1.2 as written, against ODF 1.3 and 1.4 with an office:version or
24*6e223505SJim Jagielski# manifest:version of 1.2 or 1.3 rewritten to the target ("13", "14"; any other version stays and is reported), and against
25*6e223505SJim Jagielski# ODF 1.2, 1.3 and 1.4 after removing foreign elements and attributes ("12x", "13x", "14x"; Part 1 2.2.2 B.1; manifest left
26*6e223505SJim Jagielski# as written apart from the version).
272cb6d6f1SJim Jagielskiimport sys, os, re, zipfile, subprocess, json
282cb6d6f1SJim Jagielski
292cb6d6f1SJim Jagielskifrom aootest.checkers import foreign
302cb6d6f1SJim Jagielski
312cb6d6f1SJim JagielskiSCHEMA_FILES = {"12": ("OpenDocument-v1.2-os-schema.rng", "OpenDocument-v1.2-os-manifest-schema.rng"),
322cb6d6f1SJim Jagielski                "13": ("OpenDocument-v1.3-schema.rng", "OpenDocument-v1.3-manifest-schema.rng"),
332cb6d6f1SJim Jagielski                "14": ("OpenDocument-v1.4-schema.rng", "OpenDocument-v1.4-manifest-schema.rng")}
34*6e223505SJim JagielskiVERSIONS = ("12", "14", "12x", "13", "13x", "14x")
352cb6d6f1SJim JagielskiSTREAMS = {"content.xml", "styles.xml", "meta.xml", "settings.xml"}
362cb6d6f1SJim Jagielski# ODF 1.2 Part 1 2.2.1 B.4: a content.xml rooted at MathML is validated against MathML, not ODF
372cb6d6f1SJim JagielskiERR = re.compile(r"^(.*?):(\d+):(\d+): (error|fatal|warning): (.*)$")
382cb6d6f1SJim Jagielski
392cb6d6f1SJim Jagielski
402cb6d6f1SJim Jagielskidef root_is_mathml(text):
412cb6d6f1SJim Jagielski    body = re.sub(r'<\?xml[^>]*\?>|<!DOCTYPE[^>]*>|<!--.*?-->', '', text[:4000], flags=re.S).lstrip()
422cb6d6f1SJim Jagielski    return bool(re.match(r'<(?:[\w.-]+:)?math[\s>/]', body))
432cb6d6f1SJim Jagielski
442cb6d6f1SJim Jagielski
452cb6d6f1SJim Jagielskidef schemas(schema_dir):
462cb6d6f1SJim Jagielski    sch = {v: tuple(os.path.join(schema_dir, f) for f in fs) for v, fs in SCHEMA_FILES.items()}
47*6e223505SJim Jagielski    sch["12x"], sch["13x"], sch["14x"] = sch["12"], sch["13"], sch["14"]
482cb6d6f1SJim Jagielski    return sch
492cb6d6f1SJim Jagielski
502cb6d6f1SJim Jagielski
512cb6d6f1SJim Jagielskidef check(outdir, workdir, jing, schema_dir):
522cb6d6f1SJim Jagielski    """Returns the result that main() writes as <val.json>. `jing` is the command prefix (java, -jar, jar, -i);
532cb6d6f1SJim Jagielski    `workdir` must be empty or absent: the checker writes its working copies there and never deletes anything."""
542cb6d6f1SJim Jagielski    work = os.path.abspath(workdir)
552cb6d6f1SJim Jagielski    if os.path.isdir(work) and os.listdir(work):
562cb6d6f1SJim Jagielski        raise ValueError("validate: workdir is not empty: " + work)
572cb6d6f1SJim Jagielski    SCH = schemas(schema_dir)
582cb6d6f1SJim Jagielski    files = {v: ([], []) for v in VERSIONS}
59*6e223505SJim Jagielski    mixed = {v: foreign.mixed_elements(SCH[v][0]) for v in ("12x", "13x", "14x")}
602cb6d6f1SJim Jagielski    skipped, bad_zip = [], []
612cb6d6f1SJim Jagielski    pkgs = sorted(p for p in os.listdir(outdir) if not p.startswith("."))
622cb6d6f1SJim Jagielski    for pkg in pkgs:
632cb6d6f1SJim Jagielski        try:
642cb6d6f1SJim Jagielski            z = zipfile.ZipFile(os.path.join(outdir, pkg))
652cb6d6f1SJim Jagielski        except Exception:
662cb6d6f1SJim Jagielski            bad_zip.append(pkg); continue
672cb6d6f1SJim Jagielski        for name in z.namelist():
682cb6d6f1SJim Jagielski            is_mani = name == "META-INF/manifest.xml"
692cb6d6f1SJim Jagielski            if name.rsplit("/", 1)[-1] not in STREAMS and not is_mani:
702cb6d6f1SJim Jagielski                continue
712cb6d6f1SJim Jagielski            text = z.read(name).decode("utf-8")
722cb6d6f1SJim Jagielski            if not is_mani and name.endswith("content.xml") and root_is_mathml(text):
732cb6d6f1SJim Jagielski                skipped.append(pkg + "/" + name); continue
742cb6d6f1SJim Jagielski            for v in VERSIONS:
752cb6d6f1SJim Jagielski                t = text
76*6e223505SJim Jagielski                if v in mixed and not is_mani:
772cb6d6f1SJim Jagielski                    t = foreign.strip_foreign(t, mixed[v])
78*6e223505SJim Jagielski                if v in ("13", "13x", "14", "14x"):
792cb6d6f1SJim Jagielski                    attr = "manifest:version" if is_mani else "office:version"
80*6e223505SJim Jagielski                    t = re.sub(attr + '="1\\.[23]"', attr + '="1.%s"' % v[1], t, count=1)
812cb6d6f1SJim Jagielski                p = os.path.join(work, v, pkg, name)
822cb6d6f1SJim Jagielski                os.makedirs(os.path.dirname(p), exist_ok=True)
832cb6d6f1SJim Jagielski                open(p, "w", encoding="utf-8").write(t)
842cb6d6f1SJim Jagielski                files[v][1 if is_mani else 0].append(p)
852cb6d6f1SJim Jagielski    errors = {}
862cb6d6f1SJim Jagielski    for v in VERSIONS:
872cb6d6f1SJim Jagielski        errs = []
882cb6d6f1SJim Jagielski        for schema, fl in ((SCH[v][0], files[v][0]), (SCH[v][1], files[v][1])):
892cb6d6f1SJim Jagielski            while fl:
902cb6d6f1SJim Jagielski                batch = fl[:400]
912cb6d6f1SJim Jagielski                r = subprocess.run(jing + [schema] + batch, capture_output=True, text=True)
922cb6d6f1SJim Jagielski                fl = fl[400:]
932cb6d6f1SJim Jagielski                for line in (r.stdout + r.stderr).splitlines():
942cb6d6f1SJim Jagielski                    m = ERR.match(line)
952cb6d6f1SJim Jagielski                    if m:
962cb6d6f1SJim Jagielski                        errs.append([os.path.relpath(m.group(1), os.path.join(work, v)), int(m.group(2)), int(m.group(3)), m.group(5)])
972cb6d6f1SJim Jagielski                        # a fatal error stops jing: resume with the files after the one that caused it
982cb6d6f1SJim Jagielski                        if m.group(4) == "fatal" and m.group(1) in batch:
992cb6d6f1SJim Jagielski                            fl = batch[batch.index(m.group(1)) + 1:] + fl
1002cb6d6f1SJim Jagielski                    elif line.strip():
1012cb6d6f1SJim Jagielski                        errs.append(["?", 0, 0, line.strip()])
1022cb6d6f1SJim Jagielski        errors[v] = errs
1032cb6d6f1SJim Jagielski    return {"packages": pkgs, "bad_zip": bad_zip, "mathml_skipped": skipped,
1042cb6d6f1SJim Jagielski            "nfiles": len(files["12"][0]) + len(files["12"][1]), "errors": errors}
1052cb6d6f1SJim Jagielski
1062cb6d6f1SJim Jagielski
1072cb6d6f1SJim Jagielskidef main(argv=None):
1082cb6d6f1SJim Jagielski    outdir, valjson = sys.argv[1:3] if argv is None else argv
1092cb6d6f1SJim Jagielski    res = check(outdir, valjson[:-5] + ".xml", ["java", "-jar", os.environ["AOO_JING"], "-i"], os.environ["AOO_SCHEMA_DIR"])
1102cb6d6f1SJim Jagielski    json.dump(res, open(valjson, "w"))
1112cb6d6f1SJim Jagielski    errors, pkgs, skipped, bad_zip = res["errors"], res["packages"], res["mathml_skipped"], res["bad_zip"]
1122cb6d6f1SJim Jagielski    for v in VERSIONS:
1132cb6d6f1SJim Jagielski        print(f"ODF {v[0]}.{v[1]}{' extended, foreign content removed' if v.endswith('x') else ''}: {len(errors[v])} errors in {len({e[0].split('/')[0] for e in errors[v]})} of {len(pkgs)} packages")
1142cb6d6f1SJim Jagielski    print(f"files validated: {res['nfiles']}, MathML skipped: {len(skipped)}, bad zips: {bad_zip}")
1152cb6d6f1SJim Jagielski    unparsed = [e for v in errors for e in errors[v] if e[0] == "?"]
1162cb6d6f1SJim Jagielski    if unparsed:
1172cb6d6f1SJim Jagielski        print("UNPARSED jing output:", *[e[3] for e in unparsed[:5]], sep="\n  ")
1182cb6d6f1SJim Jagielski
1192cb6d6f1SJim Jagielski
1202cb6d6f1SJim Jagielskiif __name__ == "__main__":
1212cb6d6f1SJim Jagielski    main()
122