Web lists-archives.com

Implementing accessibility non-regression check tool




Hello,

In the context of LibreOffice, the Hypra company will work on an
accessibility non-regression check tool.

Basically, the idea is to design a tool which will check .ui files for
accessibility issues: missing relations between widgets and labels,
notably. The tool would just use lxml to parse the files and emit
warnings for the found issues.

Such a tool could be called by application build system so that
developers get the warnings along other compiler warnings, and treated
the same way. It could also be used in Continuous Integration reports.

Of course, there are a lot of existing issues in applications, so
we plan to add support for suppression rules, so that when the
tool invocation is integrated, one can integrate an initial set of
suppression rules which allows to start with a zero-warning state,
and then for a start developers will try to stay without warning, and
progressively fix existing issues and their corresponding suppression
rules.

One of the remaining questions we have (it's not blocking for our
immediate development, though) is whether this tool should be integrated
within LibreOffice, or within gtk. The latter would both allow more
widespread use of the tool by other projects, and make the maintenance
happen there, thus less work for LibreOffice :)

We would like to provide it with a licence which is as permissive as
possible, so that projects can easily integrate it, so would it be
possible to keep it BSD-licenced inside gtk? Otherwise we'll probably
open e.g. a github project just for hosting a BSD version, from which
we will push updates to gtk and libreoffice (and any other project for
which LGPL is problematic).

I have attached our current version. It's not really finished or
anything, it's just for showing what it could look like eventually.

Samuel
#!/usr/bin/env python
#
# Copyright (c) 2018 Martin Pieuchot
# Copyright (c) 2018 Samuel Thibault <sthibault@xxxxxxxx>
#
# 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:
    import xml.etree.ElementTree as ET
    lxml = False


widgets_ignored = [
    'GtkFrame',
    'GtkWindow',
    'GtkScrolledWindow',
    'GtkDialog',
    'GtkMessageDialog',
    'GtkNotebook',

    # a lot of false positives there
    'GtkButton',

    # Invisible actions
    'GtkAlignment',
    'GtkAdjustment',
    'GtkBox',
    'GtkVBox',
    'GtkHBox',
    'GtkButtonBox',
    'GtkGrid',
    'GtkSizeGroup',
    'GtkSeparator',
    'GtkExpander',
    'GtkActionGroup',
    'GtkViewport',
    'GtkPaned',
    'GtkCellRendererText',

    'sfxlo-PriorityHBox',
    'sfxlo-PriorityMergedHBox',
    'sfxlo-ContextVBox',

    'GtkScrollbar',
    'GtkListBox',
    'GtkStatusbar',

    # Storage objects
    'GtkListStore',
    'GtkTextBuffer',
    'GtkTreeSelection',

    'svtlo-ValueSet',

    # Menus are fine
    'GtkMenu',
    'GtkMenuItem',
    'GtkRadioMenuItem',
    'GtkSeparatorMenuItem',
    'GtkCheckMenuItem',

    # Toolbars are fine
    'GtkToolbar',
    'GtkSeparatorToolItem',
    'GtkToggleToolButton',
    'GtkRadioToolButton',
    'GtkMenuToolButton',
    'GtkToolButton',
    'GtkToolItem',

    'sfxlo-NotebookbarToolBox',
    'svtlo-ManagedMenuButton',
    'vcllo-SmallButton',
    'sfxlo-NotebookbarTabControl',
    'sfxlo-DropdownBox',
    'sfxlo-OptionalBox',

    'AtkObject',
]

# To include for LO for sure:
# svxcorelo-SvxColorListBox
# svxcorelo-SvxLanguageBox
# foruilo-RefButton
# sfxlo-SvxCharView
# sfxlo-SidebarToolBox
# foruilo-RefEdit

# svtlo-SvSimpleTableContainer ?

# svxcorelo-SvxCheckListBox ?
# svtlo-SvTreeListBox ?

standard_gtkbuttons = [
    'gtk-ok',
    'gtk-cancel',
    'gtk-help',
    'gtk-close',
    'gtk-revert-to-saved',
]

progname = os.path.basename(sys.argv[0])
suppressions = {}
gen_supprfile = 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 errstr(elm):
    """
    Return the line number of the element
    """

    return str(elm.sourceline)

def elm_prefix(filename, elm):
    """
    Return the display prefix of the element
    """
    if elm == None or not lxml:
        return "%s:" % filename
    else:
        return "%s:%s" % (filename, errstr(elm))

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, pflag
    prefix = errpath(filename, tree, elm)
    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)

    if not pflag:
        # Use user-friendly line numbers
        prefix = elm_prefix(filename, elm)

    return (prefix, suppr)

def err(filename, tree, elm, msgtype, msg):
    """
    Emit an error for an element
    """
    global errors, errexists, pflag

    (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)


def warn(filename, tree, elm, msgtype, msg):
    """
    Emit a warning for an element
    """
    global Werror, Wnone, errors, errexists, warnings, warnexists, pflag

    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)


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, props):
    """
    Check the given list of relation properties
    """
    for prop in props:
        objects = root.iterfind(".//object[@id='%s']" % prop.text)
        check_objects(filename, tree, prop, objects, prop.text)

def check_rels(filename, tree, root, 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, rel, 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.
    """
    global widgets_ignored
    root = tree.getroot()

    for obj in root.iter('object'):
        if obj.attrib['class'] in widgets_ignored:
            continue

        label_for = obj.findall("accessibility/relation[@type='label-for']")
        check_rels(filename, tree, root, label_for)

        labelled_by = obj.findall("accessibility/relation[@type='labelled-by']")
        check_rels(filename, tree, root, labelled_by)

        member_of = obj.findall("accessibility/relation[@type='member-of']")
        check_rels(filename, tree, root, 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, properties)
            if len(properties) > 1:
                err(filename, tree, obj, "multiple-mnemonic", "has too many sub-elements"
                    ", expected single <property name='mnemonic_widgets'>"
                    "%s" % elm_lines(properties))
                continue
            if len(properties) == 1:
                continue
            warn(filename, tree, obj, "no-label-for", "missing sub-element"
                 ", expected single <property name='mnemonic_widgets'> or "
                 "one or more <relation type='label-for'>")
            continue

        # Not a label

        # 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" % elm_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

        # TODO: check with orca
        ## has an <accessibility> sub-element with a "member-of"
        ## <relation> pointing to an existing element.
        #if len(member_of) > 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]
            if len(props) == 1:
                continue
            if len(props) > 1:
                warn(filename, tree, obj, "multiple-mnemonic", "has multiple mnemonic_widget:"
                    " lines %s" % elms_lines(props))
                continue

        # Check for standard GtkButtons
        if obj.attrib['class'] == "GtkButton":
            labels = obj.findall("property[@name='label']")
            if len(labels) > 1:
                err(filename, tree, obj, "multiple-label", "has multiple label properties")
            if len(labels) == 1:
                # Has a <property name="label">
                if labels[0].text in standard_gtkbuttons:
                    # And it's a standard button
                    continue

        warn(filename, tree, obj, "no-labelled-by", "has no accessibility label")


def usage():
    print("%s [-W error|none] [-p] [-g SUPPR_FILE] [-s SUPPR_FILE] [-i WIDGET1,WIDGET2[,...]] [file ...]" % 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("  -i Ignore warnings for widgets of a given class");
    sys.exit(2)


def main():
    global pflag, Werror, Wnone, gen_suppr, gen_supprfile, suppressions, errors, widgets_ignored

    try:
        opts, args = getopt.getopt(sys.argv[1:], "W:piIg:s:")
    except getopt.GetoptError:
        usage()

    gen_suppr = None
    suppr = None
    ignore = False
    widgets = []

    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 == "-i":
            widgets = a.split(',')
        elif o == "-I":
            ignore = True
        elif o == "-g":
            gen_suppr = a
        elif o == "-s":
            suppr = a

    if ignore and widgets:
        usage()

    if ignore:
        widgets_ignored = []
    elif widgets:
        widgets_ignored.extend(widgets)

    # 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

    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 errors > 0:
        sys.exit(1)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        pass
_______________________________________________
gtk-devel-list mailing list
gtk-devel-list@xxxxxxxxx
https://mail.gnome.org/mailman/listinfo/gtk-devel-list