configure already sets TMPDIR to mixed path
[LibreOffice.git] / bin / gla11y
blob1f4bea984a88279ae0ec501e558dce79d4cf55de
1 #!/usr/bin/env python
2 # -*- tab-width: 4; indent-tabs-mode: nil; py-indent-offset: 4 -*-
4 # This file is part of the LibreOffice project.
6 # This Source Code Form is subject to the terms of the Mozilla Public
7 # License, v. 2.0. If a copy of the MPL was not distributed with this
8 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
10 # This file incorporates work covered by the following license notice:
12 # Copyright (c) 2018 Martin Pieuchot
13 # Copyright (c) 2018-2020 Samuel Thibault <sthibault@hypra.fr>
15 # Permission to use, copy, modify, and distribute this software for any
16 # purpose with or without fee is hereby granted, provided that the above
17 # copyright notice and this permission notice appear in all copies.
19 # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
20 # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
21 # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
22 # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
23 # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
24 # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
25 # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
27 # Take LibreOffice (glade) .ui files and check for non accessible widgets
29 # A white paper documents the rationale of the implementation:
31 # https://inria.hal.science/hal-02957129
33 from __future__ import print_function
35 import os
36 import sys
37 import getopt
38 try:
39 import lxml.etree as ET
40 lxml = True
41 except ImportError:
42 if sys.version_info < (2,7):
43 print("gla11y needs lxml or python >= 2.7")
44 exit()
45 import xml.etree.ElementTree as ET
46 lxml = False
48 howto_url = "https://wiki.documentfoundation.org/Development/Accessibility"
50 # Toplevel widgets
51 widgets_toplevel = [
52 'GtkWindow',
53 'GtkOffscreenWindow',
54 'GtkApplicationWindow',
55 'GtkDialog',
56 'GtkFileChooserDialog',
57 'GtkColorChooserDialog',
58 'GtkFontChooserDialog',
59 'GtkMessageDialog',
60 'GtkRecentChooserDialog',
61 'GtkAssistant',
62 'GtkAppChooserDialog',
63 'GtkPrintUnixDialog',
64 'GtkShortcutsWindow',
67 widgets_ignored = widgets_toplevel + [
68 # Containers
69 'GtkBox',
70 'GtkGrid',
71 'GtkNotebook',
72 'GtkFrame',
73 'GtkAspectFrame',
74 'GtkListBox',
75 'GtkFlowBox',
76 'GtkOverlay',
77 'GtkMenuBar',
78 'GtkToolbar',
79 'GtkToolpalette',
80 'GtkPaned',
81 'GtkHPaned',
82 'GtkVPaned',
83 'GtkButtonBox',
84 'GtkHButtonBox',
85 'GtkVButtonBox',
86 'GtkLayout',
87 'GtkFixed',
88 'GtkEventBox',
89 'GtkExpander',
90 'GtkViewport',
91 'GtkScrolledWindow',
92 'GtkRevealer',
93 'GtkSearchBar',
94 'GtkHeaderBar',
95 'GtkStack',
96 'GtkPopover',
97 'GtkPopoverMenu',
98 'GtkActionBar',
99 'GtkHandleBox',
100 'GtkShortcutsSection',
101 'GtkShortcutsGroup',
102 'GtkTable',
104 'GtkVBox',
105 'GtkHBox',
106 'GtkToolItem',
107 'GtkMenu',
109 # Invisible actions
110 'GtkSeparator',
111 'GtkHSeparator',
112 'GtkVSeparator',
113 'GtkAction',
114 'GtkToggleAction',
115 'GtkActionGroup',
116 'GtkCellRendererGraph',
117 'GtkCellRendererPixbuf',
118 'GtkCellRendererProgress',
119 'GtkCellRendererSpin',
120 'GtkCellRendererText',
121 'GtkCellRendererToggle',
122 'GtkSeparatorMenuItem',
123 'GtkSeparatorToolItem',
125 # Storage objects
126 'GtkListStore',
127 'GtkTreeStore',
128 'GtkTreeModelFilter',
129 'GtkTreeModelSort',
131 'GtkEntryBuffer',
132 'GtkTextBuffer',
133 'GtkTextTag',
134 'GtkTextTagTable',
136 'GtkSizeGroup',
137 'GtkWindowGroup',
138 'GtkAccelGroup',
139 'GtkAdjustment',
140 'GtkEntryCompletion',
141 'GtkIconFactory',
142 'GtkStatusIcon',
143 'GtkFileFilter',
144 'GtkRecentFilter',
145 'GtkRecentManager',
146 'GThemedIcon',
148 'GtkTreeSelection',
150 'GtkListBoxRow',
151 'GtkTreeViewColumn',
153 # Useless to label
154 'GtkScrollbar',
155 'GtkHScrollbar',
156 'GtkStatusbar',
157 'GtkInfoBar',
159 # These are actually labels
160 'GtkLinkButton',
162 # This precisely give a11y information :)
163 'AtkObject',
166 widgets_suffixignored = [
169 # These widgets always need a label
170 widgets_needlabel = [
171 'GtkEntry',
172 'GtkSearchEntry',
173 'GtkScale',
174 'GtkHScale',
175 'GtkVScale',
176 'GtkSpinButton',
177 'GtkSwitch',
180 # These widgets normally have their own label
181 widgets_buttons = [
182 'GtkButton',
183 'GtkToolButton',
184 'GtkToggleButton',
185 'GtkToggleToolButton',
186 'GtkRadioButton',
187 'GtkRadioToolButton',
188 'GtkCheckButton',
189 'GtkModelButton',
190 'GtkLockButton',
191 'GtkColorButton',
192 'GtkMenuButton',
194 'GtkMenuItem',
195 'GtkImageMenuItem',
196 'GtkMenuToolButton',
197 'GtkRadioMenuItem',
198 'GtkCheckMenuItem',
201 # These widgets are labels that can label other widgets
202 widgets_labels = [
203 'GtkLabel',
204 'GtkAccelLabel',
207 # The rest should probably be labelled if there are orphan labels
209 # GtkSpinner
210 # GtkProgressBar
211 # GtkLevelBar
213 # GtkComboBox
214 # GtkComboBoxText
215 # GtkFileChooserButton
216 # GtkAppChooserButton
217 # GtkFontButton
218 # GtkCalendar
219 # GtkColorChooserWidget
221 # GtkCellView
222 # GtkTreeView
223 # GtkTextView
224 # GtkIconView
226 # GtkImage
227 # GtkArrow
228 # GtkDrawingArea
230 # GtkScaleButton
231 # GtkVolumeButton
234 # TODO:
235 # GtkColorPlane ?
236 # GtkColorScale ?
237 # GtkColorSwatch ?
238 # GtkFileChooserWidget ?
239 # GtkFishbowl ?
240 # GtkFontChooserWidget ?
241 # GtkIcon ?
242 # GtkInspector* ?
243 # GtkMagnifier ?
244 # GtkPathBar ?
245 # GtkPlacesSidebar ?
246 # GtkPlacesView ?
247 # GtkPrinterOptionWidget ?
248 # GtkStackCombo ?
249 # GtkStackSidebar ?
250 # GtkStackSwitcher ?
252 progname = os.path.basename(sys.argv[0])
254 # This dictionary contains the set of suppression lines as read from the
255 # suppression file(s). It is merely indexed by the text of the suppression line
256 # and contains whether the suppressions was unused.
257 suppressions = {}
259 # This dictionary is indexed like suppressions and returns a "file:line" string
260 # to report where in the suppression file the suppression was read
261 suppressions_to_line = {}
263 # This dictionary is similar to the suppressions dictionary, but for false
264 # positives rather than suppressions
265 false_positives = {}
267 # This dictionary is indexed by the xml id and returns the element object.
268 ids = {}
269 # This dictionary is indexed by the xml id and returns whether several objects
270 # have the same id.
271 ids_dup = {}
273 # This dictionary is indexed by the xml id of an element A and returns the list
274 # of objects which are labelled-by A.
275 labelled_by_elm = {}
277 # This dictionary is indexed by the xml id of an element A and returns the list
278 # of objects which are label-for A.
279 label_for_elm = {}
281 # This dictionary is indexed by the xml id of an element A and returns the list
282 # of objects which have a mnemonic-for A.
283 mnemonic_for_elm = {}
285 # Possibly a file name to put generated suppression lines in
286 gen_suppr = None
287 # The corresponding opened file
288 gen_supprfile = None
289 # A prefix to remove from file names in the generated suppression lines
290 suppr_prefix = ""
292 # Possibly an opened file in which our output should also be written to.
293 outfile = None
295 # Whether -p option was set, i.e. print XML class path instead of line number in
296 # the output
297 pflag = False
299 # Whether we should warn about labels which are orphan
300 warn_orphan_labels = True
302 # Number of errors
303 errors = 0
304 # Number of suppressed errors
305 errexists = 0
306 # Number of warnings
307 warnings = 0
308 # Number of suppressed warnings
309 warnexists = 0
310 # Number of fatal errors
311 fatals = 0
312 # Number of suppressed fatal errors
313 fatalexists = 0
315 # List of warnings and errors which are fatal
317 # Format of each element: (enabled, type, class)
318 # See the is_enabled function: the list is traversed completely, each element
319 # can specify whether it enables or disables the warning, possibly the type of
320 # warning to be enabled/disabled, possibly the class of XML element for which it
321 # should be enabled.
323 # This mechanism matches the semantic of the parameters on the command line,
324 # each of which refining the semantic set by the previous parameters
325 dofatals = [ ]
327 # List of warnings and errors which are enabled
328 # Same format as dofatals
329 enables = [ ]
331 # buffers all printed output, so it isn't split in parallel builds
332 output_buffer = ""
335 # XML browsing and printing functions
338 def elm_parent(root, elm):
340 Return the parent of the element.
342 if lxml:
343 return elm.getparent()
344 else:
345 def find_parent(cur, elm):
346 for o in cur:
347 if o == elm:
348 return cur
349 parent = find_parent(o, elm)
350 if parent is not None:
351 return parent
352 return None
353 return find_parent(root, elm)
355 def step_elm(elm):
357 Return the XML class path step corresponding to elm.
358 This can be empty if the elm does not have any class or id.
360 step = elm.attrib.get('class')
361 if step is None:
362 step = ""
363 oid = elm.attrib.get('id')
364 if oid is not None:
365 oid = oid.encode('ascii','ignore').decode('ascii')
366 step += "[@id='%s']" % oid
367 if len(step) > 0:
368 step += '/'
369 return step
371 def find_elm(root, elm):
373 Return the XML class path of the element from the given root.
374 This is the slow version used when getparent is not available.
376 if root == elm:
377 return ""
378 for o in root:
379 path = find_elm(o, elm)
380 if path is not None:
381 step = step_elm(o)
382 return step + path
383 return None
385 def errpath(filename, tree, elm):
387 Return the XML class path of the element
389 if elm is None:
390 return ""
391 path = ""
392 if 'class' in elm.attrib:
393 path += elm.attrib['class']
394 oid = elm.attrib.get('id')
395 if oid is not None:
396 oid = oid.encode('ascii','ignore').decode('ascii')
397 path = "//" + path + "[@id='%s']" % oid
398 else:
399 if lxml:
400 elm = elm.getparent()
401 while elm is not None:
402 step = step_elm(elm)
403 path = step + path
404 elm = elm.getparent()
405 else:
406 path = find_elm(tree.getroot(), elm)[:-1]
407 path = filename + ':' + path
408 return path
411 # Warning/Error printing functions
414 def elm_prefix(filename, elm):
416 Return the display prefix of the element
418 if elm == None or not lxml:
419 return "%s:" % filename
420 else:
421 return "%s:%u" % (filename, elm.sourceline)
423 def elm_name(elm):
425 Return a display name of the element
427 if elm is not None:
428 name = ""
429 if 'class' in elm.attrib:
430 name = "'%s' " % elm.attrib['class']
431 if 'id' in elm.attrib:
432 id = elm.attrib['id'].encode('ascii','ignore').decode('ascii')
433 name += "'%s' " % id
434 if not name:
435 name = "'" + elm.tag + "'"
436 if lxml:
437 name += " line " + str(elm.sourceline)
438 return name
439 return ""
441 def elm_name_line(elm):
443 Return a display name of the element with line number
445 if elm is not None:
446 name = elm_name(elm)
447 if lxml and " line " not in name:
448 name += "line " + str(elm.sourceline) + " "
449 return name
450 return ""
452 def elm_line(elm):
454 Return the line for the given element.
456 if lxml:
457 return " line " + str(elm.sourceline)
458 else:
459 return ""
461 def elms_lines(elms):
463 Return the list of lines for the given elements.
465 if lxml:
466 return " lines " + ', '.join([str(l.sourceline) for l in elms])
467 else:
468 return ""
470 def elms_names_lines(elms):
472 Return the list of names and lines for the given elements.
474 return ', '.join([elm_name_line(elm) for elm in elms])
476 def elm_suppr(filename, tree, elm, msgtype, dogen):
478 Return the prefix to be displayed to the user and the suppression line for
479 the warning type "msgtype" for element "elm"
481 global gen_suppr, gen_supprfile, suppr_prefix, pflag
483 if suppressions or false_positives or gen_suppr is not None or pflag:
484 prefix = errpath(filename, tree, elm)
485 if prefix[0:len(suppr_prefix)] == suppr_prefix:
486 prefix = prefix[len(suppr_prefix):]
488 if suppressions or false_positives or gen_suppr is not None:
489 suppr = '%s %s' % (prefix, msgtype)
491 if gen_suppr is not None and msgtype is not None and dogen:
492 if gen_supprfile is None:
493 gen_supprfile = open(gen_suppr, 'w')
494 print(suppr, file=gen_supprfile)
495 else:
496 suppr = None
498 if not pflag:
499 # Use user-friendly line numbers
500 prefix = elm_prefix(filename, elm)
501 if prefix[0:len(suppr_prefix)] == suppr_prefix:
502 prefix = prefix[len(suppr_prefix):]
504 return (prefix, suppr)
506 def is_enabled(elm, msgtype, l, default):
508 Test whether warning type msgtype is enabled for elm in l
510 enabled = default
511 for (enable, thetype, klass) in l:
512 # Match warning type
513 if thetype is not None:
514 if thetype != msgtype:
515 continue
516 # Match elm class
517 if klass is not None and elm is not None:
518 if klass != elm.attrib.get('class'):
519 continue
520 enabled = enable
521 return enabled
523 def err(filename, tree, elm, msgtype, msg, error = True):
525 Emit a warning or error for an element
527 global errors, errexists, warnings, warnexists, fatals, fatalexists, output_buffer
529 # Let user tune whether a warning or error
530 fatal = is_enabled(elm, msgtype, dofatals, error)
532 # By default warnings and errors are enabled, but let user tune it
533 if not is_enabled(elm, msgtype, enables, True):
534 return
536 (prefix, suppr) = elm_suppr(filename, tree, elm, msgtype, True)
537 if suppr in false_positives:
538 # That was actually expected
539 return
540 if suppr in suppressions:
541 # Suppressed
542 suppressions[suppr] = False
543 if fatal:
544 fatalexists += 1
545 if error:
546 errexists += 1
547 else:
548 warnexists += 1
549 return
551 if error:
552 errors += 1
553 else:
554 warnings += 1
555 if fatal:
556 fatals += 1
558 msg = "%s %s%s: %s%s" % (prefix,
559 "FATAL " if fatal else "",
560 "ERROR" if error else "WARNING",
561 elm_name(elm), msg)
562 output_buffer += msg + "\n"
563 if outfile is not None:
564 print(msg, file=outfile)
566 def warn(filename, tree, elm, msgtype, msg):
568 Emit a warning for an element
570 err(filename, tree, elm, msgtype, msg, False)
573 # Labelling testing functions
576 def find_button_parent(root, elm):
578 Find a parent which is a button
580 if lxml:
581 parent = elm.getparent()
582 if parent is not None:
583 if parent.attrib.get('class') in widgets_buttons:
584 return parent
585 return find_button_parent(root, parent)
586 else:
587 def find_parent(cur, elm):
588 for o in cur:
589 if o == elm:
590 if cur.attrib.get('class') in widgets_buttons:
591 # we are the button, immediately above the target
592 return cur
593 else:
594 # we aren't the button, but target is over there
595 return True
596 parent = find_parent(o, elm)
597 if parent == True:
598 # It is over there, but didn't find a button yet
599 if cur.attrib.get('class') in widgets_buttons:
600 # we are the button
601 return cur
602 else:
603 return True
604 if parent is not None:
605 # we have the button parent over there
606 return parent
607 return None
608 parent = find_parent(root, elm)
609 if parent == True:
610 parent = None
611 return parent
614 def is_labelled_parent(elm):
616 Return whether this element is a labelled parent
618 klass = elm.attrib.get('class')
619 if klass in widgets_toplevel:
620 return True
621 if klass == 'GtkShortcutsGroup':
622 children = elm.findall("property[@name='title']")
623 if len(children) >= 1:
624 return True
625 if klass == 'GtkFrame' or klass == 'GtkNotebook':
626 children = elm.findall("child[@type='tab']") + elm.findall("child[@type='label']")
627 if len(children) >= 1:
628 return True
629 return False
631 def elm_labelled_parent(root, elm):
633 Return the first labelled parent of the element, which can thus be used as
634 the root of widgets with common labelled context
637 if lxml:
638 def find_labelled_parent(elm):
639 if is_labelled_parent(elm):
640 return elm
641 parent = elm.getparent()
642 if parent is None:
643 return None
644 return find_labelled_parent(parent)
645 parent = elm.getparent()
646 if parent is None:
647 return None
648 return find_labelled_parent(elm.getparent())
649 else:
650 def find_labelled_parent(cur, elm):
651 if cur == elm:
652 # the target element is over there
653 return True
654 for o in cur:
655 parent = find_labelled_parent(o, elm)
656 if parent == True:
657 # target element is over there, check ourself
658 if is_labelled_parent(cur):
659 # yes, and we are the first ancestor of the target element
660 return cur
661 else:
662 # no, but target element is over there.
663 return True
664 if parent != None:
665 # the first ancestor of the target element was over there
666 return parent
667 return None
668 parent = find_labelled_parent(root, elm)
669 if parent == True:
670 parent = None
671 return parent
673 def is_orphan_label(filename, tree, root, obj, orphan_root, doprint = False):
675 Check whether this label has no accessibility relation, or doubtful relation
676 because another label labels the same target
678 global label_for_elm, labelled_by_elm, mnemonic_for_elm, warnexists
680 # label-for
681 label_for = obj.findall("accessibility/relation[@type='label-for']")
682 for rel in label_for:
683 target = rel.attrib['target']
684 l = label_for_elm[target]
685 if len(l) > 1:
686 return True
688 # mnemonic_widget
689 mnemonic_for = obj.findall("property[@name='mnemonic_widget']") + \
690 obj.findall("property[@name='mnemonic-widget']")
691 for rel in mnemonic_for:
692 target = rel.text
693 l = mnemonic_for_elm[target]
694 if len(l) > 1:
695 return True
697 if len(label_for) > 0:
698 # At least one label-for, we are not orphan.
699 return False
701 if len(mnemonic_for) > 0:
702 # At least one mnemonic_widget, we are not orphan.
703 return False
705 labelled_by = obj.findall("accessibility/relation[@type='labelled-by']")
706 if len(labelled_by) > 0:
707 # Oh, a labelled label, probably not to be labelling anything
708 return False
710 # explicit role?
711 roles = [x.text for x in obj.findall("child[@internal-child='accessible']/object[@class='AtkObject']/property[@name='AtkObject::accessible-role']")]
712 roles += [x.attrib.get("type") for x in obj.findall("accessibility/role")]
713 if len(roles) > 1 and doprint:
714 err(filename, tree, obj, "multiple-role", "has multiple <child internal-child='accessible'><object class='AtkObject'><property name='AtkBoject::accessible-role'>"
715 "%s" % elms_lines(children))
716 for role in roles:
717 if role == 'static' or role == 'ATK_ROLE_STATIC':
718 # This is static text, not meant to label anything
719 return False
721 parent = elm_parent(root, obj)
722 if parent is not None:
723 childtype = parent.attrib.get('type')
724 if childtype is None:
725 childtype = parent.attrib.get('internal-child')
726 if parent.tag == 'child' and childtype == 'label' \
727 or childtype == 'tab':
728 # This is a frame or a notebook label, not orphan.
729 return False
731 if find_button_parent(root, obj) is not None:
732 # This label is part of a button
733 return False
735 oid = obj.attrib.get('id')
736 if oid is not None:
737 if oid in labelled_by_elm:
738 # Some widget is labelled by us, we are not orphan.
739 # We should have had a label-for, will warn about it later.
740 return False
742 # No label-for, no mnemonic-for, no labelled-by, we are orphan.
743 (_, suppr) = elm_suppr(filename, tree, obj, "orphan-label", False)
744 if suppr in false_positives:
745 # That was actually expected
746 return False
747 if suppr in suppressions:
748 # Warning suppressed for this label
749 if suppressions[suppr]:
750 warnexists += 1
751 suppressions[suppr] = False
752 return False
754 if doprint:
755 context = elm_name(orphan_root)
756 if context:
757 context = " within " + context
758 warn(filename, tree, obj, "orphan-label", "does not specify what it labels" + context)
759 return True
761 def is_orphan_widget(filename, tree, root, obj, orphan, orphan_root, doprint = False):
763 Check whether this widget has no accessibility relation.
765 global warnexists
766 if obj.tag != 'object':
767 return False
769 oid = obj.attrib.get('id')
770 klass = obj.attrib.get('class')
772 # "Don't care" special case
773 if klass in widgets_ignored:
774 return False
775 for suffix in widgets_suffixignored:
776 if klass[-len(suffix):] == suffix:
777 return False
779 # Widgets usual do not strictly require a label, i.e. a labelled parent
780 # is enough for context, but some do always need one.
781 requires_label = klass in widgets_needlabel
783 labelled_by = obj.findall("accessibility/relation[@type='labelled-by']")
785 # Labels special case
786 if klass in widgets_labels:
787 return False
789 # Case 1: has an explicit <child internal-child="accessible"> sub-element
790 children = obj.findall("child[@internal-child='accessible']")
791 if len(children) > 1 and doprint:
792 err(filename, tree, obj, "multiple-accessible", "has multiple <child internal-child='accessible'>"
793 "%s" % elms_lines(children))
794 if len(children) >= 1:
795 return False
797 # Case 2: has an <accessibility> sub-element with a "labelled-by"
798 # <relation> pointing to an existing element.
799 if len(labelled_by) > 0:
800 return False
802 # Case 3: has a label-for
803 if oid in label_for_elm:
804 return False
806 # Case 4: has a mnemonic
807 if oid in mnemonic_for_elm:
808 return False
810 # Case 5: Has a <property name="tooltip_text">
811 tooltips = obj.findall("property[@name='tooltip_text']") + \
812 obj.findall("property[@name='tooltip-text']")
813 if len(tooltips) > 1 and doprint:
814 err(filename, tree, obj, "multiple-tooltip", "has multiple tooltip_text properties")
815 if len(tooltips) >= 1 and klass != 'GtkCheckButton':
816 return False
818 # Case 6: Has a <property name="placeholder_text">
819 placeholders = obj.findall("property[@name='placeholder_text']") + \
820 obj.findall("property[@name='placeholder-text']")
821 if len(placeholders) > 1 and doprint:
822 err(filename, tree, obj, "multiple-placeholder", "has multiple placeholder_text properties")
823 if len(placeholders) >= 1:
824 return False
826 # Buttons usually don't need an external label, their own is enough, (but they do need one)
827 if klass in widgets_buttons:
829 labels = obj.findall("property[@name='label']")
830 if len(labels) > 1 and doprint:
831 err(filename, tree, obj, "multiple-label", "has multiple label properties")
832 if len(labels) >= 1:
833 # Has a <property name="label">
834 return False
836 actions = obj.findall("property[@name='action_name']")
837 if len(actions) > 1 and doprint:
838 err(filename, tree, obj, "multiple-action_name", "has multiple action_name properties")
839 if len(actions) >= 1:
840 # Has a <property name="action_name">
841 return False
843 # Uses id as an action_name
844 if 'id' in obj.attrib:
845 if obj.attrib['id'].startswith(".uno:"):
846 return False
848 gtklabels = obj.findall(".//object[@class='GtkLabel']") + obj.findall(".//object[@class='GtkAccelLabel']")
849 if len(gtklabels) >= 1:
850 # Has a custom label
851 return False
853 # no label for a button, warn
854 if doprint:
855 warn(filename, tree, obj, "button-no-label", "does not have its own label")
856 if not is_enabled(obj, "button-no-label", enables, True):
857 # Warnings disabled
858 return False
859 (_, suppr) = elm_suppr(filename, tree, obj, "button-no-label", False)
860 if suppr in false_positives:
861 # That was actually expected
862 return False
863 if suppr in suppressions:
864 # Warning suppressed for this widget
865 if suppressions[suppr]:
866 warnexists += 1
867 suppressions[suppr] = False
868 return False
869 return True
871 # GtkImages special case
872 if klass == "GtkImage":
873 uses = [u for u in tree.iterfind(".//object/property[@name='image']") if u.text == oid]
874 if len(uses) > 0:
875 # This image is just used by another element, don't warn
876 # about the image itself, we probably want the warning on
877 # the element instead.
878 return False
880 if find_button_parent(root, obj) is not None:
881 # This image is part of a button, we want the warning on the button
882 # instead, if any.
883 return False
885 # GtkEntry special case
886 if klass == 'GtkEntry' or klass == 'GtkSearchEntry':
887 parent = elm_parent(root, obj)
888 if parent is not None:
889 if parent.tag == 'child' and \
890 parent.attrib.get('internal-child') == "entry":
891 # This is an internal entry of another widget. Relations
892 # will be handled by that widget.
893 return False
895 # GtkShortcutsShortcut special case
896 if klass == 'GtkShortcutsShortcut':
897 children = obj.findall("property[@name='title']")
898 if len(children) >= 1:
899 return False
901 # Really no label, perhaps emit a warning
902 if not is_enabled(obj, "no-labelled-by", enables, True):
903 # Warnings disabled for this class of widgets
904 return False
905 (_, suppr) = elm_suppr(filename, tree, obj, "no-labelled-by", False)
906 if suppr in false_positives:
907 # That was actually expected
908 return False
909 if suppr in suppressions:
910 # Warning suppressed for this widget
911 if suppressions[suppr]:
912 warnexists += 1
913 suppressions[suppr] = False
914 return False
916 if not orphan:
917 # No orphan label, so probably the labelled parent provides enough
918 # context.
919 if requires_label:
920 # But these always need a label.
921 if doprint:
922 warn(filename, tree, obj, "no-labelled-by", "has no accessibility label")
923 return True
924 return False
926 if doprint:
927 context = elm_name(orphan_root)
928 if context:
929 context = " within " + context
930 warn(filename, tree, obj, "no-labelled-by", "has no accessibility label while there are orphan labels" + context)
931 return True
933 def orphan_items(filename, tree, root, elm):
935 Check whether from some element there exists orphan labels and orphan widgets
937 orphan_labels = False
938 orphan_widgets = False
939 if elm.attrib.get('class') in widgets_labels:
940 orphan_labels = is_orphan_label(filename, tree, root, elm, None)
941 else:
942 orphan_widgets = is_orphan_widget(filename, tree, root, elm, True, None)
943 for obj in elm:
944 # We are not interested in orphan labels under another labelled
945 # parent. This also allows to keep linear complexity.
946 if not is_labelled_parent(obj):
947 label, widget = orphan_items(filename, tree, root, obj)
948 if label:
949 orphan_labels = True
950 if widget:
951 orphan_widgets = True
952 if orphan_labels and orphan_widgets:
953 # No need to look up more
954 break
955 return orphan_labels, orphan_widgets
958 # UI accessibility checks
961 def check_props(filename, tree, root, elm, forward):
963 Check the given list of relation properties
965 props = elm.findall("property[@name='" + forward + "']")
966 for prop in props:
967 if prop.text not in ids:
968 err(filename, tree, elm, "undeclared-target", forward + " uses undeclared target '%s'" % prop.text)
969 return props
971 def is_visible(obj):
972 visible = False
973 visible_prop = obj.findall("property[@name='visible']")
974 visible_len = len(visible_prop)
975 if visible_len:
976 visible_txt = visible_prop[visible_len - 1].text
977 if visible_txt.lower() == "true":
978 visible = True
979 elif visible_txt.lower() == "false":
980 visible = False
981 return visible
983 def check_rels(filename, tree, root, elm, forward, backward = None):
985 Check the relations given by forward
987 oid = elm.attrib.get('id')
988 rels = elm.findall("accessibility/relation[@type='" + forward + "']")
989 for rel in rels:
990 target = rel.attrib['target']
991 if target not in ids:
992 err(filename, tree, elm, "undeclared-target", forward + " uses undeclared target '%s'" % target)
993 elif backward is not None:
994 widget = ids[target]
995 backrels = widget.findall("accessibility/relation[@type='" + backward + "']")
996 if len([x for x in backrels if x.attrib['target'] == oid]) == 0:
997 err(filename, tree, elm, "missing-" + backward, "has " + forward + \
998 ", but is not " + backward + " by " + elm_name_line(widget))
999 return rels
1001 def check_a11y_relation(filename, tree):
1003 Emit an error message if any of the 'object' elements of the XML
1004 document represented by `root' doesn't comply with Accessibility
1005 rules.
1007 global widgets_ignored, ids, label_for_elm, labelled_by_elm, mnemonic_for_elm
1009 def check_elm(orphan_root, obj, orphan_labels, orphan_widgets):
1011 Check one element, knowing that orphan_labels/widgets tell whether
1012 there are orphan labels and widgets within orphan_root
1015 oid = obj.attrib.get('id')
1016 klass = obj.attrib.get('class')
1018 # "Don't care" special case
1019 if klass in widgets_ignored:
1020 return
1021 for suffix in widgets_suffixignored:
1022 if klass[-len(suffix):] == suffix:
1023 return
1025 # Widgets usual do not strictly require a label, i.e. a labelled parent
1026 # is enough for context, but some do always need one.
1027 requires_label = klass in widgets_needlabel
1029 if oid is not None:
1030 # Check that ids are unique
1031 if oid in ids_dup:
1032 if ids[oid] == obj:
1033 # We are the first, warn
1034 duplicates = tree.findall(".//object[@id='" + oid + "']")
1035 err(filename, tree, obj, "duplicate-id", "has the same id as other elements " + elms_names_lines(duplicates))
1037 # Check label-for and their dual labelled-by
1038 label_for = check_rels(filename, tree, root, obj, "label-for", "labelled-by")
1040 # Check labelled-by and its dual label-for
1041 labelled_by = check_rels(filename, tree, root, obj, "labelled-by", "label-for")
1043 visible = is_visible(obj)
1045 # warning message type "syntax" used:
1047 # multiple-* => 2+ XML tags of the inspected element itself
1048 # duplicate-* => 2+ XML tags of other elements referencing this element
1050 # Should have only one label
1051 if len(labelled_by) >= 1:
1052 if oid in mnemonic_for_elm:
1053 warn(filename, tree, obj, "labelled-by-and-mnemonic",
1054 "has both a mnemonic " + elm_name_line(mnemonic_for_elm[oid][0]) + "and labelled-by relation")
1055 if len(labelled_by) > 1:
1056 warn(filename, tree, obj, "multiple-labelled-by", "has multiple labelled-by relations")
1058 if oid in labelled_by_elm:
1059 if len(labelled_by_elm[oid]) == 1:
1060 paired = labelled_by_elm[oid][0]
1061 if paired != None and visible != is_visible(paired):
1062 warn(filename, tree, obj, "visibility-conflict", "visibility conflicts with paired " + elm_name_line(paired))
1064 if oid in label_for_elm:
1065 if len(label_for_elm[oid]) > 1:
1066 warn(filename, tree, obj, "duplicate-label-for", "is referenced by multiple label-for " + elms_names_lines(label_for_elm[oid]))
1067 elif len(label_for_elm[oid]) == 1:
1068 paired = label_for_elm[oid][0]
1069 if visible != is_visible(paired):
1070 warn(filename, tree, obj, "visibility-conflict", "visibility conflicts with paired " + elm_name_line(paired))
1072 if oid in mnemonic_for_elm:
1073 if len(mnemonic_for_elm[oid]) > 1:
1074 warn(filename, tree, obj, "duplicate-mnemonic", "is referenced by multiple mnemonic_widget " + elms_names_lines(mnemonic_for_elm[oid]))
1076 # Check controlled-by/controller-for
1077 controlled_by = check_rels(filename, tree, root, obj, "controlled-by", "controller-for")
1078 controller_for = check_rels(filename, tree, root, obj, "controlled-for", "controlled-by")
1080 # Labels special case
1081 if klass in widgets_labels:
1082 properties = check_props(filename, tree, root, obj, "mnemonic_widget") + \
1083 check_props(filename, tree, root, obj, "mnemonic-widget")
1084 if len(properties) > 1:
1085 err(filename, tree, obj, "multiple-mnemonic", "has multiple mnemonic_widgets properties"
1086 "%s" % elms_lines(properties))
1088 # Emit orphaning warnings
1089 if warn_orphan_labels or orphan_widgets:
1090 is_orphan_label(filename, tree, root, obj, orphan_root, True)
1092 # We are done with the label
1093 return
1095 # Not a label, will perhaps need one
1097 # Emit orphaning warnings
1098 is_orphan_widget(filename, tree, root, obj, orphan_labels, orphan_root, True)
1100 root = tree.getroot()
1102 # Flush ids and relations from previous files
1103 ids = {}
1104 ids_dup = {}
1105 labelled_by_elm = {}
1106 label_for_elm = {}
1107 mnemonic_for_elm = {}
1109 # First pass to get links into hash tables, no warning, just record duplicates
1110 for obj in root.iter('object'):
1111 oid = obj.attrib.get('id')
1112 if oid is not None:
1113 if oid not in ids:
1114 ids[oid] = obj
1115 else:
1116 ids_dup[oid] = True
1118 labelled_by = obj.findall("accessibility/relation[@type='labelled-by']")
1119 for rel in labelled_by:
1120 target = rel.attrib.get('target')
1121 if target is not None:
1122 if target not in labelled_by_elm:
1123 labelled_by_elm[target] = [ obj ]
1124 else:
1125 labelled_by_elm[target].append(obj)
1127 label_for = obj.findall("accessibility/relation[@type='label-for']")
1128 for rel in label_for:
1129 target = rel.attrib.get('target')
1130 if target is not None:
1131 if target not in label_for_elm:
1132 label_for_elm[target] = [ obj ]
1133 else:
1134 label_for_elm[target].append(obj)
1136 mnemonic_for = obj.findall("property[@name='mnemonic_widget']") + \
1137 obj.findall("property[@name='mnemonic-widget']")
1138 for rel in mnemonic_for:
1139 target = rel.text
1140 if target is not None:
1141 if target not in mnemonic_for_elm:
1142 mnemonic_for_elm[target] = [ obj ]
1143 else:
1144 mnemonic_for_elm[target].append(obj)
1146 # Second pass, recursive depth-first, to be able to efficiently know whether
1147 # there are orphan labels within a part of the tree.
1148 def recurse(orphan_root, obj, orphan_labels, orphan_widgets):
1149 if obj == root or is_labelled_parent(obj):
1150 orphan_root = obj
1151 orphan_labels, orphan_widgets = orphan_items(filename, tree, root, obj)
1153 if obj.tag == 'object':
1154 check_elm(orphan_root, obj, orphan_labels, orphan_widgets)
1156 for o in obj:
1157 recurse(orphan_root, o, orphan_labels, orphan_widgets)
1159 recurse(root, root, False, False)
1162 # Main
1165 def usage(fatal = True):
1166 print("`%s' checks accessibility of glade .ui files" % progname)
1167 print("")
1168 print("Usage: %s [-p] [-g SUPPR_FILE] [-s SUPPR_FILE] [-f SUPPR_FILE] [-P PREFIX] [-o LOG_FILE] [file ...]" % progname)
1169 print("")
1170 print(" -p Print XML class path instead of line number")
1171 print(" -g Generate suppression file SUPPR_FILE")
1172 print(" -s Suppress warnings given by file SUPPR_FILE, but count them")
1173 print(" -f Suppress warnings given by file SUPPR_FILE completely")
1174 print(" -P Remove PREFIX from file names in warnings")
1175 print(" -o Also prints errors and warnings to given file")
1176 print("")
1177 print(" --widgets-FOO [+][CLASS1[,CLASS2[,...]]]")
1178 print(" Give or extend one of the lists of widget classes, where FOO can be:")
1179 print(" - toplevel : widgets to be considered toplevel windows")
1180 print(" - ignored : widgets which do not need labelling (e.g. GtkBox)")
1181 print(" - suffixignored : suffixes of widget classes which do not need labelling")
1182 print(" - needlabel : widgets which always need labelling (e.g. GtkEntry)")
1183 print(" - buttons : widgets which need their own label but not more")
1184 print(" (e.g. GtkButton)")
1185 print(" - labels : widgets which provide labels (e.g. GtkLabel)")
1186 print(" --widgets-print print default widgets lists")
1187 print("")
1188 print(" --enable-all enable all warnings/dofatals (default)")
1189 print(" --disable-all disable all warnings/dofatals")
1190 print(" --fatal-all make all warnings dofatals")
1191 print(" --not-fatal-all do not make all warnings dofatals (default)")
1192 print("")
1193 print(" --enable-type=TYPE enable warning/fatal type TYPE")
1194 print(" --disable-type=TYPE disable warning/fatal type TYPE")
1195 print(" --fatal-type=TYPE make warning type TYPE a fatal")
1196 print(" --not-fatal-type=TYPE make warning type TYPE not a fatal")
1197 print("")
1198 print(" --enable-widgets=CLASS enable warning/fatal type CLASS")
1199 print(" --disable-widgets=CLASS disable warning/fatal type CLASS")
1200 print(" --fatal-widgets=CLASS make warning type CLASS a fatal")
1201 print(" --not-fatal-widgets=CLASS make warning type CLASS not a fatal")
1202 print("")
1203 print(" --enable-specific=TYPE.CLASS enable warning/fatal type TYPE for widget")
1204 print(" class CLASS")
1205 print(" --disable-specific=TYPE.CLASS disable warning/fatal type TYPE for widget")
1206 print(" class CLASS")
1207 print(" --fatal-specific=TYPE.CLASS make warning type TYPE a fatal for widget")
1208 print(" class CLASS")
1209 print(" --not-fatal-specific=TYPE.CLASS make warning type TYPE not a fatal for widget")
1210 print(" class CLASS")
1211 print("")
1212 print(" --disable-orphan-labels only warn about orphan labels when there are")
1213 print(" orphan widgets in the same context")
1214 print("")
1215 print("Report bugs to <bugs@hypra.fr>")
1216 sys.exit(2 if fatal else 0)
1218 def widgets_opt(widgets_list, arg):
1220 Replace or extend `widgets_list' with the list of classes contained in `arg'
1222 append = arg and arg[0] == '+'
1223 if append:
1224 arg = arg[1:]
1226 if arg:
1227 widgets = arg.split(',')
1228 else:
1229 widgets = []
1231 if not append:
1232 del widgets_list[:]
1234 widgets_list.extend(widgets)
1237 def main():
1238 global pflag, gen_suppr, gen_supprfile, suppressions, suppr_prefix, false_positives, dofatals, enables, dofatals, warn_orphan_labels
1239 global widgets_toplevel, widgets_ignored, widgets_suffixignored, widgets_needlabel, widgets_buttons, widgets_labels
1240 global outfile, output_buffer
1242 try:
1243 opts, args = getopt.getopt(sys.argv[1:], "hpiIg:s:f:P:o:L:", [
1244 "help",
1245 "version",
1247 "widgets-toplevel=",
1248 "widgets-ignored=",
1249 "widgets-suffixignored=",
1250 "widgets-needlabel=",
1251 "widgets-buttons=",
1252 "widgets-labels=",
1253 "widgets-print",
1255 "enable-all",
1256 "disable-all",
1257 "fatal-all",
1258 "not-fatal-all",
1260 "enable-type=",
1261 "disable-type=",
1262 "fatal-type=",
1263 "not-fatal-type=",
1265 "enable-widgets=",
1266 "disable-widgets=",
1267 "fatal-widgets=",
1268 "not-fatal-widgets=",
1270 "enable-specific=",
1271 "disable-specific=",
1272 "fatal-specific=",
1273 "not-fatal-specific=",
1275 "disable-orphan-labels",
1277 except getopt.GetoptError:
1278 usage()
1280 suppr = None
1281 false = None
1282 out = None
1283 filelist = None
1285 for o, a in opts:
1286 if o == "--help" or o == "-h":
1287 usage(False)
1288 if o == "--version":
1289 print("0.1")
1290 sys.exit(0)
1291 elif o == "-p":
1292 pflag = True
1293 elif o == "-g":
1294 gen_suppr = a
1295 elif o == "-s":
1296 suppr = a
1297 elif o == "-f":
1298 false = a
1299 elif o == "-P":
1300 suppr_prefix = a
1301 elif o == "-o":
1302 out = a
1303 elif o == "-L":
1304 filelist = a
1306 elif o == "--widgets-toplevel":
1307 widgets_opt(widgets_toplevel, a)
1308 elif o == "--widgets-ignored":
1309 widgets_opt(widgets_ignored, a)
1310 elif o == "--widgets-suffixignored":
1311 widgets_opt(widgets_suffixignored, a)
1312 elif o == "--widgets-needlabel":
1313 widgets_opt(widgets_needlabel, a)
1314 elif o == "--widgets-buttons":
1315 widgets_opt(widgets_buttons, a)
1316 elif o == "--widgets-labels":
1317 widgets_opt(widgets_labels, a)
1318 elif o == "--widgets-print":
1319 print("--widgets-toplevel '" + ','.join(widgets_toplevel) + "'")
1320 print("--widgets-ignored '" + ','.join(widgets_ignored) + "'")
1321 print("--widgets-suffixignored '" + ','.join(widgets_suffixignored) + "'")
1322 print("--widgets-needlabel '" + ','.join(widgets_needlabel) + "'")
1323 print("--widgets-buttons '" + ','.join(widgets_buttons) + "'")
1324 print("--widgets-labels '" + ','.join(widgets_labels) + "'")
1325 sys.exit(0)
1327 elif o == '--enable-all':
1328 enables.append( (True, None, None) )
1329 elif o == '--disable-all':
1330 enables.append( (False, None, None) )
1331 elif o == '--fatal-all':
1332 dofatals.append( (True, None, None) )
1333 elif o == '--not-fatal-all':
1334 dofatals.append( (False, None, None) )
1336 elif o == '--enable-type':
1337 enables.append( (True, a, None) )
1338 elif o == '--disable-type':
1339 enables.append( (False, a, None) )
1340 elif o == '--fatal-type':
1341 dofatals.append( (True, a, None) )
1342 elif o == '--not-fatal-type':
1343 dofatals.append( (False, a, None) )
1345 elif o == '--enable-widgets':
1346 enables.append( (True, None, a) )
1347 elif o == '--disable-widgets':
1348 enables.append( (False, None, a) )
1349 elif o == '--fatal-widgets':
1350 dofatals.append( (True, None, a) )
1351 elif o == '--not-fatal-widgets':
1352 dofatals.append( (False, None, a) )
1354 elif o == '--enable-specific':
1355 (thetype, klass) = a.split('.', 1)
1356 enables.append( (True, thetype, klass) )
1357 elif o == '--disable-specific':
1358 (thetype, klass) = a.split('.', 1)
1359 enables.append( (False, thetype, klass) )
1360 elif o == '--fatal-specific':
1361 (thetype, klass) = a.split('.', 1)
1362 dofatals.append( (True, thetype, klass) )
1363 elif o == '--not-fatal-specific':
1364 (thetype, klass) = a.split('.', 1)
1365 dofatals.append( (False, thetype, klass) )
1367 elif o == '--disable-orphan-labels':
1368 warn_orphan_labels = False
1370 output_header = ""
1372 # Read suppression file before overwriting it
1373 if suppr is not None:
1374 try:
1375 output_header += "Suppression file: " + suppr + "\n"
1376 supprfile = open(suppr, 'r')
1377 line_no = 0
1378 for line in supprfile.readlines():
1379 line_no = line_no + 1
1380 if line.startswith('#'):
1381 continue
1382 prefix = line.rstrip()
1383 suppressions[prefix] = True
1384 suppressions_to_line[prefix] = "%s:%u" % (suppr, line_no)
1385 supprfile.close()
1386 except IOError:
1387 pass
1389 # Read false positives file
1390 if false is not None:
1391 try:
1392 output_header += "False positive file: " + false + "\n"
1393 falsefile = open(false, 'r')
1394 for line in falsefile.readlines():
1395 if line.startswith('#'):
1396 continue
1397 prefix = line.rstrip()
1398 false_positives[prefix] = True
1399 falsefile.close()
1400 except IOError:
1401 pass
1403 if out is not None:
1404 outfile = open(out, 'w')
1406 if filelist is not None:
1407 try:
1408 filelistfile = open(filelist, 'r')
1409 for line in filelistfile.readlines():
1410 line = line.strip()
1411 if line:
1412 args += line.split(' ')
1413 filelistfile.close()
1414 except IOError:
1415 err(filelist, None, None, "unable to read file list file")
1417 for filename in args:
1418 try:
1419 tree = ET.parse(filename)
1420 except ET.ParseError:
1421 err(filename, None, None, "parse", "malformatted xml file")
1422 continue
1423 except IOError:
1424 err(filename, None, None, None, "unable to read file")
1425 continue
1427 try:
1428 check_a11y_relation(filename, tree)
1429 except Exception as error:
1430 import traceback
1431 output_buffer += traceback.format_exc()
1432 err(filename, None, None, "parse", "error parsing file")
1434 if errors > 0 or errexists > 0:
1435 output_buffer += "%s new error%s" % (errors, 's' if errors != 1 else '')
1436 if errexists > 0:
1437 output_buffer += " (%s suppressed by %s, please fix %s)" % (errexists, suppr, 'them' if errexists > 1 else 'it')
1438 output_buffer += "\n"
1440 if warnings > 0 or warnexists > 0:
1441 output_buffer += "%s new warning%s" % (warnings, 's' if warnings != 1 else '')
1442 if warnexists > 0:
1443 output_buffer += " (%s suppressed by %s, please fix %s)" % (warnexists, suppr, 'them' if warnexists > 1 else 'it')
1444 output_buffer += "\n"
1446 if fatals > 0 or fatalexists > 0:
1447 output_buffer += "%s new fatal%s" % (fatals, 's' if fatals != 1 else '')
1448 if fatalexists > 0:
1449 output_buffer += " (%s suppressed by %s, please fix %s)" % (fatalexists, suppr, 'them' if fatalexists > 1 else 'it')
1450 output_buffer += "\n"
1452 n = 0
1453 for (suppr,unused) in suppressions.items():
1454 if unused:
1455 n += 1
1457 if n > 0:
1458 output_buffer += "%s suppression%s unused:\n" % (n, 's' if n != 1 else '')
1459 for (suppr,unused) in suppressions.items():
1460 if unused:
1461 output_buffer += " %s:%s\n" % (suppressions_to_line[suppr], suppr)
1463 if gen_supprfile is not None:
1464 gen_supprfile.close()
1465 if outfile is not None:
1466 outfile.close()
1468 if gen_suppr is None:
1469 if output_buffer != "":
1470 output_buffer += "Explanations are available on " + howto_url + "\n"
1472 if fatals > 0:
1473 print(output_header.rstrip() + "\n" + output_buffer)
1474 sys.exit(1)
1476 if len(output_buffer) > 0:
1477 print(output_header.rstrip() + "\n" + output_buffer)
1479 if __name__ == "__main__":
1480 try:
1481 main()
1482 except KeyboardInterrupt:
1483 pass
1485 # vim: set shiftwidth=4 softtabstop=4 expandtab: