Adjust for new linux baseline
[LibreOffice.git] / bin / gla11y
blobc3ae7be67604cb4a9d562e24378b3afcfc97ee64
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 from __future__ import print_function
31 import os
32 import sys
33 import getopt
34 try:
35 import lxml.etree as ET
36 lxml = True
37 except ImportError:
38 if sys.version_info < (2,7):
39 print("gla11y needs lxml or python >= 2.7")
40 exit()
41 import xml.etree.ElementTree as ET
42 lxml = False
44 howto_url = "https://wiki.documentfoundation.org/Development/Accessibility"
46 # Toplevel widgets
47 widgets_toplevel = [
48 'GtkWindow',
49 'GtkOffscreenWindow',
50 'GtkApplicationWindow',
51 'GtkDialog',
52 'GtkFileChooserDialog',
53 'GtkColorChooserDialog',
54 'GtkFontChooserDialog',
55 'GtkMessageDialog',
56 'GtkRecentChooserDialog',
57 'GtkAssistant',
58 'GtkAppChooserDialog',
59 'GtkPrintUnixDialog',
60 'GtkShortcutsWindow',
63 widgets_ignored = widgets_toplevel + [
64 # Containers
65 'GtkBox',
66 'GtkGrid',
67 'GtkNotebook',
68 'GtkFrame',
69 'GtkAspectFrame',
70 'GtkListBox',
71 'GtkFlowBox',
72 'GtkOverlay',
73 'GtkMenuBar',
74 'GtkToolbar',
75 'GtkToolpalette',
76 'GtkPaned',
77 'GtkHPaned',
78 'GtkVPaned',
79 'GtkButtonBox',
80 'GtkHButtonBox',
81 'GtkVButtonBox',
82 'GtkLayout',
83 'GtkFixed',
84 'GtkEventBox',
85 'GtkExpander',
86 'GtkViewport',
87 'GtkScrolledWindow',
88 'GtkRevealer',
89 'GtkSearchBar',
90 'GtkHeaderBar',
91 'GtkStack',
92 'GtkPopover',
93 'GtkPopoverMenu',
94 'GtkActionBar',
95 'GtkHandleBox',
96 'GtkShortcutsSection',
97 'GtkShortcutsGroup',
98 'GtkTable',
100 'GtkVBox',
101 'GtkHBox',
102 'GtkToolItem',
103 'GtkMenu',
105 # Invisible actions
106 'GtkSeparator',
107 'GtkHSeparator',
108 'GtkVSeparator',
109 'GtkAction',
110 'GtkToggleAction',
111 'GtkActionGroup',
112 'GtkCellRendererGraph',
113 'GtkCellRendererPixbuf',
114 'GtkCellRendererProgress',
115 'GtkCellRendererSpin',
116 'GtkCellRendererText',
117 'GtkCellRendererToggle',
118 'GtkSeparatorMenuItem',
119 'GtkSeparatorToolItem',
121 # Storage objects
122 'GtkListStore',
123 'GtkTreeStore',
124 'GtkTreeModelFilter',
125 'GtkTreeModelSort',
127 'GtkEntryBuffer',
128 'GtkTextBuffer',
129 'GtkTextTag',
130 'GtkTextTagTable',
132 'GtkSizeGroup',
133 'GtkWindowGroup',
134 'GtkAccelGroup',
135 'GtkAdjustment',
136 'GtkEntryCompletion',
137 'GtkIconFactory',
138 'GtkStatusIcon',
139 'GtkFileFilter',
140 'GtkRecentFilter',
141 'GtkRecentManager',
142 'GThemedIcon',
144 'GtkTreeSelection',
146 'GtkListBoxRow',
147 'GtkTreeViewColumn',
149 # Useless to label
150 'GtkScrollbar',
151 'GtkHScrollbar',
152 'GtkStatusbar',
153 'GtkInfoBar',
155 # These are actually labels
156 'GtkLinkButton',
158 # This precisely give a11y information :)
159 'AtkObject',
162 widgets_suffixignored = [
165 # These widgets always need a label
166 widgets_needlabel = [
167 'GtkEntry',
168 'GtkSearchEntry',
169 'GtkScale',
170 'GtkHScale',
171 'GtkVScale',
172 'GtkSpinButton',
173 'GtkSwitch',
176 # These widgets normally have their own label
177 widgets_buttons = [
178 'GtkButton',
179 'GtkToolButton',
180 'GtkToggleButton',
181 'GtkToggleToolButton',
182 'GtkRadioButton',
183 'GtkRadioToolButton',
184 'GtkCheckButton',
185 'GtkModelButton',
186 'GtkLockButton',
187 'GtkColorButton',
188 'GtkMenuButton',
190 'GtkMenuItem',
191 'GtkImageMenuItem',
192 'GtkMenuToolButton',
193 'GtkRadioMenuItem',
194 'GtkCheckMenuItem',
197 # These widgets are labels that can label other widgets
198 widgets_labels = [
199 'GtkLabel',
200 'GtkAccelLabel',
203 # The rest should probably be labelled if there are orphan labels
205 # GtkSpinner
206 # GtkProgressBar
207 # GtkLevelBar
209 # GtkComboBox
210 # GtkComboBoxText
211 # GtkFileChooserButton
212 # GtkAppChooserButton
213 # GtkFontButton
214 # GtkCalendar
215 # GtkColorChooserWidget
217 # GtkCellView
218 # GtkTreeView
219 # GtkTextView
220 # GtkIconView
222 # GtkImage
223 # GtkArrow
224 # GtkDrawingArea
226 # GtkScaleButton
227 # GtkVolumeButton
230 # TODO:
231 # GtkColorPlane ?
232 # GtkColorScale ?
233 # GtkColorSwatch ?
234 # GtkFileChooserWidget ?
235 # GtkFishbowl ?
236 # GtkFontChooserWidget ?
237 # GtkIcon ?
238 # GtkInspector* ?
239 # GtkMagnifier ?
240 # GtkPathBar ?
241 # GtkPlacesSidebar ?
242 # GtkPlacesView ?
243 # GtkPrinterOptionWidget ?
244 # GtkStackCombo ?
245 # GtkStackSidebar ?
246 # GtkStackSwitcher ?
248 progname = os.path.basename(sys.argv[0])
250 # This dictionary contains the set of suppression lines as read from the
251 # suppression file(s). It is merely indexed by the text of the suppression line
252 # and contains whether the suppressions was unused.
253 suppressions = {}
255 # This dictionary is indexed like suppressions and returns a "file:line" string
256 # to report where in the suppression file the suppression was read
257 suppressions_to_line = {}
259 # This dictionary is similar to the suppressions dictionary, but for false
260 # positives rather than suppressions
261 false_positives = {}
263 # This dictionary is indexed by the xml id and returns the element object.
264 ids = {}
265 # This dictionary is indexed by the xml id and returns whether several objects
266 # have the same id.
267 ids_dup = {}
269 # This dictionary is indexed by the xml id of an element A and returns the list
270 # of objects which are labelled-by A.
271 labelled_by_elm = {}
273 # This dictionary is indexed by the xml id of an element A and returns the list
274 # of objects which are label-for A.
275 label_for_elm = {}
277 # This dictionary is indexed by the xml id of an element A and returns the list
278 # of objects which have a mnemonic-for A.
279 mnemonic_for_elm = {}
281 # Possibly a file name to put generated suppression lines in
282 gen_suppr = None
283 # The corresponding opened file
284 gen_supprfile = None
285 # A prefix to remove from file names in the generated suppression lines
286 suppr_prefix = ""
288 # Possibly an opened file in which our output should also be written to.
289 outfile = None
291 # Whether -p option was set, i.e. print XML class path instead of line number in
292 # the output
293 pflag = False
295 # Whether we should warn about labels which are orphan
296 warn_orphan_labels = True
298 # Number of errors
299 errors = 0
300 # Number of suppressed errors
301 errexists = 0
302 # Number of warnings
303 warnings = 0
304 # Number of suppressed warnings
305 warnexists = 0
306 # Number of fatal errors
307 fatals = 0
308 # Number of suppressed fatal errors
309 fatalexists = 0
311 # List of warnings and errors which are fatal
313 # Format of each element: (enabled, type, class)
314 # See the is_enabled function: the list is traversed completely, each element
315 # can specify whether it enables or disables the warning, possibly the type of
316 # warning to be enabled/disabled, possibly the class of XML element for which it
317 # should be enabled.
319 # This mechanism matches the semantic of the parameters on the command line,
320 # each of which refining the semantic set by the previous parameters
321 dofatals = [ ]
323 # List of warnings and errors which are enabled
324 # Same format as dofatals
325 enables = [ ]
327 # buffers all printed output, so it isn't split in parallel builds
328 output_buffer = ""
331 # XML browsing and printing functions
334 def elm_parent(root, elm):
336 Return the parent of the element.
338 if lxml:
339 return elm.getparent()
340 else:
341 def find_parent(cur, elm):
342 for o in cur:
343 if o == elm:
344 return cur
345 parent = find_parent(o, elm)
346 if parent is not None:
347 return parent
348 return None
349 return find_parent(root, elm)
351 def step_elm(elm):
353 Return the XML class path step corresponding to elm.
354 This can be empty if the elm does not have any class or id.
356 step = elm.attrib.get('class')
357 if step is None:
358 step = ""
359 oid = elm.attrib.get('id')
360 if oid is not None:
361 oid = oid.encode('ascii','ignore').decode('ascii')
362 step += "[@id='%s']" % oid
363 if len(step) > 0:
364 step += '/'
365 return step
367 def find_elm(root, elm):
369 Return the XML class path of the element from the given root.
370 This is the slow version used when getparent is not available.
372 if root == elm:
373 return ""
374 for o in root:
375 path = find_elm(o, elm)
376 if path is not None:
377 step = step_elm(o)
378 return step + path
379 return None
381 def errpath(filename, tree, elm):
383 Return the XML class path of the element
385 if elm is None:
386 return ""
387 path = ""
388 if 'class' in elm.attrib:
389 path += elm.attrib['class']
390 oid = elm.attrib.get('id')
391 if oid is not None:
392 oid = oid.encode('ascii','ignore').decode('ascii')
393 path = "//" + path + "[@id='%s']" % oid
394 else:
395 if lxml:
396 elm = elm.getparent()
397 while elm is not None:
398 step = step_elm(elm)
399 path = step + path
400 elm = elm.getparent()
401 else:
402 path = find_elm(tree.getroot(), elm)[:-1]
403 path = filename + ':' + path
404 return path
407 # Warning/Error printing functions
410 def elm_prefix(filename, elm):
412 Return the display prefix of the element
414 if elm == None or not lxml:
415 return "%s:" % filename
416 else:
417 return "%s:%u" % (filename, elm.sourceline)
419 def elm_name(elm):
421 Return a display name of the element
423 if elm is not None:
424 name = ""
425 if 'class' in elm.attrib:
426 name = "'%s' " % elm.attrib['class']
427 if 'id' in elm.attrib:
428 id = elm.attrib['id'].encode('ascii','ignore').decode('ascii')
429 name += "'%s' " % id
430 if not name:
431 name = "'" + elm.tag + "'"
432 if lxml:
433 name += " line " + str(elm.sourceline)
434 return name
435 return ""
437 def elm_name_line(elm):
439 Return a display name of the element with line number
441 if elm is not None:
442 name = elm_name(elm)
443 if lxml and " line " not in name:
444 name += "line " + str(elm.sourceline) + " "
445 return name
446 return ""
448 def elm_line(elm):
450 Return the line for the given element.
452 if lxml:
453 return " line " + str(elm.sourceline)
454 else:
455 return ""
457 def elms_lines(elms):
459 Return the list of lines for the given elements.
461 if lxml:
462 return " lines " + ', '.join([str(l.sourceline) for l in elms])
463 else:
464 return ""
466 def elms_names_lines(elms):
468 Return the list of names and lines for the given elements.
470 return ', '.join([elm_name_line(elm) for elm in elms])
472 def elm_suppr(filename, tree, elm, msgtype, dogen):
474 Return the prefix to be displayed to the user and the suppression line for
475 the warning type "msgtype" for element "elm"
477 global gen_suppr, gen_supprfile, suppr_prefix, pflag
479 if suppressions or false_positives or gen_suppr is not None or pflag:
480 prefix = errpath(filename, tree, elm)
481 if prefix[0:len(suppr_prefix)] == suppr_prefix:
482 prefix = prefix[len(suppr_prefix):]
484 if suppressions or false_positives or gen_suppr is not None:
485 suppr = '%s %s' % (prefix, msgtype)
487 if gen_suppr is not None and msgtype is not None and dogen:
488 if gen_supprfile is None:
489 gen_supprfile = open(gen_suppr, 'w')
490 print(suppr, file=gen_supprfile)
491 else:
492 suppr = None
494 if not pflag:
495 # Use user-friendly line numbers
496 prefix = elm_prefix(filename, elm)
497 if prefix[0:len(suppr_prefix)] == suppr_prefix:
498 prefix = prefix[len(suppr_prefix):]
500 return (prefix, suppr)
502 def is_enabled(elm, msgtype, l, default):
504 Test whether warning type msgtype is enabled for elm in l
506 enabled = default
507 for (enable, thetype, klass) in l:
508 # Match warning type
509 if thetype is not None:
510 if thetype != msgtype:
511 continue
512 # Match elm class
513 if klass is not None and elm is not None:
514 if klass != elm.attrib.get('class'):
515 continue
516 enabled = enable
517 return enabled
519 def err(filename, tree, elm, msgtype, msg, error = True):
521 Emit a warning or error for an element
523 global errors, errexists, warnings, warnexists, fatals, fatalexists, output_buffer
525 # Let user tune whether a warning or error
526 fatal = is_enabled(elm, msgtype, dofatals, error)
528 # By default warnings and errors are enabled, but let user tune it
529 if not is_enabled(elm, msgtype, enables, True):
530 return
532 (prefix, suppr) = elm_suppr(filename, tree, elm, msgtype, True)
533 if suppr in false_positives:
534 # That was actually expected
535 return
536 if suppr in suppressions:
537 # Suppressed
538 suppressions[suppr] = False
539 if fatal:
540 fatalexists += 1
541 if error:
542 errexists += 1
543 else:
544 warnexists += 1
545 return
547 if error:
548 errors += 1
549 else:
550 warnings += 1
551 if fatal:
552 fatals += 1
554 msg = "%s %s%s: %s%s" % (prefix,
555 "FATAL " if fatal else "",
556 "ERROR" if error else "WARNING",
557 elm_name(elm), msg)
558 output_buffer += msg + "\n"
559 if outfile is not None:
560 print(msg, file=outfile)
562 def warn(filename, tree, elm, msgtype, msg):
564 Emit a warning for an element
566 err(filename, tree, elm, msgtype, msg, False)
569 # Labelling testing functions
572 def find_button_parent(root, elm):
574 Find a parent which is a button
576 if lxml:
577 parent = elm.getparent()
578 if parent is not None:
579 if parent.attrib.get('class') in widgets_buttons:
580 return parent
581 return find_button_parent(root, parent)
582 else:
583 def find_parent(cur, elm):
584 for o in cur:
585 if o == elm:
586 if cur.attrib.get('class') in widgets_buttons:
587 # we are the button, immediately above the target
588 return cur
589 else:
590 # we aren't the button, but target is over there
591 return True
592 parent = find_parent(o, elm)
593 if parent == True:
594 # It is over there, but didn't find a button yet
595 if cur.attrib.get('class') in widgets_buttons:
596 # we are the button
597 return cur
598 else:
599 return True
600 if parent is not None:
601 # we have the button parent over there
602 return parent
603 return None
604 parent = find_parent(root, elm)
605 if parent == True:
606 parent = None
607 return parent
610 def is_labelled_parent(elm):
612 Return whether this element is a labelled parent
614 klass = elm.attrib.get('class')
615 if klass in widgets_toplevel:
616 return True
617 if klass == 'GtkShortcutsGroup':
618 children = elm.findall("property[@name='title']")
619 if len(children) >= 1:
620 return True
621 if klass == 'GtkFrame' or klass == 'GtkNotebook':
622 children = elm.findall("child[@type='tab']") + elm.findall("child[@type='label']")
623 if len(children) >= 1:
624 return True
625 return False
627 def elm_labelled_parent(root, elm):
629 Return the first labelled parent of the element, which can thus be used as
630 the root of widgets with common labelled context
633 if lxml:
634 def find_labelled_parent(elm):
635 if is_labelled_parent(elm):
636 return elm
637 parent = elm.getparent()
638 if parent is None:
639 return None
640 return find_labelled_parent(parent)
641 parent = elm.getparent()
642 if parent is None:
643 return None
644 return find_labelled_parent(elm.getparent())
645 else:
646 def find_labelled_parent(cur, elm):
647 if cur == elm:
648 # the target element is over there
649 return True
650 for o in cur:
651 parent = find_labelled_parent(o, elm)
652 if parent == True:
653 # target element is over there, check ourself
654 if is_labelled_parent(cur):
655 # yes, and we are the first ancestor of the target element
656 return cur
657 else:
658 # no, but target element is over there.
659 return True
660 if parent != None:
661 # the first ancestor of the target element was over there
662 return parent
663 return None
664 parent = find_labelled_parent(root, elm)
665 if parent == True:
666 parent = None
667 return parent
669 def is_orphan_label(filename, tree, root, obj, orphan_root, doprint = False):
671 Check whether this label has no accessibility relation, or doubtful relation
672 because another label labels the same target
674 global label_for_elm, labelled_by_elm, mnemonic_for_elm, warnexists
676 # label-for
677 label_for = obj.findall("accessibility/relation[@type='label-for']")
678 for rel in label_for:
679 target = rel.attrib['target']
680 l = label_for_elm[target]
681 if len(l) > 1:
682 return True
684 # mnemonic_widget
685 mnemonic_for = obj.findall("property[@name='mnemonic_widget']") + \
686 obj.findall("property[@name='mnemonic-widget']")
687 for rel in mnemonic_for:
688 target = rel.text
689 l = mnemonic_for_elm[target]
690 if len(l) > 1:
691 return True
693 if len(label_for) > 0:
694 # At least one label-for, we are not orphan.
695 return False
697 if len(mnemonic_for) > 0:
698 # At least one mnemonic_widget, we are not orphan.
699 return False
701 labelled_by = obj.findall("accessibility/relation[@type='labelled-by']")
702 if len(labelled_by) > 0:
703 # Oh, a labelled label, probably not to be labelling anything
704 return False
706 # explicit role?
707 roles = [x.text for x in obj.findall("child[@internal-child='accessible']/object[@class='AtkObject']/property[@name='AtkObject::accessible-role']")]
708 roles += [x.attrib.get("type") for x in obj.findall("accessibility/role")]
709 if len(roles) > 1 and doprint:
710 err(filename, tree, obj, "multiple-role", "has multiple <child internal-child='accessible'><object class='AtkObject'><property name='AtkBoject::accessible-role'>"
711 "%s" % elms_lines(children))
712 for role in roles:
713 if role == 'static' or role == 'ATK_ROLE_STATIC':
714 # This is static text, not meant to label anything
715 return False
717 parent = elm_parent(root, obj)
718 if parent is not None:
719 childtype = parent.attrib.get('type')
720 if childtype is None:
721 childtype = parent.attrib.get('internal-child')
722 if parent.tag == 'child' and childtype == 'label' \
723 or childtype == 'tab':
724 # This is a frame or a notebook label, not orphan.
725 return False
727 if find_button_parent(root, obj) is not None:
728 # This label is part of a button
729 return False
731 oid = obj.attrib.get('id')
732 if oid is not None:
733 if oid in labelled_by_elm:
734 # Some widget is labelled by us, we are not orphan.
735 # We should have had a label-for, will warn about it later.
736 return False
738 # No label-for, no mnemonic-for, no labelled-by, we are orphan.
739 (_, suppr) = elm_suppr(filename, tree, obj, "orphan-label", False)
740 if suppr in false_positives:
741 # That was actually expected
742 return False
743 if suppr in suppressions:
744 # Warning suppressed for this label
745 if suppressions[suppr]:
746 warnexists += 1
747 suppressions[suppr] = False
748 return False
750 if doprint:
751 context = elm_name(orphan_root)
752 if context:
753 context = " within " + context
754 warn(filename, tree, obj, "orphan-label", "does not specify what it labels" + context)
755 return True
757 def is_orphan_widget(filename, tree, root, obj, orphan, orphan_root, doprint = False):
759 Check whether this widget has no accessibility relation.
761 global warnexists
762 if obj.tag != 'object':
763 return False
765 oid = obj.attrib.get('id')
766 klass = obj.attrib.get('class')
768 # "Don't care" special case
769 if klass in widgets_ignored:
770 return False
771 for suffix in widgets_suffixignored:
772 if klass[-len(suffix):] == suffix:
773 return False
775 # Widgets usual do not strictly require a label, i.e. a labelled parent
776 # is enough for context, but some do always need one.
777 requires_label = klass in widgets_needlabel
779 labelled_by = obj.findall("accessibility/relation[@type='labelled-by']")
781 # Labels special case
782 if klass in widgets_labels:
783 return False
785 # Case 1: has an explicit <child internal-child="accessible"> sub-element
786 children = obj.findall("child[@internal-child='accessible']")
787 if len(children) > 1 and doprint:
788 err(filename, tree, obj, "multiple-accessible", "has multiple <child internal-child='accessible'>"
789 "%s" % elms_lines(children))
790 if len(children) >= 1:
791 return False
793 # Case 2: has an <accessibility> sub-element with a "labelled-by"
794 # <relation> pointing to an existing element.
795 if len(labelled_by) > 0:
796 return False
798 # Case 3: has a label-for
799 if oid in label_for_elm:
800 return False
802 # Case 4: has a mnemonic
803 if oid in mnemonic_for_elm:
804 return False
806 # Case 5: Has a <property name="tooltip_text">
807 tooltips = obj.findall("property[@name='tooltip_text']") + \
808 obj.findall("property[@name='tooltip-text']")
809 if len(tooltips) > 1 and doprint:
810 err(filename, tree, obj, "multiple-tooltip", "has multiple tooltip_text properties")
811 if len(tooltips) >= 1 and klass != 'GtkCheckButton':
812 return False
814 # Case 6: Has a <property name="placeholder_text">
815 placeholders = obj.findall("property[@name='placeholder_text']") + \
816 obj.findall("property[@name='placeholder-text']")
817 if len(placeholders) > 1 and doprint:
818 err(filename, tree, obj, "multiple-placeholder", "has multiple placeholder_text properties")
819 if len(placeholders) >= 1:
820 return False
822 # Buttons usually don't need an external label, their own is enough, (but they do need one)
823 if klass in widgets_buttons:
825 labels = obj.findall("property[@name='label']")
826 if len(labels) > 1 and doprint:
827 err(filename, tree, obj, "multiple-label", "has multiple label properties")
828 if len(labels) >= 1:
829 # Has a <property name="label">
830 return False
832 actions = obj.findall("property[@name='action_name']")
833 if len(actions) > 1 and doprint:
834 err(filename, tree, obj, "multiple-action_name", "has multiple action_name properties")
835 if len(actions) >= 1:
836 # Has a <property name="action_name">
837 return False
839 # Uses id as an action_name
840 if 'id' in obj.attrib:
841 if obj.attrib['id'].startswith(".uno:"):
842 return False
844 gtklabels = obj.findall(".//object[@class='GtkLabel']") + obj.findall(".//object[@class='GtkAccelLabel']")
845 if len(gtklabels) >= 1:
846 # Has a custom label
847 return False
849 # no label for a button, warn
850 if doprint:
851 warn(filename, tree, obj, "button-no-label", "does not have its own label")
852 if not is_enabled(obj, "button-no-label", enables, True):
853 # Warnings disabled
854 return False
855 (_, suppr) = elm_suppr(filename, tree, obj, "button-no-label", False)
856 if suppr in false_positives:
857 # That was actually expected
858 return False
859 if suppr in suppressions:
860 # Warning suppressed for this widget
861 if suppressions[suppr]:
862 warnexists += 1
863 suppressions[suppr] = False
864 return False
865 return True
867 # GtkImages special case
868 if klass == "GtkImage":
869 uses = [u for u in tree.iterfind(".//object/property[@name='image']") if u.text == oid]
870 if len(uses) > 0:
871 # This image is just used by another element, don't warn
872 # about the image itself, we probably want the warning on
873 # the element instead.
874 return False
876 if find_button_parent(root, obj) is not None:
877 # This image is part of a button, we want the warning on the button
878 # instead, if any.
879 return False
881 # GtkEntry special case
882 if klass == 'GtkEntry' or klass == 'GtkSearchEntry':
883 parent = elm_parent(root, obj)
884 if parent is not None:
885 if parent.tag == 'child' and \
886 parent.attrib.get('internal-child') == "entry":
887 # This is an internal entry of another widget. Relations
888 # will be handled by that widget.
889 return False
891 # GtkShortcutsShortcut special case
892 if klass == 'GtkShortcutsShortcut':
893 children = obj.findall("property[@name='title']")
894 if len(children) >= 1:
895 return False
897 # Really no label, perhaps emit a warning
898 if not is_enabled(obj, "no-labelled-by", enables, True):
899 # Warnings disabled for this class of widgets
900 return False
901 (_, suppr) = elm_suppr(filename, tree, obj, "no-labelled-by", False)
902 if suppr in false_positives:
903 # That was actually expected
904 return False
905 if suppr in suppressions:
906 # Warning suppressed for this widget
907 if suppressions[suppr]:
908 warnexists += 1
909 suppressions[suppr] = False
910 return False
912 if not orphan:
913 # No orphan label, so probably the labelled parent provides enough
914 # context.
915 if requires_label:
916 # But these always need a label.
917 if doprint:
918 warn(filename, tree, obj, "no-labelled-by", "has no accessibility label")
919 return True
920 return False
922 if doprint:
923 context = elm_name(orphan_root)
924 if context:
925 context = " within " + context
926 warn(filename, tree, obj, "no-labelled-by", "has no accessibility label while there are orphan labels" + context)
927 return True
929 def orphan_items(filename, tree, root, elm):
931 Check whether from some element there exists orphan labels and orphan widgets
933 orphan_labels = False
934 orphan_widgets = False
935 if elm.attrib.get('class') in widgets_labels:
936 orphan_labels = is_orphan_label(filename, tree, root, elm, None)
937 else:
938 orphan_widgets = is_orphan_widget(filename, tree, root, elm, True, None)
939 for obj in elm:
940 # We are not interested in orphan labels under another labelled
941 # parent. This also allows to keep linear complexity.
942 if not is_labelled_parent(obj):
943 label, widget = orphan_items(filename, tree, root, obj)
944 if label:
945 orphan_labels = True
946 if widget:
947 orphan_widgets = True
948 if orphan_labels and orphan_widgets:
949 # No need to look up more
950 break
951 return orphan_labels, orphan_widgets
954 # UI accessibility checks
957 def check_props(filename, tree, root, elm, forward):
959 Check the given list of relation properties
961 props = elm.findall("property[@name='" + forward + "']")
962 for prop in props:
963 if prop.text not in ids:
964 err(filename, tree, elm, "undeclared-target", forward + " uses undeclared target '%s'" % prop.text)
965 return props
967 def is_visible(obj):
968 visible = False
969 visible_prop = obj.findall("property[@name='visible']")
970 visible_len = len(visible_prop)
971 if visible_len:
972 visible_txt = visible_prop[visible_len - 1].text
973 if visible_txt.lower() == "true":
974 visible = True
975 elif visible_txt.lower() == "false":
976 visible = False
977 return visible
979 def check_rels(filename, tree, root, elm, forward, backward = None):
981 Check the relations given by forward
983 oid = elm.attrib.get('id')
984 rels = elm.findall("accessibility/relation[@type='" + forward + "']")
985 for rel in rels:
986 target = rel.attrib['target']
987 if target not in ids:
988 err(filename, tree, elm, "undeclared-target", forward + " uses undeclared target '%s'" % target)
989 elif backward is not None:
990 widget = ids[target]
991 backrels = widget.findall("accessibility/relation[@type='" + backward + "']")
992 if len([x for x in backrels if x.attrib['target'] == oid]) == 0:
993 err(filename, tree, elm, "missing-" + backward, "has " + forward + \
994 ", but is not " + backward + " by " + elm_name_line(widget))
995 return rels
997 def check_a11y_relation(filename, tree):
999 Emit an error message if any of the 'object' elements of the XML
1000 document represented by `root' doesn't comply with Accessibility
1001 rules.
1003 global widgets_ignored, ids, label_for_elm, labelled_by_elm, mnemonic_for_elm
1005 def check_elm(orphan_root, obj, orphan_labels, orphan_widgets):
1007 Check one element, knowing that orphan_labels/widgets tell whether
1008 there are orphan labels and widgets within orphan_root
1011 oid = obj.attrib.get('id')
1012 klass = obj.attrib.get('class')
1014 # "Don't care" special case
1015 if klass in widgets_ignored:
1016 return
1017 for suffix in widgets_suffixignored:
1018 if klass[-len(suffix):] == suffix:
1019 return
1021 # Widgets usual do not strictly require a label, i.e. a labelled parent
1022 # is enough for context, but some do always need one.
1023 requires_label = klass in widgets_needlabel
1025 if oid is not None:
1026 # Check that ids are unique
1027 if oid in ids_dup:
1028 if ids[oid] == obj:
1029 # We are the first, warn
1030 duplicates = tree.findall(".//object[@id='" + oid + "']")
1031 err(filename, tree, obj, "duplicate-id", "has the same id as other elements " + elms_names_lines(duplicates))
1033 # Check label-for and their dual labelled-by
1034 label_for = check_rels(filename, tree, root, obj, "label-for", "labelled-by")
1036 # Check labelled-by and its dual label-for
1037 labelled_by = check_rels(filename, tree, root, obj, "labelled-by", "label-for")
1039 visible = is_visible(obj)
1041 # warning message type "syntax" used:
1043 # multiple-* => 2+ XML tags of the inspected element itself
1044 # duplicate-* => 2+ XML tags of other elements referencing this element
1046 # Should have only one label
1047 if len(labelled_by) >= 1:
1048 if oid in mnemonic_for_elm:
1049 warn(filename, tree, obj, "labelled-by-and-mnemonic",
1050 "has both a mnemonic " + elm_name_line(mnemonic_for_elm[oid][0]) + "and labelled-by relation")
1051 if len(labelled_by) > 1:
1052 warn(filename, tree, obj, "multiple-labelled-by", "has multiple labelled-by relations")
1054 if oid in labelled_by_elm:
1055 if len(labelled_by_elm[oid]) == 1:
1056 paired = labelled_by_elm[oid][0]
1057 if paired != None and visible != is_visible(paired):
1058 warn(filename, tree, obj, "visibility-conflict", "visibility conflicts with paired " + elm_name_line(paired))
1060 if oid in label_for_elm:
1061 if len(label_for_elm[oid]) > 1:
1062 warn(filename, tree, obj, "duplicate-label-for", "is referenced by multiple label-for " + elms_names_lines(label_for_elm[oid]))
1063 elif len(label_for_elm[oid]) == 1:
1064 paired = label_for_elm[oid][0]
1065 if visible != is_visible(paired):
1066 warn(filename, tree, obj, "visibility-conflict", "visibility conflicts with paired " + elm_name_line(paired))
1068 if oid in mnemonic_for_elm:
1069 if len(mnemonic_for_elm[oid]) > 1:
1070 warn(filename, tree, obj, "duplicate-mnemonic", "is referenced by multiple mnemonic_widget " + elms_names_lines(mnemonic_for_elm[oid]))
1072 # Check controlled-by/controller-for
1073 controlled_by = check_rels(filename, tree, root, obj, "controlled-by", "controller-for")
1074 controller_for = check_rels(filename, tree, root, obj, "controlled-for", "controlled-by")
1076 # Labels special case
1077 if klass in widgets_labels:
1078 properties = check_props(filename, tree, root, obj, "mnemonic_widget") + \
1079 check_props(filename, tree, root, obj, "mnemonic-widget")
1080 if len(properties) > 1:
1081 err(filename, tree, obj, "multiple-mnemonic", "has multiple mnemonic_widgets properties"
1082 "%s" % elms_lines(properties))
1084 # Emit orphaning warnings
1085 if warn_orphan_labels or orphan_widgets:
1086 is_orphan_label(filename, tree, root, obj, orphan_root, True)
1088 # We are done with the label
1089 return
1091 # Not a label, will perhaps need one
1093 # Emit orphaning warnings
1094 is_orphan_widget(filename, tree, root, obj, orphan_labels, orphan_root, True)
1096 root = tree.getroot()
1098 # Flush ids and relations from previous files
1099 ids = {}
1100 ids_dup = {}
1101 labelled_by_elm = {}
1102 label_for_elm = {}
1103 mnemonic_for_elm = {}
1105 # First pass to get links into hash tables, no warning, just record duplicates
1106 for obj in root.iter('object'):
1107 oid = obj.attrib.get('id')
1108 if oid is not None:
1109 if oid not in ids:
1110 ids[oid] = obj
1111 else:
1112 ids_dup[oid] = True
1114 labelled_by = obj.findall("accessibility/relation[@type='labelled-by']")
1115 for rel in labelled_by:
1116 target = rel.attrib.get('target')
1117 if target is not None:
1118 if target not in labelled_by_elm:
1119 labelled_by_elm[target] = [ obj ]
1120 else:
1121 labelled_by_elm[target].append(obj)
1123 label_for = obj.findall("accessibility/relation[@type='label-for']")
1124 for rel in label_for:
1125 target = rel.attrib.get('target')
1126 if target is not None:
1127 if target not in label_for_elm:
1128 label_for_elm[target] = [ obj ]
1129 else:
1130 label_for_elm[target].append(obj)
1132 mnemonic_for = obj.findall("property[@name='mnemonic_widget']") + \
1133 obj.findall("property[@name='mnemonic-widget']")
1134 for rel in mnemonic_for:
1135 target = rel.text
1136 if target is not None:
1137 if target not in mnemonic_for_elm:
1138 mnemonic_for_elm[target] = [ obj ]
1139 else:
1140 mnemonic_for_elm[target].append(obj)
1142 # Second pass, recursive depth-first, to be able to efficiently know whether
1143 # there are orphan labels within a part of the tree.
1144 def recurse(orphan_root, obj, orphan_labels, orphan_widgets):
1145 if obj == root or is_labelled_parent(obj):
1146 orphan_root = obj
1147 orphan_labels, orphan_widgets = orphan_items(filename, tree, root, obj)
1149 if obj.tag == 'object':
1150 check_elm(orphan_root, obj, orphan_labels, orphan_widgets)
1152 for o in obj:
1153 recurse(orphan_root, o, orphan_labels, orphan_widgets)
1155 recurse(root, root, False, False)
1158 # Main
1161 def usage(fatal = True):
1162 print("`%s' checks accessibility of glade .ui files" % progname)
1163 print("")
1164 print("Usage: %s [-p] [-g SUPPR_FILE] [-s SUPPR_FILE] [-f SUPPR_FILE] [-P PREFIX] [-o LOG_FILE] [file ...]" % progname)
1165 print("")
1166 print(" -p Print XML class path instead of line number")
1167 print(" -g Generate suppression file SUPPR_FILE")
1168 print(" -s Suppress warnings given by file SUPPR_FILE, but count them")
1169 print(" -f Suppress warnings given by file SUPPR_FILE completely")
1170 print(" -P Remove PREFIX from file names in warnings")
1171 print(" -o Also prints errors and warnings to given file")
1172 print("")
1173 print(" --widgets-FOO [+][CLASS1[,CLASS2[,...]]]")
1174 print(" Give or extend one of the lists of widget classes, where FOO can be:")
1175 print(" - toplevel : widgets to be considered toplevel windows")
1176 print(" - ignored : widgets which do not need labelling (e.g. GtkBox)")
1177 print(" - suffixignored : suffixes of widget classes which do not need labelling")
1178 print(" - needlabel : widgets which always need labelling (e.g. GtkEntry)")
1179 print(" - buttons : widgets which need their own label but not more")
1180 print(" (e.g. GtkButton)")
1181 print(" - labels : widgets which provide labels (e.g. GtkLabel)")
1182 print(" --widgets-print print default widgets lists")
1183 print("")
1184 print(" --enable-all enable all warnings/dofatals (default)")
1185 print(" --disable-all disable all warnings/dofatals")
1186 print(" --fatal-all make all warnings dofatals")
1187 print(" --not-fatal-all do not make all warnings dofatals (default)")
1188 print("")
1189 print(" --enable-type=TYPE enable warning/fatal type TYPE")
1190 print(" --disable-type=TYPE disable warning/fatal type TYPE")
1191 print(" --fatal-type=TYPE make warning type TYPE a fatal")
1192 print(" --not-fatal-type=TYPE make warning type TYPE not a fatal")
1193 print("")
1194 print(" --enable-widgets=CLASS enable warning/fatal type CLASS")
1195 print(" --disable-widgets=CLASS disable warning/fatal type CLASS")
1196 print(" --fatal-widgets=CLASS make warning type CLASS a fatal")
1197 print(" --not-fatal-widgets=CLASS make warning type CLASS not a fatal")
1198 print("")
1199 print(" --enable-specific=TYPE.CLASS enable warning/fatal type TYPE for widget")
1200 print(" class CLASS")
1201 print(" --disable-specific=TYPE.CLASS disable warning/fatal type TYPE for widget")
1202 print(" class CLASS")
1203 print(" --fatal-specific=TYPE.CLASS make warning type TYPE a fatal for widget")
1204 print(" class CLASS")
1205 print(" --not-fatal-specific=TYPE.CLASS make warning type TYPE not a fatal for widget")
1206 print(" class CLASS")
1207 print("")
1208 print(" --disable-orphan-labels only warn about orphan labels when there are")
1209 print(" orphan widgets in the same context")
1210 print("")
1211 print("Report bugs to <bugs@hypra.fr>")
1212 sys.exit(2 if fatal else 0)
1214 def widgets_opt(widgets_list, arg):
1216 Replace or extend `widgets_list' with the list of classes contained in `arg'
1218 append = arg and arg[0] == '+'
1219 if append:
1220 arg = arg[1:]
1222 if arg:
1223 widgets = arg.split(',')
1224 else:
1225 widgets = []
1227 if not append:
1228 del widgets_list[:]
1230 widgets_list.extend(widgets)
1233 def main():
1234 global pflag, gen_suppr, gen_supprfile, suppressions, suppr_prefix, false_positives, dofatals, enables, dofatals, warn_orphan_labels
1235 global widgets_toplevel, widgets_ignored, widgets_suffixignored, widgets_needlabel, widgets_buttons, widgets_labels
1236 global outfile, output_buffer
1238 try:
1239 opts, args = getopt.getopt(sys.argv[1:], "hpiIg:s:f:P:o:L:", [
1240 "help",
1241 "version",
1243 "widgets-toplevel=",
1244 "widgets-ignored=",
1245 "widgets-suffixignored=",
1246 "widgets-needlabel=",
1247 "widgets-buttons=",
1248 "widgets-labels=",
1249 "widgets-print",
1251 "enable-all",
1252 "disable-all",
1253 "fatal-all",
1254 "not-fatal-all",
1256 "enable-type=",
1257 "disable-type=",
1258 "fatal-type=",
1259 "not-fatal-type=",
1261 "enable-widgets=",
1262 "disable-widgets=",
1263 "fatal-widgets=",
1264 "not-fatal-widgets=",
1266 "enable-specific=",
1267 "disable-specific=",
1268 "fatal-specific=",
1269 "not-fatal-specific=",
1271 "disable-orphan-labels",
1273 except getopt.GetoptError:
1274 usage()
1276 suppr = None
1277 false = None
1278 out = None
1279 filelist = None
1281 for o, a in opts:
1282 if o == "--help" or o == "-h":
1283 usage(False)
1284 if o == "--version":
1285 print("0.1")
1286 sys.exit(0)
1287 elif o == "-p":
1288 pflag = True
1289 elif o == "-g":
1290 gen_suppr = a
1291 elif o == "-s":
1292 suppr = a
1293 elif o == "-f":
1294 false = a
1295 elif o == "-P":
1296 suppr_prefix = a
1297 elif o == "-o":
1298 out = a
1299 elif o == "-L":
1300 filelist = a
1302 elif o == "--widgets-toplevel":
1303 widgets_opt(widgets_toplevel, a)
1304 elif o == "--widgets-ignored":
1305 widgets_opt(widgets_ignored, a)
1306 elif o == "--widgets-suffixignored":
1307 widgets_opt(widgets_suffixignored, a)
1308 elif o == "--widgets-needlabel":
1309 widgets_opt(widgets_needlabel, a)
1310 elif o == "--widgets-buttons":
1311 widgets_opt(widgets_buttons, a)
1312 elif o == "--widgets-labels":
1313 widgets_opt(widgets_labels, a)
1314 elif o == "--widgets-print":
1315 print("--widgets-toplevel '" + ','.join(widgets_toplevel) + "'")
1316 print("--widgets-ignored '" + ','.join(widgets_ignored) + "'")
1317 print("--widgets-suffixignored '" + ','.join(widgets_suffixignored) + "'")
1318 print("--widgets-needlabel '" + ','.join(widgets_needlabel) + "'")
1319 print("--widgets-buttons '" + ','.join(widgets_buttons) + "'")
1320 print("--widgets-labels '" + ','.join(widgets_labels) + "'")
1321 sys.exit(0)
1323 elif o == '--enable-all':
1324 enables.append( (True, None, None) )
1325 elif o == '--disable-all':
1326 enables.append( (False, None, None) )
1327 elif o == '--fatal-all':
1328 dofatals.append( (True, None, None) )
1329 elif o == '--not-fatal-all':
1330 dofatals.append( (False, None, None) )
1332 elif o == '--enable-type':
1333 enables.append( (True, a, None) )
1334 elif o == '--disable-type':
1335 enables.append( (False, a, None) )
1336 elif o == '--fatal-type':
1337 dofatals.append( (True, a, None) )
1338 elif o == '--not-fatal-type':
1339 dofatals.append( (False, a, None) )
1341 elif o == '--enable-widgets':
1342 enables.append( (True, None, a) )
1343 elif o == '--disable-widgets':
1344 enables.append( (False, None, a) )
1345 elif o == '--fatal-widgets':
1346 dofatals.append( (True, None, a) )
1347 elif o == '--not-fatal-widgets':
1348 dofatals.append( (False, None, a) )
1350 elif o == '--enable-specific':
1351 (thetype, klass) = a.split('.', 1)
1352 enables.append( (True, thetype, klass) )
1353 elif o == '--disable-specific':
1354 (thetype, klass) = a.split('.', 1)
1355 enables.append( (False, thetype, klass) )
1356 elif o == '--fatal-specific':
1357 (thetype, klass) = a.split('.', 1)
1358 dofatals.append( (True, thetype, klass) )
1359 elif o == '--not-fatal-specific':
1360 (thetype, klass) = a.split('.', 1)
1361 dofatals.append( (False, thetype, klass) )
1363 elif o == '--disable-orphan-labels':
1364 warn_orphan_labels = False
1366 output_header = ""
1368 # Read suppression file before overwriting it
1369 if suppr is not None:
1370 try:
1371 output_header += "Suppression file: " + suppr + "\n"
1372 supprfile = open(suppr, 'r')
1373 line_no = 0
1374 for line in supprfile.readlines():
1375 line_no = line_no + 1
1376 if line.startswith('#'):
1377 continue
1378 prefix = line.rstrip()
1379 suppressions[prefix] = True
1380 suppressions_to_line[prefix] = "%s:%u" % (suppr, line_no)
1381 supprfile.close()
1382 except IOError:
1383 pass
1385 # Read false positives file
1386 if false is not None:
1387 try:
1388 output_header += "False positive file: " + false + "\n"
1389 falsefile = open(false, 'r')
1390 for line in falsefile.readlines():
1391 if line.startswith('#'):
1392 continue
1393 prefix = line.rstrip()
1394 false_positives[prefix] = True
1395 falsefile.close()
1396 except IOError:
1397 pass
1399 if out is not None:
1400 outfile = open(out, 'w')
1402 if filelist is not None:
1403 try:
1404 filelistfile = open(filelist, 'r')
1405 for line in filelistfile.readlines():
1406 line = line.strip()
1407 if line:
1408 args += line.split(' ')
1409 filelistfile.close()
1410 except IOError:
1411 err(filelist, None, None, "unable to read file list file")
1413 for filename in args:
1414 try:
1415 tree = ET.parse(filename)
1416 except ET.ParseError:
1417 err(filename, None, None, "parse", "malformatted xml file")
1418 continue
1419 except IOError:
1420 err(filename, None, None, None, "unable to read file")
1421 continue
1423 try:
1424 check_a11y_relation(filename, tree)
1425 except Exception as error:
1426 import traceback
1427 output_buffer += traceback.format_exc()
1428 err(filename, None, None, "parse", "error parsing file")
1430 if errors > 0 or errexists > 0:
1431 output_buffer += "%s new error%s" % (errors, 's' if errors != 1 else '')
1432 if errexists > 0:
1433 output_buffer += " (%s suppressed by %s, please fix %s)" % (errexists, suppr, 'them' if errexists > 1 else 'it')
1434 output_buffer += "\n"
1436 if warnings > 0 or warnexists > 0:
1437 output_buffer += "%s new warning%s" % (warnings, 's' if warnings != 1 else '')
1438 if warnexists > 0:
1439 output_buffer += " (%s suppressed by %s, please fix %s)" % (warnexists, suppr, 'them' if warnexists > 1 else 'it')
1440 output_buffer += "\n"
1442 if fatals > 0 or fatalexists > 0:
1443 output_buffer += "%s new fatal%s" % (fatals, 's' if fatals != 1 else '')
1444 if fatalexists > 0:
1445 output_buffer += " (%s suppressed by %s, please fix %s)" % (fatalexists, suppr, 'them' if fatalexists > 1 else 'it')
1446 output_buffer += "\n"
1448 n = 0
1449 for (suppr,unused) in suppressions.items():
1450 if unused:
1451 n += 1
1453 if n > 0:
1454 output_buffer += "%s suppression%s unused:\n" % (n, 's' if n != 1 else '')
1455 for (suppr,unused) in suppressions.items():
1456 if unused:
1457 output_buffer += " %s:%s\n" % (suppressions_to_line[suppr], suppr)
1459 if gen_supprfile is not None:
1460 gen_supprfile.close()
1461 if outfile is not None:
1462 outfile.close()
1464 if gen_suppr is None:
1465 if output_buffer != "":
1466 output_buffer += "Explanations are available on " + howto_url + "\n"
1468 if fatals > 0:
1469 print(output_header.rstrip() + "\n" + output_buffer)
1470 sys.exit(1)
1472 if len(output_buffer) > 0:
1473 print(output_header.rstrip() + "\n" + output_buffer)
1475 if __name__ == "__main__":
1476 try:
1477 main()
1478 except KeyboardInterrupt:
1479 pass
1481 # vim: set shiftwidth=4 softtabstop=4 expandtab: