"""Structural accessibility checks for rendered pages. Not a substitute for axe or a screen reader, but cheap enough to run on every page in unit tests. """ from html.parser import HTMLParser _UNLABELLED_INPUT_TYPES = {"hidden", "submit", "button", "reset", "image"} class _Collector(HTMLParser): def __init__(self): super().__init__() self.html_lang = None self.headings = [] self.ids = [] self.label_fors = set() self.controls = [] self.images_without_alt = 0 self._label_depth = 0 def handle_starttag(self, tag, attrs): attrs = dict(attrs) if tag == "html": self.html_lang = attrs.get("lang") if "id" in attrs: self.ids.append(attrs["id"]) if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}: self.headings.append(int(tag[1])) if tag == "label": self._label_depth += 1 if attrs.get("for"): self.label_fors.add(attrs["for"]) if tag == "img" and "alt" not in attrs: self.images_without_alt += 1 if tag in {"input", "select", "textarea"}: if tag == "input" and attrs.get("type", "text") in _UNLABELLED_INPUT_TYPES: return wrapped = self._label_depth > 0 named = bool(attrs.get("aria-label") or attrs.get("aria-labelledby")) self.controls.append((attrs.get("id"), attrs.get("name"), wrapped or named)) def handle_endtag(self, tag): if tag == "label": self._label_depth -= 1 def assert_accessible_html(html: str) -> None: page = _Collector() page.feed(html) problems = [] if not page.html_lang: problems.append(" has no lang") if page.headings.count(1) != 1: problems.append(f"expected one

, found {page.headings.count(1)}") for before, after in zip(page.headings, page.headings[1:], strict=False): if after > before + 1: problems.append(f"heading level skips from h{before} to h{after}") duplicates = {i for i in page.ids if page.ids.count(i) > 1} if duplicates: problems.append(f"duplicate ids: {sorted(duplicates)}") for control_id, name, labelled in page.controls: if not (labelled or control_id in page.label_fors): problems.append(f"form control without a label: id={control_id} name={name}") if page.images_without_alt: problems.append(f"{page.images_without_alt} without alt") assert not problems, "\n".join(problems)