Skip to content

Commit 6df1740

Browse files
committed
add an extension for indicating library requirements
1 parent 4ef51fa commit 6df1740

File tree

5 files changed

+192
-68
lines changed

5 files changed

+192
-68
lines changed

Doc/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
'misc_news',
3636
'pydoc_topics',
3737
'pyspecific',
38+
'requirements',
3839
'sphinx.ext.coverage',
3940
'sphinx.ext.doctest',
4041
'sphinx.ext.extlinks',

Doc/tools/extensions/__init__.py

Whitespace-only changes.

Doc/tools/extensions/availability.py

Lines changed: 27 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,8 @@
44

55
from typing import TYPE_CHECKING
66

7-
from docutils import nodes
8-
from sphinx import addnodes
9-
from sphinx.locale import _ as sphinx_gettext
107
from sphinx.util import logging
11-
from sphinx.util.docutils import SphinxDirective
8+
from support import SmartItemList
129

1310
if TYPE_CHECKING:
1411
from sphinx.application import Sphinx
@@ -49,71 +46,33 @@
4946
KNOWN_PLATFORMS = _PLATFORMS | _LIBC | _THREADING
5047

5148

52-
class Availability(SphinxDirective):
53-
has_content = True
54-
required_arguments = 1
55-
optional_arguments = 0
56-
final_argument_whitespace = True
57-
58-
def run(self) -> list[nodes.container]:
59-
title = sphinx_gettext("Availability")
60-
refnode = addnodes.pending_xref(
61-
title,
62-
nodes.inline(title, title, classes=["xref", "std", "std-ref"]),
63-
refdoc=self.env.docname,
64-
refdomain="std",
65-
refexplicit=True,
66-
reftarget="availability",
67-
reftype="ref",
68-
refwarn=True,
49+
class Availability(SmartItemList):
50+
"""Parse platform information from arguments
51+
52+
Arguments is a comma-separated string of platforms. A platform may
53+
be prefixed with "not " to indicate that a feature is not available.
54+
55+
Example::
56+
57+
.. availability:: Windows, Linux >= 4.2, not WASI
58+
59+
Arguments like "Linux >= 3.17 with glibc >= 2.27" are currently not
60+
parsed into separate tokens.
61+
"""
62+
63+
title = "Availability"
64+
reftarget = "availability"
65+
classes = ["availability"]
66+
67+
def check_information(self, platforms: dict[str, str | bool], /) -> None:
68+
unknown = platforms.keys() - KNOWN_PLATFORMS
69+
self._check_information(
70+
logger,
71+
f"{__file__}:KNOWN_PLATFORMS",
72+
unknown,
73+
("platform", "platforms"),
74+
len(platforms),
6975
)
70-
sep = nodes.Text(": ")
71-
parsed, msgs = self.state.inline_text(self.arguments[0], self.lineno)
72-
pnode = nodes.paragraph(title, "", refnode, sep, *parsed, *msgs)
73-
self.set_source_info(pnode)
74-
cnode = nodes.container("", pnode, classes=["availability"])
75-
self.set_source_info(cnode)
76-
if self.content:
77-
self.state.nested_parse(self.content, self.content_offset, cnode)
78-
self.parse_platforms()
79-
80-
return [cnode]
81-
82-
def parse_platforms(self) -> dict[str, str | bool]:
83-
"""Parse platform information from arguments
84-
85-
Arguments is a comma-separated string of platforms. A platform may
86-
be prefixed with "not " to indicate that a feature is not available.
87-
88-
Example::
89-
90-
.. availability:: Windows, Linux >= 4.2, not WASI
91-
92-
Arguments like "Linux >= 3.17 with glibc >= 2.27" are currently not
93-
parsed into separate tokens.
94-
"""
95-
platforms = {}
96-
for arg in self.arguments[0].rstrip(".").split(","):
97-
arg = arg.strip()
98-
platform, _, version = arg.partition(" >= ")
99-
if platform.startswith("not "):
100-
version = False
101-
platform = platform.removeprefix("not ")
102-
elif not version:
103-
version = True
104-
platforms[platform] = version
105-
106-
if unknown := set(platforms).difference(KNOWN_PLATFORMS):
107-
logger.warning(
108-
"Unknown platform%s or syntax '%s' in '.. availability:: %s', "
109-
"see %s:KNOWN_PLATFORMS for a set of known platforms.",
110-
"s" if len(platforms) != 1 else "",
111-
" ".join(sorted(unknown)),
112-
self.arguments[0],
113-
__file__,
114-
)
115-
116-
return platforms
11776

11877

11978
def setup(app: Sphinx) -> ExtensionMetadata:
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Support for documenting system library requirements."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
from sphinx.util import logging
8+
from support import SmartItemList
9+
10+
if TYPE_CHECKING:
11+
from sphinx.application import Sphinx
12+
from sphinx.util.typing import ExtensionMetadata
13+
14+
15+
logger = logging.getLogger("requirements")
16+
17+
# Known non-vendored dependencies.
18+
KNOWN_REQUIREMENTS = frozenset({
19+
# libssl implementations
20+
"OpenSSL",
21+
"AWS-LC",
22+
"BoringSSL",
23+
"LibreSSL",
24+
})
25+
26+
27+
class Requirements(SmartItemList):
28+
"""Parse dependencies information from arguments.
29+
30+
Arguments is a comma-separated string of dependencies. A dependency may
31+
be prefixed with "not " to indicate that a feature is not available if
32+
it was used.
33+
34+
Example::
35+
36+
.. availability:: OpenSSL >= 3.5, not BoringSSL
37+
38+
Arguments like "OpenSSL >= 3.5 with FIPS mode on" are currently not
39+
parsed into separate tokens.
40+
"""
41+
42+
title = "Requirements"
43+
reftarget = "requirements-notes"
44+
classes = ["requirements"]
45+
46+
def check_information(self, requirements, /):
47+
unknown = requirements.keys() - KNOWN_REQUIREMENTS
48+
self._check_information(
49+
logger,
50+
f"{__file__}:KNOWN_REQUIREMENTS",
51+
unknown,
52+
("requirement", "requirements"),
53+
len(requirements),
54+
)
55+
56+
57+
def setup(app: Sphinx) -> ExtensionMetadata:
58+
app.add_directive("requirements", Requirements)
59+
60+
return {
61+
"version": "1.0",
62+
"parallel_read_safe": True,
63+
"parallel_write_safe": True,
64+
}

Doc/tools/extensions/support.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from docutils import nodes
6+
from sphinx import addnodes
7+
from sphinx.locale import _ as sphinx_gettext
8+
from sphinx.util.docutils import SphinxDirective
9+
10+
if TYPE_CHECKING:
11+
from collections.abc import Iterable, Sequence
12+
from logging import Logger
13+
from typing import ClassVar
14+
15+
16+
class SmartItemList(SphinxDirective):
17+
title: ClassVar[str]
18+
reftarget: ClassVar[str]
19+
classes: ClassVar[Sequence[str]]
20+
21+
has_content = True
22+
required_arguments = 1
23+
optional_arguments = 0
24+
final_argument_whitespace = True
25+
26+
def run(self) -> list[nodes.container]:
27+
title = sphinx_gettext(self.title)
28+
refnode = addnodes.pending_xref(
29+
title,
30+
nodes.inline(title, title, classes=["xref", "std", "std-ref"]),
31+
refdoc=self.env.docname,
32+
refdomain="std",
33+
refexplicit=True,
34+
reftarget=self.reftarget,
35+
reftype="ref",
36+
refwarn=True,
37+
)
38+
sep = nodes.Text(": ")
39+
parsed, msgs = self.state.inline_text(self.arguments[0], self.lineno)
40+
pnode = nodes.paragraph(title, "", refnode, sep, *parsed, *msgs)
41+
self.set_source_info(pnode)
42+
cnode = nodes.container("", pnode, classes=self.classes)
43+
self.set_source_info(cnode)
44+
if self.content:
45+
self.state.nested_parse(self.content, self.content_offset, cnode)
46+
47+
items = self.parse_information()
48+
self.check_information(items)
49+
50+
return [cnode]
51+
52+
def parse_information(self) -> dict[str, str | bool]:
53+
"""Parse information from arguments.
54+
55+
Arguments is a comma-separated string of versioned named items.
56+
Each item may be prefixed with "not " to indicate that a feature
57+
is not available.
58+
59+
Example::
60+
61+
.. <directive>:: <item>, <item> >= <version>, not <item>
62+
63+
Arguments like "<item> >= major.minor with <flavor> >= x.y.z" are
64+
currently not parsed into separate tokens.
65+
"""
66+
items = {}
67+
for arg in self.arguments[0].rstrip(".").split(","):
68+
arg = arg.strip()
69+
name, _, version = arg.partition(" >= ")
70+
if name.startswith("not "):
71+
version = False
72+
name = name.removeprefix("not ")
73+
elif not version:
74+
version = True
75+
items[name] = version
76+
return items
77+
78+
def check_information(self, items: dict[str, str | bool], /) -> None:
79+
raise NotImplementedError
80+
81+
def _check_information(
82+
self,
83+
logger: Logger,
84+
seealso: str,
85+
unknown: Iterable[str],
86+
parsed_items_descr: tuple[str, str],
87+
parsed_items_count: int,
88+
/,
89+
):
90+
if unknown := " ".join(sorted(unknown)):
91+
logger.warning(
92+
"Unknown %s or syntax '%s' in '.. %s:: %s', "
93+
"see %s for a set of known %s.",
94+
parsed_items_descr[int(parsed_items_count > 1)],
95+
unknown,
96+
self.name,
97+
self.arguments[0],
98+
seealso,
99+
parsed_items_descr[1],
100+
)

0 commit comments

Comments
 (0)