0a3151314a
python2.6's internal xml parser is not enough for gla11y, but it can run python-lxml fine. Change-Id: I725471729c8e2f25ce60f1d92e08d87a96e171a5 Reviewed-on: https://gerrit.libreoffice.org/50686 Tested-by: Jenkins <ci@libreoffice.org> Reviewed-by: Thorsten Behrens <Thorsten.Behrens@CIB.de>
441 lines
14 KiB
Python
Executable file
441 lines
14 KiB
Python
Executable file
#!/usr/bin/env python
|
|
# -*- tab-width: 4; indent-tabs-mode: nil; py-indent-offset: 4 -*-
|
|
#
|
|
# This file is part of the LibreOffice project.
|
|
#
|
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
#
|
|
# This file incorporates work covered by the following license notice:
|
|
#
|
|
# Copyright (c) 2018 Martin Pieuchot
|
|
# Copyright (c) 2018 Samuel Thibault <sthibault@hypra.fr>
|
|
#
|
|
# Permission to use, copy, modify, and distribute this software for any
|
|
# purpose with or without fee is hereby granted, provided that the above
|
|
# copyright notice and this permission notice appear in all copies.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
|
|
# Take LibreOffice (glade) .ui files and check for non accessible widgets
|
|
|
|
from __future__ import print_function
|
|
|
|
import os
|
|
import sys
|
|
import getopt
|
|
try:
|
|
import lxml.etree as ET
|
|
lxml = True
|
|
except ImportError:
|
|
if sys.version_info < (2,7):
|
|
exit()
|
|
import xml.etree.ElementTree as ET
|
|
lxml = False
|
|
|
|
progname = os.path.basename(sys.argv[0])
|
|
suppressions = {}
|
|
gen_suppr = None
|
|
gen_supprfile = None
|
|
suppr_prefix = ""
|
|
outfile = None
|
|
pflag = False
|
|
Werror = False
|
|
Wnone = False
|
|
errors = 0
|
|
errexists = 0
|
|
warnings = 0
|
|
warnexists = 0
|
|
|
|
def step_elm(elm):
|
|
"""
|
|
Return the XML class path step corresponding to elm.
|
|
This can be empty if the elm does not have any class or id.
|
|
"""
|
|
step = elm.attrib.get('class')
|
|
if step is None:
|
|
step = ""
|
|
oid = elm.attrib.get('id')
|
|
if oid is not None:
|
|
oid = oid.encode('ascii','ignore').decode('ascii')
|
|
step += "[@id='%s']" % oid
|
|
if len(step) > 0:
|
|
step += '/'
|
|
return step
|
|
|
|
def find_elm(root, elm):
|
|
"""
|
|
Return the XML class path of the element from the given root.
|
|
This is the slow version used when getparent is not available.
|
|
"""
|
|
if root == elm:
|
|
return ""
|
|
for o in root:
|
|
path = find_elm(o, elm)
|
|
if path is not None:
|
|
step = step_elm(o)
|
|
return step + path
|
|
return None
|
|
|
|
def errpath(filename, tree, elm):
|
|
"""
|
|
Return the XML class path of the element
|
|
"""
|
|
if elm is None:
|
|
return ""
|
|
path = ""
|
|
if 'class' in elm.attrib:
|
|
path += elm.attrib['class']
|
|
oid = elm.attrib.get('id')
|
|
if oid is not None:
|
|
oid = oid.encode('ascii','ignore').decode('ascii')
|
|
path += "[@id='%s']" % oid
|
|
if lxml:
|
|
elm = elm.getparent()
|
|
while elm is not None:
|
|
step = step_elm(elm)
|
|
path = step + path
|
|
elm = elm.getparent()
|
|
else:
|
|
path = find_elm(tree.getroot(), elm)[:-1]
|
|
path = filename + ':' + path
|
|
return path
|
|
|
|
def elm_prefix(filename, elm):
|
|
"""
|
|
Return the display prefix of the element
|
|
"""
|
|
if elm == None or not lxml:
|
|
return "%s:" % filename
|
|
else:
|
|
return "%s:%u" % (filename, elm.sourceline)
|
|
|
|
def elm_name(elm):
|
|
"""
|
|
Return a display name of the element
|
|
"""
|
|
if elm is not None:
|
|
name = ""
|
|
if 'class' in elm.attrib:
|
|
name = "'%s' " % elm.attrib['class']
|
|
if 'id' in elm.attrib:
|
|
id = elm.attrib['id'].encode('ascii','ignore').decode('ascii')
|
|
name += "'%s' " % id
|
|
return name
|
|
return ""
|
|
|
|
def elm_suppr(filename, tree, elm, msgtype):
|
|
"""
|
|
Return the prefix to be displayed to the user and the suppression line for
|
|
the warning type "msgtype" for element "elm"
|
|
"""
|
|
global gen_suppr, gen_supprfile, suppr_prefix, pflag
|
|
|
|
if suppressions or gen_suppr is not None or pflag:
|
|
prefix = errpath(filename, tree, elm)
|
|
if prefix[0:len(suppr_prefix)] == suppr_prefix:
|
|
prefix = prefix[len(suppr_prefix):]
|
|
|
|
if suppressions or gen_suppr is not None:
|
|
suppr = '%s %s' % (prefix, msgtype)
|
|
|
|
if gen_suppr is not None and msgtype is not None:
|
|
if gen_supprfile is None:
|
|
gen_supprfile = open(gen_suppr, 'w')
|
|
print(suppr, file=gen_supprfile)
|
|
else:
|
|
suppr = None
|
|
|
|
if not pflag:
|
|
# Use user-friendly line numbers
|
|
prefix = elm_prefix(filename, elm)
|
|
if prefix[0:len(suppr_prefix)] == suppr_prefix:
|
|
prefix = prefix[len(suppr_prefix):]
|
|
|
|
return (prefix, suppr)
|
|
|
|
def err(filename, tree, elm, msgtype, msg):
|
|
"""
|
|
Emit an error for an element
|
|
"""
|
|
global errors, errexists
|
|
|
|
(prefix, suppr) = elm_suppr(filename, tree, elm, msgtype)
|
|
|
|
if suppr in suppressions:
|
|
# Suppressed
|
|
errexists += 1
|
|
return
|
|
|
|
errors += 1
|
|
msg = "%s ERROR: %s%s" % (prefix, elm_name(elm), msg)
|
|
print(msg)
|
|
if outfile is not None:
|
|
print(msg, file=outfile)
|
|
|
|
|
|
def warn(filename, tree, elm, msgtype, msg):
|
|
"""
|
|
Emit a warning for an element
|
|
"""
|
|
global Werror, Wnone, errors, errexists, warnings, warnexists
|
|
|
|
if Wnone:
|
|
return
|
|
|
|
(prefix, suppr) = elm_suppr(filename, tree, elm, msgtype)
|
|
if suppr in suppressions:
|
|
# Suppressed
|
|
if Werror:
|
|
errexists += 1
|
|
else:
|
|
warnexists += 1
|
|
return
|
|
|
|
if Werror:
|
|
errors += 1
|
|
else:
|
|
warnings += 1
|
|
|
|
msg = "%s WARNING: %s%s" % (prefix, elm_name(elm), msg)
|
|
print(msg)
|
|
if outfile is not None:
|
|
print(msg, file=outfile)
|
|
|
|
|
|
def check_objects(filename, tree, elm, objects, target):
|
|
"""
|
|
Check that objects contains exactly one object
|
|
"""
|
|
length = len(list(objects))
|
|
if length == 0:
|
|
err(filename, tree, elm, "undeclared-target", "uses undeclared target '%s'" % target)
|
|
elif length > 1:
|
|
err(filename, tree, elm, "multiple-target", "several targets are named '%s'" % target)
|
|
|
|
def check_props(filename, tree, root, elm, props):
|
|
"""
|
|
Check the given list of relation properties
|
|
"""
|
|
for prop in props:
|
|
objects = root.iterfind(".//object[@id='%s']" % prop.text)
|
|
check_objects(filename, tree, elm, objects, prop.text)
|
|
|
|
def check_rels(filename, tree, root, elm, rels):
|
|
"""
|
|
Check the given list of relations
|
|
"""
|
|
for rel in rels:
|
|
target = rel.attrib['target']
|
|
targets = root.iterfind(".//object[@id='%s']" % target)
|
|
check_objects(filename, tree, elm, targets, target)
|
|
|
|
def elms_lines(elms):
|
|
"""
|
|
Return the list of lines for the given elements.
|
|
"""
|
|
if lxml:
|
|
return ": lines " + ', '.join([str(l.sourceline) for l in elms])
|
|
else:
|
|
return ""
|
|
|
|
def check_a11y_relation(filename, tree):
|
|
"""
|
|
Emit an error message if any of the 'object' elements of the XML
|
|
document represented by `root' doesn't comply with Accessibility
|
|
rules.
|
|
"""
|
|
root = tree.getroot()
|
|
|
|
for obj in root.iter('object'):
|
|
|
|
label_for = obj.findall("accessibility/relation[@type='label-for']")
|
|
check_rels(filename, tree, root, obj, label_for)
|
|
|
|
labelled_by = obj.findall("accessibility/relation[@type='labelled-by']")
|
|
check_rels(filename, tree, root, obj, labelled_by)
|
|
|
|
member_of = obj.findall("accessibility/relation[@type='member-of']")
|
|
check_rels(filename, tree, root, obj, member_of)
|
|
|
|
if obj.attrib['class'] == 'GtkLabel':
|
|
# Case 0: A 'GtkLabel' must contain one or more "label-for"
|
|
# pointing to existing elements or...
|
|
if len(label_for) > 0:
|
|
continue
|
|
|
|
# ...a single "mnemonic_widget"
|
|
properties = obj.findall("property[@name='mnemonic_widget']")
|
|
check_props(filename, tree, root, obj, properties)
|
|
if len(properties) > 1:
|
|
# It does not make sense for a label to be a mnemonic for
|
|
# several actions.
|
|
err(filename, tree, obj, "multiple-mnemonic", "has too many sub-elements"
|
|
", expected single <property name='mnemonic_widgets'>"
|
|
"%s" % elms_lines(properties))
|
|
continue
|
|
if len(properties) == 1:
|
|
continue
|
|
# TODO: warn that it is a label for nothing
|
|
continue
|
|
|
|
# Case 1: has a <child internal-child="accessible"> sub-element
|
|
children = obj.findall("child[@internal-child='accessible']")
|
|
if children:
|
|
if len(children) > 1:
|
|
err(filename, tree, obj, "multiple-accessible", "has too many sub-elements"
|
|
", expected single <child internal-child='accessible'>"
|
|
"%s" % elms_lines(children))
|
|
continue
|
|
|
|
# Case 2: has an <accessibility> sub-element with a "labelled-by"
|
|
# <relation> pointing to an existing element.
|
|
if len(labelled_by) > 0:
|
|
continue
|
|
|
|
# Case 3/4: has an ID...
|
|
oid = obj.attrib.get('id')
|
|
if oid is not None:
|
|
# ...referenced by a single "label-for" <relation>
|
|
rels = root.iterfind(".//relation[@target='%s']" % oid)
|
|
labelfor = [r for r in rels if r.attrib.get('type') == 'label-for']
|
|
if len(labelfor) == 1:
|
|
continue
|
|
if len(labelfor) > 1:
|
|
err(filename, tree, obj, "multiple-label-for", "has too many elements"
|
|
", expected single <relation type='label-for' target='%s'>"
|
|
"%s" % (oid, elm_lines(labelfor)))
|
|
continue
|
|
|
|
# ...referenced by a single "mnemonic_widget"
|
|
props = root.iterfind(".//property[@name='mnemonic_widget']")
|
|
props = [p for p in props if p.text == oid]
|
|
# TODO: warn when more than one.
|
|
if len(props) >= 1:
|
|
continue
|
|
|
|
# TODO: after a few more checks and false-positives filtering, warn
|
|
# that this does not have a label
|
|
if obj.attrib['class'] == "GtkScale":
|
|
# GtkScale definitely needs a context
|
|
err(filename, tree, obj, "no-labelled-by", "has no accessibility label")
|
|
|
|
|
|
def usage():
|
|
print("%s [-W error|none] [-p] [-g SUPPR_FILE] [-s SUPPR_FILE] [-P SUPPR_PREFIX] [-o LOG_FILE] [file ... | -L filelist]" % progname,
|
|
file=sys.stderr)
|
|
print(" -p print XML class path instead of line number");
|
|
print(" -g Generate suppression file SUPPR_FILE");
|
|
print(" -s Suppress warnings given by file SUPPR_FILE");
|
|
print(" -P Suppress SUPPR_PREFIX from emitted warnings, e.g. absolute source directory");
|
|
print(" -o Also prints errors and warnings to given file");
|
|
sys.exit(2)
|
|
|
|
|
|
def main():
|
|
global pflag, Werror, Wnone, gen_suppr, gen_supprfile, suppressions, suppr_prefix, errors, outfile
|
|
|
|
try:
|
|
opts, args = getopt.getopt(sys.argv[1:], "W:pg:s:P:o:L:")
|
|
except getopt.GetoptError:
|
|
usage()
|
|
|
|
suppr = None
|
|
out = None
|
|
filelist = None
|
|
for o, a in opts:
|
|
if o == "-W":
|
|
if a == "error":
|
|
Werror = True
|
|
elif a == "none":
|
|
Wnone = True
|
|
elif o == "-p":
|
|
pflag = True
|
|
elif o == "-g":
|
|
gen_suppr = a
|
|
elif o == "-s":
|
|
suppr = a
|
|
elif o == "-P":
|
|
suppr_prefix = a
|
|
elif o == "-o":
|
|
out = a
|
|
elif o == "-L":
|
|
filelist = a
|
|
|
|
# Read suppression file before overwriting it
|
|
if suppr is not None:
|
|
try:
|
|
supprfile = open(suppr, 'r')
|
|
for line in supprfile.readlines():
|
|
prefix = line.rstrip()
|
|
suppressions[prefix] = True
|
|
supprfile.close()
|
|
except IOError:
|
|
pass
|
|
|
|
if out is not None:
|
|
outfile = open(out, 'w')
|
|
|
|
if filelist is not None:
|
|
try:
|
|
filelistfile = open(filelist, 'r')
|
|
for line in filelistfile.readlines():
|
|
line = line.strip()
|
|
if line:
|
|
args += line.split(' ')
|
|
filelistfile.close()
|
|
except IOError:
|
|
err(filelist, None, None, "unable to read file list file")
|
|
|
|
for filename in args:
|
|
try:
|
|
tree = ET.parse(filename)
|
|
except ET.ParseError:
|
|
err(filename, None, None, "parse", "malformatted xml file")
|
|
continue
|
|
except IOError:
|
|
err(filename, None, None, None, "unable to read file")
|
|
continue
|
|
|
|
try:
|
|
check_a11y_relation(filename, tree)
|
|
except Exception as error:
|
|
import traceback
|
|
traceback.print_exc()
|
|
err(filename, None, None, "parse", "error parsing file")
|
|
|
|
if errors > 0 or errexists > 0:
|
|
estr = "%s new error%s" % (errors, 's' if errors > 1 else '')
|
|
if errexists > 0:
|
|
estr += " (%s suppressed by %s)" % (errexists, suppr)
|
|
print(estr)
|
|
|
|
if warnings > 0 or warnexists > 0:
|
|
wstr = "%s new warning%s" % (warnings,
|
|
's' if warnings > 1 else '')
|
|
if warnexists > 0:
|
|
wstr += " (%s suppressed by %s)" % (warnexists, suppr)
|
|
print(wstr)
|
|
|
|
if gen_supprfile is not None:
|
|
gen_supprfile.close()
|
|
if outfile is not None:
|
|
outfile.close()
|
|
if errors > 0 and gen_suppr is None:
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
|
|
# vim: set shiftwidth=4 softtabstop=4 expandtab:
|