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
import lxml.etree as ET

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 errpath(filename, 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:
        path += "[@id='%s']" % oid
    elm = elm.getparent()
    while elm is not None:
        klass = elm.attrib.get('class')
        oid = elm.attrib.get('id')
        if oid is not None:
            klass += "[@id='%s']" % oid
        if klass is not None:
            path = klass + '/' + path
        elm = elm.getparent()
    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:
        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:
            name += "'%s' " % elm.attrib['id']
        return name
    return ""

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

    prefix = errpath(filename, elm)
    suppr = '%s %s' % (prefix, msgtype)
    suppr = suppr.encode('ascii', 'ignore')

    if gen_supprfile is not None:
        print(suppr, file=gen_supprfile)

    if suppr in suppressions:
        # Suppressed
        errexists += 1
        return

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

    errors += 1
    msg = "%s ERROR: %s%s" % (prefix, elm_name(elm), msg)
    print(msg.encode('ascii', 'ignore'))


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

    if Wnone:
        return

    prefix = errpath(filename, elm)
    suppr = '%s %s' % (prefix, msgtype)
    suppr = suppr.encode('ascii', 'ignore')

    if gen_supprfile is not None:
        print(suppr, file=gen_supprfile)

    if suppr in suppressions:
        # Suppressed
        if Werror:
            errexists += 1
        else:
            warnexists += 1
        return

    if Werror:
        errors += 1
    else:
        warnings += 1

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

    msg = "%s WARNING: %s%s" % (prefix, elm_name(elm), msg)
    print(msg.encode('ascii', 'ignore'))


def check_objects(filename, elm, objects, target):
    """
    Check that objects contains exactly one object
    """
    length = len(list(objects))
    if length == 0:
        err(filename, elm, "undeclared-target", "uses undeclared target '%s'" % target)
    elif length > 1:
        err(filename, elm, "multiple-target", "several targets are named '%s'" % target)

def check_props(filename, root, props):
    """
    Check the given list of relation properties
    """
    for prop in props:
        objects = root.iterfind(".//object[@id='%s']" % prop.text)
        check_objects(filename, prop, objects, prop.text)

def check_rels(filename, 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, rel, targets, target)

def check_a11y_relation(filename, root):
    """
    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

    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, root, label_for)

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

        member_of = obj.findall("accessibility/relation[@type='member-of']")
        check_rels(filename, 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, root, properties)
            if len(properties) > 1:
                lines = ', '.join([str(p.sourceline) for p in properties])
                err(filename, obj, "multiple-mnemonic", "has too many sub-elements"
                    ", expected single <property name='mnemonic_widgets'>"
                    ": lines %s" % lines)
                continue
            if len(properties) == 1:
                continue
            warn(filename, 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:
                lines = ', '.join([str(c.sourceline) for c in children])
                err(filename, obj, "multiple-accessible", "has too many sub-elements"
                    ", expected single <child internal-child='accessible'>"
                    ": lines %s" % lines)
            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:
                lines = ', '.join([str(l.sourceline) for l in labelfor])
                err(filename, obj, "multiple-label-for", "has too many elements"
                    ", expected single <relation type='label-for' target='%s'>"
                    ": lines %s" % (oid, lines))
                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:
                lines = ', '.join([str(p.sourceline) for p in props])
                warn(filename, obj, "multiple-mnemonic", "has multiple mnemonic_widget:"
                    " lines %s" % (lines))
                continue

        # Check for standard GtkButtons
        if obj.attrib['class'] == "GtkButton":
            labels = obj.findall("property[@name='label']")
            if len(labels) > 1:
                err(filename, 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, obj, "no-labelled-by", "has no accessibility relation")


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_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
    append = 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.decode('ascii').rstrip()
                suppressions.append(prefix)
            supprfile.close()
        except IOError:
            err(suppr, None, "", "unable to read suppression file")

    if gen_suppr is not None:
        gen_supprfile = open(gen_suppr, 'w')

    if not args:
        sys.exit("%s: no input files" % progname)

    for filename in args:
        try:
            tree = ET.parse(filename)
        except ET.ParseError:
            err(filename, None, "", "malformatted xml file")
        except IOError:
            err(filename, None, "", "unable to read file")

        try:
            check_a11y_relation(filename, tree.getroot())
        except Exception as error:
            import traceback
            traceback.print_exc()
            err(filename, None, "", "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)" % errexists
        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)" % warnexists
        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