better name for a function overload
[LibreOffice.git] / bin / gla11y
blob7376f672d866cbfa5009f58df6fdf6f4bf062889
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 member-of
1073 member_of = check_rels(filename, tree, root, obj, "member-of")
1075 # Labels special case
1076 if klass in widgets_labels:
1077 properties = check_props(filename, tree, root, obj, "mnemonic_widget") + \
1078 check_props(filename, tree, root, obj, "mnemonic-widget")
1079 if len(properties) > 1:
1080 err(filename, tree, obj, "multiple-mnemonic", "has multiple mnemonic_widgets properties"
1081 "%s" % elms_lines(properties))
1083 # Emit orphaning warnings
1084 if warn_orphan_labels or orphan_widgets:
1085 is_orphan_label(filename, tree, root, obj, orphan_root, True)
1087 # We are done with the label
1088 return
1090 # Not a label, will perhaps need one
1092 # Emit orphaning warnings
1093 is_orphan_widget(filename, tree, root, obj, orphan_labels, orphan_root, True)
1095 root = tree.getroot()
1097 # Flush ids and relations from previous files
1098 ids = {}
1099 ids_dup = {}
1100 labelled_by_elm = {}
1101 label_for_elm = {}
1102 mnemonic_for_elm = {}
1104 # First pass to get links into hash tables, no warning, just record duplicates
1105 for obj in root.iter('object'):
1106 oid = obj.attrib.get('id')
1107 if oid is not None:
1108 if oid not in ids:
1109 ids[oid] = obj
1110 else:
1111 ids_dup[oid] = True
1113 labelled_by = obj.findall("accessibility/relation[@type='labelled-by']")
1114 for rel in labelled_by:
1115 target = rel.attrib.get('target')
1116 if target is not None:
1117 if target not in labelled_by_elm:
1118 labelled_by_elm[target] = [ obj ]
1119 else:
1120 labelled_by_elm[target].append(obj)
1122 label_for = obj.findall("accessibility/relation[@type='label-for']")
1123 for rel in label_for:
1124 target = rel.attrib.get('target')
1125 if target is not None:
1126 if target not in label_for_elm:
1127 label_for_elm[target] = [ obj ]
1128 else:
1129 label_for_elm[target].append(obj)
1131 mnemonic_for = obj.findall("property[@name='mnemonic_widget']") + \
1132 obj.findall("property[@name='mnemonic-widget']")
1133 for rel in mnemonic_for:
1134 target = rel.text
1135 if target is not None:
1136 if target not in mnemonic_for_elm:
1137 mnemonic_for_elm[target] = [ obj ]
1138 else:
1139 mnemonic_for_elm[target].append(obj)
1141 # Second pass, recursive depth-first, to be able to efficiently know whether
1142 # there are orphan labels within a part of the tree.
1143 def recurse(orphan_root, obj, orphan_labels, orphan_widgets):
1144 if obj == root or is_labelled_parent(obj):
1145 orphan_root = obj
1146 orphan_labels, orphan_widgets = orphan_items(filename, tree, root, obj)
1148 if obj.tag == 'object':
1149 check_elm(orphan_root, obj, orphan_labels, orphan_widgets)
1151 for o in obj:
1152 recurse(orphan_root, o, orphan_labels, orphan_widgets)
1154 recurse(root, root, False, False)
1157 # Main
1160 def usage(fatal = True):
1161 print("`%s' checks accessibility of glade .ui files" % progname)
1162 print("")
1163 print("Usage: %s [-p] [-g SUPPR_FILE] [-s SUPPR_FILE] [-f SUPPR_FILE] [-P PREFIX] [-o LOG_FILE] [file ...]" % progname)
1164 print("")
1165 print(" -p Print XML class path instead of line number")
1166 print(" -g Generate suppression file SUPPR_FILE")
1167 print(" -s Suppress warnings given by file SUPPR_FILE, but count them")
1168 print(" -f Suppress warnings given by file SUPPR_FILE completely")
1169 print(" -P Remove PREFIX from file names in warnings")
1170 print(" -o Also prints errors and warnings to given file")
1171 print("")
1172 print(" --widgets-FOO [+][CLASS1[,CLASS2[,...]]]")
1173 print(" Give or extend one of the lists of widget classes, where FOO can be:")
1174 print(" - toplevel : widgets to be considered toplevel windows")
1175 print(" - ignored : widgets which do not need labelling (e.g. GtkBox)")
1176 print(" - suffixignored : suffixes of widget classes which do not need labelling")
1177 print(" - needlabel : widgets which always need labelling (e.g. GtkEntry)")
1178 print(" - buttons : widgets which need their own label but not more")
1179 print(" (e.g. GtkButton)")
1180 print(" - labels : widgets which provide labels (e.g. GtkLabel)")
1181 print(" --widgets-print print default widgets lists")
1182 print("")
1183 print(" --enable-all enable all warnings/dofatals (default)")
1184 print(" --disable-all disable all warnings/dofatals")
1185 print(" --fatal-all make all warnings dofatals")
1186 print(" --not-fatal-all do not make all warnings dofatals (default)")
1187 print("")
1188 print(" --enable-type=TYPE enable warning/fatal type TYPE")
1189 print(" --disable-type=TYPE disable warning/fatal type TYPE")
1190 print(" --fatal-type=TYPE make warning type TYPE a fatal")
1191 print(" --not-fatal-type=TYPE make warning type TYPE not a fatal")
1192 print("")
1193 print(" --enable-widgets=CLASS enable warning/fatal type CLASS")
1194 print(" --disable-widgets=CLASS disable warning/fatal type CLASS")
1195 print(" --fatal-widgets=CLASS make warning type CLASS a fatal")
1196 print(" --not-fatal-widgets=CLASS make warning type CLASS not a fatal")
1197 print("")
1198 print(" --enable-specific=TYPE.CLASS enable warning/fatal type TYPE for widget")
1199 print(" class CLASS")
1200 print(" --disable-specific=TYPE.CLASS disable warning/fatal type TYPE for widget")
1201 print(" class CLASS")
1202 print(" --fatal-specific=TYPE.CLASS make warning type TYPE a fatal for widget")
1203 print(" class CLASS")
1204 print(" --not-fatal-specific=TYPE.CLASS make warning type TYPE not a fatal for widget")
1205 print(" class CLASS")
1206 print("")
1207 print(" --disable-orphan-labels only warn about orphan labels when there are")
1208 print(" orphan widgets in the same context")
1209 print("")
1210 print("Report bugs to <bugs@hypra.fr>")
1211 sys.exit(2 if fatal else 0)
1213 def widgets_opt(widgets_list, arg):
1215 Replace or extend `widgets_list' with the list of classes contained in `arg'
1217 append = arg and arg[0] == '+'
1218 if append:
1219 arg = arg[1:]
1221 if arg:
1222 widgets = arg.split(',')
1223 else:
1224 widgets = []
1226 if not append:
1227 del widgets_list[:]
1229 widgets_list.extend(widgets)
1232 def main():
1233 global pflag, gen_suppr, gen_supprfile, suppressions, suppr_prefix, false_positives, dofatals, enables, dofatals, warn_orphan_labels
1234 global widgets_toplevel, widgets_ignored, widgets_suffixignored, widgets_needlabel, widgets_buttons, widgets_labels
1235 global outfile, output_buffer
1237 try:
1238 opts, args = getopt.getopt(sys.argv[1:], "hpiIg:s:f:P:o:L:", [
1239 "help",
1240 "version",
1242 "widgets-toplevel=",
1243 "widgets-ignored=",
1244 "widgets-suffixignored=",
1245 "widgets-needlabel=",
1246 "widgets-buttons=",
1247 "widgets-labels=",
1248 "widgets-print",
1250 "enable-all",
1251 "disable-all",
1252 "fatal-all",
1253 "not-fatal-all",
1255 "enable-type=",
1256 "disable-type=",
1257 "fatal-type=",
1258 "not-fatal-type=",
1260 "enable-widgets=",
1261 "disable-widgets=",
1262 "fatal-widgets=",
1263 "not-fatal-widgets=",
1265 "enable-specific=",
1266 "disable-specific=",
1267 "fatal-specific=",
1268 "not-fatal-specific=",
1270 "disable-orphan-labels",
1272 except getopt.GetoptError:
1273 usage()
1275 suppr = None
1276 false = None
1277 out = None
1278 filelist = None
1280 for o, a in opts:
1281 if o == "--help" or o == "-h":
1282 usage(False)
1283 if o == "--version":
1284 print("0.1")
1285 sys.exit(0)
1286 elif o == "-p":
1287 pflag = True
1288 elif o == "-g":
1289 gen_suppr = a
1290 elif o == "-s":
1291 suppr = a
1292 elif o == "-f":
1293 false = a
1294 elif o == "-P":
1295 suppr_prefix = a
1296 elif o == "-o":
1297 out = a
1298 elif o == "-L":
1299 filelist = a
1301 elif o == "--widgets-toplevel":
1302 widgets_opt(widgets_toplevel, a)
1303 elif o == "--widgets-ignored":
1304 widgets_opt(widgets_ignored, a)
1305 elif o == "--widgets-suffixignored":
1306 widgets_opt(widgets_suffixignored, a)
1307 elif o == "--widgets-needlabel":
1308 widgets_opt(widgets_needlabel, a)
1309 elif o == "--widgets-buttons":
1310 widgets_opt(widgets_buttons, a)
1311 elif o == "--widgets-labels":
1312 widgets_opt(widgets_labels, a)
1313 elif o == "--widgets-print":
1314 print("--widgets-toplevel '" + ','.join(widgets_toplevel) + "'")
1315 print("--widgets-ignored '" + ','.join(widgets_ignored) + "'")
1316 print("--widgets-suffixignored '" + ','.join(widgets_suffixignored) + "'")
1317 print("--widgets-needlabel '" + ','.join(widgets_needlabel) + "'")
1318 print("--widgets-buttons '" + ','.join(widgets_buttons) + "'")
1319 print("--widgets-labels '" + ','.join(widgets_labels) + "'")
1320 sys.exit(0)
1322 elif o == '--enable-all':
1323 enables.append( (True, None, None) )
1324 elif o == '--disable-all':
1325 enables.append( (False, None, None) )
1326 elif o == '--fatal-all':
1327 dofatals.append( (True, None, None) )
1328 elif o == '--not-fatal-all':
1329 dofatals.append( (False, None, None) )
1331 elif o == '--enable-type':
1332 enables.append( (True, a, None) )
1333 elif o == '--disable-type':
1334 enables.append( (False, a, None) )
1335 elif o == '--fatal-type':
1336 dofatals.append( (True, a, None) )
1337 elif o == '--not-fatal-type':
1338 dofatals.append( (False, a, None) )
1340 elif o == '--enable-widgets':
1341 enables.append( (True, None, a) )
1342 elif o == '--disable-widgets':
1343 enables.append( (False, None, a) )
1344 elif o == '--fatal-widgets':
1345 dofatals.append( (True, None, a) )
1346 elif o == '--not-fatal-widgets':
1347 dofatals.append( (False, None, a) )
1349 elif o == '--enable-specific':
1350 (thetype, klass) = a.split('.', 1)
1351 enables.append( (True, thetype, klass) )
1352 elif o == '--disable-specific':
1353 (thetype, klass) = a.split('.', 1)
1354 enables.append( (False, thetype, klass) )
1355 elif o == '--fatal-specific':
1356 (thetype, klass) = a.split('.', 1)
1357 dofatals.append( (True, thetype, klass) )
1358 elif o == '--not-fatal-specific':
1359 (thetype, klass) = a.split('.', 1)
1360 dofatals.append( (False, thetype, klass) )
1362 elif o == '--disable-orphan-labels':
1363 warn_orphan_labels = False
1365 output_header = ""
1367 # Read suppression file before overwriting it
1368 if suppr is not None:
1369 try:
1370 output_header += "Suppression file: " + suppr + "\n"
1371 supprfile = open(suppr, 'r')
1372 line_no = 0
1373 for line in supprfile.readlines():
1374 line_no = line_no + 1
1375 if line.startswith('#'):
1376 continue
1377 prefix = line.rstrip()
1378 suppressions[prefix] = True
1379 suppressions_to_line[prefix] = "%s:%u" % (suppr, line_no)
1380 supprfile.close()
1381 except IOError:
1382 pass
1384 # Read false positives file
1385 if false is not None:
1386 try:
1387 output_header += "False positive file: " + false + "\n"
1388 falsefile = open(false, 'r')
1389 for line in falsefile.readlines():
1390 if line.startswith('#'):
1391 continue
1392 prefix = line.rstrip()
1393 false_positives[prefix] = True
1394 falsefile.close()
1395 except IOError:
1396 pass
1398 if out is not None:
1399 outfile = open(out, 'w')
1401 if filelist is not None:
1402 try:
1403 filelistfile = open(filelist, 'r')
1404 for line in filelistfile.readlines():
1405 line = line.strip()
1406 if line:
1407 args += line.split(' ')
1408 filelistfile.close()
1409 except IOError:
1410 err(filelist, None, None, "unable to read file list file")
1412 for filename in args:
1413 try:
1414 tree = ET.parse(filename)
1415 except ET.ParseError:
1416 err(filename, None, None, "parse", "malformatted xml file")
1417 continue
1418 except IOError:
1419 err(filename, None, None, None, "unable to read file")
1420 continue
1422 try:
1423 check_a11y_relation(filename, tree)
1424 except Exception as error:
1425 import traceback
1426 output_buffer += traceback.format_exc()
1427 err(filename, None, None, "parse", "error parsing file")
1429 if errors > 0 or errexists > 0:
1430 output_buffer += "%s new error%s" % (errors, 's' if errors != 1 else '')
1431 if errexists > 0:
1432 output_buffer += " (%s suppressed by %s, please fix %s)" % (errexists, suppr, 'them' if errexists > 1 else 'it')
1433 output_buffer += "\n"
1435 if warnings > 0 or warnexists > 0:
1436 output_buffer += "%s new warning%s" % (warnings, 's' if warnings != 1 else '')
1437 if warnexists > 0:
1438 output_buffer += " (%s suppressed by %s, please fix %s)" % (warnexists, suppr, 'them' if warnexists > 1 else 'it')
1439 output_buffer += "\n"
1441 if fatals > 0 or fatalexists > 0:
1442 output_buffer += "%s new fatal%s" % (fatals, 's' if fatals != 1 else '')
1443 if fatalexists > 0:
1444 output_buffer += " (%s suppressed by %s, please fix %s)" % (fatalexists, suppr, 'them' if fatalexists > 1 else 'it')
1445 output_buffer += "\n"
1447 n = 0
1448 for (suppr,unused) in suppressions.items():
1449 if unused:
1450 n += 1
1452 if n > 0:
1453 output_buffer += "%s suppression%s unused:\n" % (n, 's' if n != 1 else '')
1454 for (suppr,unused) in suppressions.items():
1455 if unused:
1456 output_buffer += " %s:%s\n" % (suppressions_to_line[suppr], suppr)
1458 if gen_supprfile is not None:
1459 gen_supprfile.close()
1460 if outfile is not None:
1461 outfile.close()
1463 if gen_suppr is None:
1464 if output_buffer != "":
1465 output_buffer += "Explanations are available on " + howto_url + "\n"
1467 if fatals > 0:
1468 print(output_header.rstrip() + "\n" + output_buffer)
1469 sys.exit(1)
1471 if len(output_buffer) > 0:
1472 print(output_header.rstrip() + "\n" + output_buffer)
1474 if __name__ == "__main__":
1475 try:
1476 main()
1477 except KeyboardInterrupt:
1478 pass
1480 # vim: set shiftwidth=4 softtabstop=4 expandtab: