Skip to content

Commit ae1bdca

Browse files
[3.13] GH-121970: Extract audit_events into a new extension (GH-122325) (#122434)
1 parent b252317 commit ae1bdca

File tree

3 files changed

+263
-207
lines changed

3 files changed

+263
-207
lines changed

Doc/conf.py

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
# ---------------------
2121

2222
extensions = [
23+
'audit_events',
2324
'c_annotations',
2425
'glossary_search',
2526
'lexers',

Doc/tools/extensions/audit_events.py

+262
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
"""Support for documenting audit events."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
from typing import TYPE_CHECKING
7+
8+
from docutils import nodes
9+
from sphinx.errors import NoUri
10+
from sphinx.locale import _ as sphinx_gettext
11+
from sphinx.transforms.post_transforms import SphinxPostTransform
12+
from sphinx.util import logging
13+
from sphinx.util.docutils import SphinxDirective
14+
15+
if TYPE_CHECKING:
16+
from collections.abc import Iterator
17+
18+
from sphinx.application import Sphinx
19+
from sphinx.builders import Builder
20+
from sphinx.environment import BuildEnvironment
21+
22+
logger = logging.getLogger(__name__)
23+
24+
# This list of sets are allowable synonyms for event argument names.
25+
# If two names are in the same set, they are treated as equal for the
26+
# purposes of warning. This won't help if the number of arguments is
27+
# different!
28+
_SYNONYMS = [
29+
frozenset({"file", "path", "fd"}),
30+
]
31+
32+
33+
class AuditEvents:
34+
def __init__(self) -> None:
35+
self.events: dict[str, list[str]] = {}
36+
self.sources: dict[str, list[tuple[str, str]]] = {}
37+
38+
def __iter__(self) -> Iterator[tuple[str, list[str], tuple[str, str]]]:
39+
for name, args in self.events.items():
40+
for source in self.sources[name]:
41+
yield name, args, source
42+
43+
def add_event(
44+
self, name, args: list[str], source: tuple[str, str]
45+
) -> None:
46+
if name in self.events:
47+
self._check_args_match(name, args)
48+
else:
49+
self.events[name] = args
50+
self.sources.setdefault(name, []).append(source)
51+
52+
def _check_args_match(self, name: str, args: list[str]) -> None:
53+
current_args = self.events[name]
54+
msg = (
55+
f"Mismatched arguments for audit-event {name}: "
56+
f"{current_args!r} != {args!r}"
57+
)
58+
if current_args == args:
59+
return
60+
if len(current_args) != len(args):
61+
logger.warning(msg)
62+
return
63+
for a1, a2 in zip(current_args, args, strict=False):
64+
if a1 == a2:
65+
continue
66+
if any(a1 in s and a2 in s for s in _SYNONYMS):
67+
continue
68+
logger.warning(msg)
69+
return
70+
71+
def id_for(self, name) -> str:
72+
source_count = len(self.sources.get(name, ()))
73+
name_clean = re.sub(r"\W", "_", name)
74+
return f"audit_event_{name_clean}_{source_count}"
75+
76+
def rows(self) -> Iterator[tuple[str, list[str], list[tuple[str, str]]]]:
77+
for name in sorted(self.events.keys()):
78+
yield name, self.events[name], self.sources[name]
79+
80+
81+
def initialise_audit_events(app: Sphinx) -> None:
82+
"""Initialise the audit_events attribute on the environment."""
83+
if not hasattr(app.env, "audit_events"):
84+
app.env.audit_events = AuditEvents()
85+
86+
87+
def audit_events_purge(
88+
app: Sphinx, env: BuildEnvironment, docname: str
89+
) -> None:
90+
"""This is to remove traces of removed documents from env.audit_events."""
91+
fresh_audit_events = AuditEvents()
92+
for name, args, (doc, target) in env.audit_events:
93+
if doc != docname:
94+
fresh_audit_events.add_event(name, args, (doc, target))
95+
96+
97+
def audit_events_merge(
98+
app: Sphinx,
99+
env: BuildEnvironment,
100+
docnames: list[str],
101+
other: BuildEnvironment,
102+
) -> None:
103+
"""In Sphinx parallel builds, this merges audit_events from subprocesses."""
104+
for name, args, source in other.audit_events:
105+
env.audit_events.add_event(name, args, source)
106+
107+
108+
class AuditEvent(SphinxDirective):
109+
has_content = True
110+
required_arguments = 1
111+
optional_arguments = 2
112+
final_argument_whitespace = True
113+
114+
_label = [
115+
sphinx_gettext(
116+
"Raises an :ref:`auditing event <auditing>` "
117+
"{name} with no arguments."
118+
),
119+
sphinx_gettext(
120+
"Raises an :ref:`auditing event <auditing>` "
121+
"{name} with argument {args}."
122+
),
123+
sphinx_gettext(
124+
"Raises an :ref:`auditing event <auditing>` "
125+
"{name} with arguments {args}."
126+
),
127+
]
128+
129+
def run(self) -> list[nodes.paragraph]:
130+
name = self.arguments[0]
131+
if len(self.arguments) >= 2 and self.arguments[1]:
132+
args = [
133+
arg
134+
for argument in self.arguments[1].strip("'\"").split(",")
135+
if (arg := argument.strip())
136+
]
137+
else:
138+
args = []
139+
ids = []
140+
try:
141+
target = self.arguments[2].strip("\"'")
142+
except (IndexError, TypeError):
143+
target = None
144+
if not target:
145+
target = self.env.audit_events.id_for(name)
146+
ids.append(target)
147+
self.env.audit_events.add_event(name, args, (self.env.docname, target))
148+
149+
node = nodes.paragraph("", classes=["audit-hook"], ids=ids)
150+
self.set_source_info(node)
151+
if self.content:
152+
self.state.nested_parse(self.content, self.content_offset, node)
153+
else:
154+
num_args = min(2, len(args))
155+
text = self._label[num_args].format(
156+
name=f"``{name}``",
157+
args=", ".join(f"``{a}``" for a in args),
158+
)
159+
parsed, messages = self.state.inline_text(text, self.lineno)
160+
node += parsed
161+
node += messages
162+
return [node]
163+
164+
165+
class audit_event_list(nodes.General, nodes.Element): # noqa: N801
166+
pass
167+
168+
169+
class AuditEventListDirective(SphinxDirective):
170+
def run(self) -> list[audit_event_list]:
171+
return [audit_event_list()]
172+
173+
174+
class AuditEventListTransform(SphinxPostTransform):
175+
default_priority = 500
176+
177+
def run(self) -> None:
178+
if self.document.next_node(audit_event_list) is None:
179+
return
180+
181+
table = self._make_table(self.app.builder, self.env.docname)
182+
for node in self.document.findall(audit_event_list):
183+
node.replace_self(table)
184+
185+
def _make_table(self, builder: Builder, docname: str) -> nodes.table:
186+
table = nodes.table(cols=3)
187+
group = nodes.tgroup(
188+
"",
189+
nodes.colspec(colwidth=30),
190+
nodes.colspec(colwidth=55),
191+
nodes.colspec(colwidth=15),
192+
cols=3,
193+
)
194+
head = nodes.thead()
195+
body = nodes.tbody()
196+
197+
table += group
198+
group += head
199+
group += body
200+
201+
head += nodes.row(
202+
"",
203+
nodes.entry("", nodes.paragraph("", "Audit event")),
204+
nodes.entry("", nodes.paragraph("", "Arguments")),
205+
nodes.entry("", nodes.paragraph("", "References")),
206+
)
207+
208+
for name, args, sources in builder.env.audit_events.rows():
209+
body += self._make_row(builder, docname, name, args, sources)
210+
211+
return table
212+
213+
@staticmethod
214+
def _make_row(
215+
builder: Builder,
216+
docname: str,
217+
name: str,
218+
args: list[str],
219+
sources: list[tuple[str, str]],
220+
) -> nodes.row:
221+
row = nodes.row()
222+
name_node = nodes.paragraph("", nodes.Text(name))
223+
row += nodes.entry("", name_node)
224+
225+
args_node = nodes.paragraph()
226+
for arg in args:
227+
args_node += nodes.literal(arg, arg)
228+
args_node += nodes.Text(", ")
229+
if len(args_node.children) > 0:
230+
args_node.children.pop() # remove trailing comma
231+
row += nodes.entry("", args_node)
232+
233+
backlinks_node = nodes.paragraph()
234+
backlinks = enumerate(sorted(set(sources)), start=1)
235+
for i, (doc, label) in backlinks:
236+
if isinstance(label, str):
237+
ref = nodes.reference("", f"[{i}]", internal=True)
238+
try:
239+
target = (
240+
f"{builder.get_relative_uri(docname, doc)}#{label}"
241+
)
242+
except NoUri:
243+
continue
244+
else:
245+
ref["refuri"] = target
246+
backlinks_node += ref
247+
row += nodes.entry("", backlinks_node)
248+
return row
249+
250+
251+
def setup(app: Sphinx):
252+
app.add_directive("audit-event", AuditEvent)
253+
app.add_directive("audit-event-table", AuditEventListDirective)
254+
app.add_post_transform(AuditEventListTransform)
255+
app.connect("builder-inited", initialise_audit_events)
256+
app.connect("env-purge-doc", audit_events_purge)
257+
app.connect("env-merge-info", audit_events_merge)
258+
return {
259+
"version": "1.0",
260+
"parallel_read_safe": True,
261+
"parallel_write_safe": True,
262+
}

0 commit comments

Comments
 (0)