bpo-37390: Add audit event table to documentations (GH-14406)

Also updates some (unreleased) event names to be consistent with the others.
diff --git a/Doc/tools/extensions/pyspecific.py b/Doc/tools/extensions/pyspecific.py
index f79b250..a6f39b0 100644
--- a/Doc/tools/extensions/pyspecific.py
+++ b/Doc/tools/extensions/pyspecific.py
@@ -23,7 +23,7 @@
 from sphinx import addnodes
 from sphinx.builders import Builder
 from sphinx.locale import translators
-from sphinx.util import status_iterator
+from sphinx.util import status_iterator, logging
 from sphinx.util.nodes import split_explicit_title
 from sphinx.writers.html import HTMLTranslator
 from sphinx.writers.text import TextWriter, TextTranslator
@@ -157,7 +157,7 @@
 
     has_content = True
     required_arguments = 1
-    optional_arguments = 1
+    optional_arguments = 2
     final_argument_whitespace = True
 
     _label = [
@@ -166,21 +166,49 @@
         "Raises an :ref:`auditing event <auditing>` {name} with arguments {args}.",
     ]
 
+    @property
+    def logger(self):
+        cls = type(self)
+        return logging.getLogger(cls.__module__ + "." + cls.__name__)
+
     def run(self):
+        name = self.arguments[0]
         if len(self.arguments) >= 2 and self.arguments[1]:
-            args = [
-                "``{}``".format(a.strip())
-                for a in self.arguments[1].strip("'\"").split()
-                if a.strip()
-            ]
+            args = (a.strip() for a in self.arguments[1].strip("'\"").split(","))
+            args = [a for a in args if a]
         else:
             args = []
 
         label = translators['sphinx'].gettext(self._label[min(2, len(args))])
-        text = label.format(name="``{}``".format(self.arguments[0]),
-                            args=", ".join(args))
+        text = label.format(name="``{}``".format(name),
+                            args=", ".join("``{}``".format(a) for a in args if a))
 
-        pnode = nodes.paragraph(text, classes=["audit-hook"])
+        env = self.state.document.settings.env
+        if not hasattr(env, 'all_audit_events'):
+            env.all_audit_events = {}
+
+        new_info = {
+            'source': [],
+            'args': args
+        }
+        info = env.all_audit_events.setdefault(name, new_info)
+        if info is not new_info:
+            if not self._do_args_match(info['args'], new_info['args']):
+                self.logger.warn(
+                    "Mismatched arguments for audit-event {}: {!r} != {!r}"
+                    .format(name, info['args'], new_info['args'])
+                )
+
+        if len(self.arguments) >= 3 and self.arguments[2]:
+            target = self.arguments[2]
+            ids = []
+        else:
+            target = "audit_event_{}_{}".format(name, len(info['source']))
+            target = re.sub(r'\W', '_', label)
+            ids = [target]
+        info['source'].append((env.docname, target))
+
+        pnode = nodes.paragraph(text, classes=["audit-hook"], ids=ids)
         if self.content:
             self.state.nested_parse(self.content, self.content_offset, pnode)
         else:
@@ -189,6 +217,37 @@
 
         return [pnode]
 
+    # This list of sets are allowable synonyms for event argument names.
+    # If two names are in the same set, they are treated as equal for the
+    # purposes of warning. This won't help if number of arguments is
+    # different!
+    _SYNONYMS = [
+        {"file", "path", "fd"},
+    ]
+
+    def _do_args_match(self, args1, args2):
+        if args1 == args2:
+            return True
+        if len(args1) != len(args2):
+            return False
+        for a1, a2 in zip(args1, args2):
+            if a1 == a2:
+                continue
+            if any(a1 in s and a2 in s for s in self._SYNONYMS):
+                continue
+            return False
+        return True
+
+
+class audit_event_list(nodes.General, nodes.Element):
+    pass
+
+
+class AuditEventListDirective(Directive):
+
+    def run(self):
+        return [audit_event_list('')]
+
 
 # Support for documenting decorators
 
@@ -394,7 +453,7 @@
                                      'building topics... ',
                                      length=len(pydoc_topic_labels)):
             if label not in self.env.domaindata['std']['labels']:
-                self.warn('label %r not in documentation' % label)
+                self.env.logger.warn('label %r not in documentation' % label)
                 continue
             docname, labelid, sectname = self.env.domaindata['std']['labels'][label]
             doctree = self.env.get_and_resolve_doctree(docname, self)
@@ -458,12 +517,72 @@
     return fullname
 
 
+def process_audit_events(app, doctree, fromdocname):
+    for node in doctree.traverse(audit_event_list):
+        break
+    else:
+        return
+
+    env = app.builder.env
+
+    table = nodes.table(cols=3)
+    group = nodes.tgroup(
+        '',
+        nodes.colspec(colwidth=30),
+        nodes.colspec(colwidth=55),
+        nodes.colspec(colwidth=15),
+    )
+    head = nodes.thead()
+    body = nodes.tbody()
+
+    table += group
+    group += head
+    group += body
+
+    row = nodes.row()
+    row += nodes.entry('', nodes.paragraph('', nodes.Text('Audit event')))
+    row += nodes.entry('', nodes.paragraph('', nodes.Text('Arguments')))
+    row += nodes.entry('', nodes.paragraph('', nodes.Text('References')))
+    head += row
+
+    for name in sorted(getattr(env, "all_audit_events", ())):
+        audit_event = env.all_audit_events[name]
+
+        row = nodes.row()
+        node = nodes.paragraph('', nodes.Text(name))
+        row += nodes.entry('', node)
+
+        node = nodes.paragraph()
+        for i, a in enumerate(audit_event['args']):
+            if i:
+                node += nodes.Text(", ")
+            node += nodes.literal(a, nodes.Text(a))
+        row += nodes.entry('', node)
+
+        node = nodes.paragraph()
+        for i, (doc, label) in enumerate(audit_event['source'], start=1):
+            if isinstance(label, str):
+                ref = nodes.reference("", nodes.Text("[{}]".format(i)), internal=True)
+                ref['refuri'] = "{}#{}".format(
+                    app.builder.get_relative_uri(fromdocname, doc),
+                    label,
+                )
+                node += ref
+        row += nodes.entry('', node)
+
+        body += row
+
+    for node in doctree.traverse(audit_event_list):
+        node.replace_self(table)
+
+
 def setup(app):
     app.add_role('issue', issue_role)
     app.add_role('source', source_role)
     app.add_directive('impl-detail', ImplementationDetail)
     app.add_directive('availability', Availability)
     app.add_directive('audit-event', AuditEvent)
+    app.add_directive('audit-event-table', AuditEventListDirective)
     app.add_directive('deprecated-removed', DeprecatedRemoved)
     app.add_builder(PydocTopicsBuilder)
     app.add_builder(suspicious.CheckSuspiciousMarkupBuilder)
@@ -478,4 +597,5 @@
     app.add_directive_to_domain('py', 'awaitablemethod', PyAwaitableMethod)
     app.add_directive_to_domain('py', 'abstractmethod', PyAbstractMethod)
     app.add_directive('miscnews', MiscNews)
+    app.connect('doctree-resolved', process_audit_events)
     return {'version': '1.0', 'parallel_read_safe': True}