Skip to content

Commit 1a7824a

Browse files
gh-141004: Add a CI job ensuring that new C APIs include documentation (GH-142102)
Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com>
1 parent c525204 commit 1a7824a

File tree

5 files changed

+297
-0
lines changed

5 files changed

+297
-0
lines changed

.github/CODEOWNERS

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ Doc/howto/clinic.rst @erlend-aasland @AA-Turner
126126
# C Analyser
127127
Tools/c-analyzer/ @ericsnowcurrently
128128

129+
# C API Documentation Checks
130+
Tools/check-c-api-docs/ @ZeroIntensity
131+
129132
# Fuzzing
130133
Modules/_xxtestfuzz/ @ammaraskar
131134

.github/workflows/build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ jobs:
142142
- name: Check for unsupported C global variables
143143
if: github.event_name == 'pull_request' # $GITHUB_EVENT_NAME
144144
run: make check-c-globals
145+
- name: Check for undocumented C APIs
146+
run: make check-c-api-docs
147+
145148

146149
build-windows:
147150
name: >-

Makefile.pre.in

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3322,6 +3322,11 @@ check-c-globals:
33223322
--format summary \
33233323
--traceback
33243324

3325+
# Check for undocumented C APIs.
3326+
.PHONY: check-c-api-docs
3327+
check-c-api-docs:
3328+
$(PYTHON_FOR_REGEN) $(srcdir)/Tools/check-c-api-docs/main.py
3329+
33253330
# Find files with funny names
33263331
.PHONY: funny
33273332
funny:
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# pydtrace_probes.h
2+
PyDTrace_AUDIT
3+
PyDTrace_FUNCTION_ENTRY
4+
PyDTrace_FUNCTION_RETURN
5+
PyDTrace_GC_DONE
6+
PyDTrace_GC_START
7+
PyDTrace_IMPORT_FIND_LOAD_DONE
8+
PyDTrace_IMPORT_FIND_LOAD_START
9+
PyDTrace_INSTANCE_DELETE_DONE
10+
PyDTrace_INSTANCE_DELETE_START
11+
PyDTrace_INSTANCE_NEW_DONE
12+
PyDTrace_INSTANCE_NEW_START
13+
PyDTrace_LINE
14+
# fileobject.h
15+
Py_FileSystemDefaultEncodeErrors
16+
Py_FileSystemDefaultEncoding
17+
Py_HasFileSystemDefaultEncoding
18+
Py_UTF8Mode
19+
# pyhash.h
20+
Py_HASH_EXTERNAL
21+
# exports.h
22+
PyAPI_DATA
23+
Py_EXPORTED_SYMBOL
24+
Py_IMPORTED_SYMBOL
25+
Py_LOCAL_SYMBOL
26+
# modsupport.h
27+
PyABIInfo_FREETHREADING_AGNOSTIC
28+
# moduleobject.h
29+
PyModuleDef_Type
30+
# object.h
31+
Py_INVALID_SIZE
32+
Py_TPFLAGS_HAVE_VERSION_TAG
33+
Py_TPFLAGS_INLINE_VALUES
34+
Py_TPFLAGS_IS_ABSTRACT
35+
# pyexpat.h
36+
PyExpat_CAPI_MAGIC
37+
PyExpat_CAPSULE_NAME
38+
# pyport.h
39+
Py_ALIGNED
40+
Py_ARITHMETIC_RIGHT_SHIFT
41+
Py_CAN_START_THREADS
42+
Py_FORCE_EXPANSION
43+
Py_GCC_ATTRIBUTE
44+
Py_LL
45+
Py_SAFE_DOWNCAST
46+
Py_ULL
47+
Py_VA_COPY
48+
# unicodeobject.h
49+
Py_UNICODE_SIZE
50+
# cpython/methodobject.h
51+
PyCFunction_GET_CLASS
52+
# cpython/compile.h
53+
PyCF_ALLOW_INCOMPLETE_INPUT
54+
PyCF_COMPILE_MASK
55+
PyCF_DONT_IMPLY_DEDENT
56+
PyCF_IGNORE_COOKIE
57+
PyCF_MASK
58+
PyCF_MASK_OBSOLETE
59+
PyCF_SOURCE_IS_UTF8
60+
# cpython/descrobject.h
61+
PyDescr_COMMON
62+
PyDescr_NAME
63+
PyDescr_TYPE
64+
PyWrapperFlag_KEYWORDS
65+
# cpython/fileobject.h
66+
PyFile_NewStdPrinter
67+
PyStdPrinter_Type
68+
Py_UniversalNewlineFgets
69+
# cpython/setobject.h
70+
PySet_MINSIZE
71+
# cpython/ceval.h
72+
PyUnstable_CopyPerfMapFile
73+
PyUnstable_PerfTrampoline_CompileCode
74+
PyUnstable_PerfTrampoline_SetPersistAfterFork
75+
# cpython/genobject.h
76+
PyAsyncGenASend_CheckExact
77+
# cpython/longintrepr.h
78+
PyLong_BASE
79+
PyLong_MASK
80+
PyLong_SHIFT
81+
# cpython/pyerrors.h
82+
PyException_HEAD
83+
# cpython/pyframe.h
84+
PyUnstable_EXECUTABLE_KINDS
85+
PyUnstable_EXECUTABLE_KIND_BUILTIN_FUNCTION
86+
PyUnstable_EXECUTABLE_KIND_METHOD_DESCRIPTOR
87+
PyUnstable_EXECUTABLE_KIND_PY_FUNCTION
88+
PyUnstable_EXECUTABLE_KIND_SKIP
89+
# cpython/pylifecycle.h
90+
Py_FrozenMain
91+
# cpython/unicodeobject.h
92+
PyUnicode_IS_COMPACT
93+
PyUnicode_IS_COMPACT_ASCII

Tools/check-c-api-docs/main.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import re
2+
from pathlib import Path
3+
import sys
4+
import _colorize
5+
import textwrap
6+
7+
SIMPLE_FUNCTION_REGEX = re.compile(r"PyAPI_FUNC(.+) (\w+)\(")
8+
SIMPLE_MACRO_REGEX = re.compile(r"# *define *(\w+)(\(.+\))? ")
9+
SIMPLE_INLINE_REGEX = re.compile(r"static inline .+( |\n)(\w+)")
10+
SIMPLE_DATA_REGEX = re.compile(r"PyAPI_DATA\(.+\) (\w+)")
11+
12+
CPYTHON = Path(__file__).parent.parent.parent
13+
INCLUDE = CPYTHON / "Include"
14+
C_API_DOCS = CPYTHON / "Doc" / "c-api"
15+
IGNORED = (
16+
(CPYTHON / "Tools" / "check-c-api-docs" / "ignored_c_api.txt")
17+
.read_text()
18+
.split("\n")
19+
)
20+
21+
for index, line in enumerate(IGNORED):
22+
if line.startswith("#"):
23+
IGNORED.pop(index)
24+
25+
MISTAKE = """
26+
If this is a mistake and this script should not be failing, create an
27+
issue and tag Peter (@ZeroIntensity) on it.\
28+
"""
29+
30+
31+
def found_undocumented(singular: bool) -> str:
32+
some = "an" if singular else "some"
33+
s = "" if singular else "s"
34+
these = "this" if singular else "these"
35+
them = "it" if singular else "them"
36+
were = "was" if singular else "were"
37+
38+
return (
39+
textwrap.dedent(
40+
f"""
41+
Found {some} undocumented C API{s}!
42+
43+
Python requires documentation on all public C API symbols, macros, and types.
44+
If {these} API{s} {were} not meant to be public, prefix {them} with a
45+
leading underscore (_PySomething_API) or move {them} to the internal C API
46+
(pycore_*.h files).
47+
48+
In exceptional cases, certain APIs can be ignored by adding them to
49+
Tools/check-c-api-docs/ignored_c_api.txt
50+
"""
51+
)
52+
+ MISTAKE
53+
)
54+
55+
56+
def found_ignored_documented(singular: bool) -> str:
57+
some = "a" if singular else "some"
58+
s = "" if singular else "s"
59+
them = "it" if singular else "them"
60+
were = "was" if singular else "were"
61+
they = "it" if singular else "they"
62+
63+
return (
64+
textwrap.dedent(
65+
f"""
66+
Found {some} C API{s} listed in Tools/c-api-docs-check/ignored_c_api.txt, but
67+
{they} {were} found in the documentation. To fix this, remove {them} from
68+
ignored_c_api.txt.
69+
"""
70+
)
71+
+ MISTAKE
72+
)
73+
74+
75+
def is_documented(name: str) -> bool:
76+
"""
77+
Is a name present in the C API documentation?
78+
"""
79+
for path in C_API_DOCS.iterdir():
80+
if path.is_dir():
81+
continue
82+
if path.suffix != ".rst":
83+
continue
84+
85+
text = path.read_text(encoding="utf-8")
86+
if name in text:
87+
return True
88+
89+
return False
90+
91+
92+
def scan_file_for_docs(filename: str, text: str) -> tuple[list[str], list[str]]:
93+
"""
94+
Scan a header file for C API functions.
95+
"""
96+
undocumented: list[str] = []
97+
documented_ignored: list[str] = []
98+
colors = _colorize.get_colors()
99+
100+
def check_for_name(name: str) -> None:
101+
documented = is_documented(name)
102+
if documented and (name in IGNORED):
103+
documented_ignored.append(name)
104+
elif not documented and (name not in IGNORED):
105+
undocumented.append(name)
106+
107+
for function in SIMPLE_FUNCTION_REGEX.finditer(text):
108+
name = function.group(2)
109+
if not name.startswith("Py"):
110+
continue
111+
112+
check_for_name(name)
113+
114+
for macro in SIMPLE_MACRO_REGEX.finditer(text):
115+
name = macro.group(1)
116+
if not name.startswith("Py"):
117+
continue
118+
119+
if "(" in name:
120+
name = name[: name.index("(")]
121+
122+
check_for_name(name)
123+
124+
for inline in SIMPLE_INLINE_REGEX.finditer(text):
125+
name = inline.group(2)
126+
if not name.startswith("Py"):
127+
continue
128+
129+
check_for_name(name)
130+
131+
for data in SIMPLE_DATA_REGEX.finditer(text):
132+
name = data.group(1)
133+
if not name.startswith("Py"):
134+
continue
135+
136+
check_for_name(name)
137+
138+
# Remove duplicates and sort alphabetically to keep the output deterministic
139+
undocumented = list(set(undocumented))
140+
undocumented.sort()
141+
142+
if undocumented or documented_ignored:
143+
print(f"{filename} {colors.RED}BAD{colors.RESET}")
144+
for name in undocumented:
145+
print(f"{colors.BOLD_RED}UNDOCUMENTED:{colors.RESET} {name}")
146+
for name in documented_ignored:
147+
print(f"{colors.BOLD_YELLOW}DOCUMENTED BUT IGNORED:{colors.RESET} {name}")
148+
else:
149+
print(f"{filename} {colors.GREEN}OK{colors.RESET}")
150+
151+
return undocumented, documented_ignored
152+
153+
154+
def main() -> None:
155+
print("Scanning for undocumented C API functions...")
156+
files = [*INCLUDE.iterdir(), *(INCLUDE / "cpython").iterdir()]
157+
all_missing: list[str] = []
158+
all_found_ignored: list[str] = []
159+
160+
for file in files:
161+
if file.is_dir():
162+
continue
163+
assert file.exists()
164+
text = file.read_text(encoding="utf-8")
165+
missing, ignored = scan_file_for_docs(str(file.relative_to(INCLUDE)), text)
166+
all_found_ignored += ignored
167+
all_missing += missing
168+
169+
fail = False
170+
to_check = [
171+
(all_missing, "missing", found_undocumented(len(all_missing) == 1)),
172+
(
173+
all_found_ignored,
174+
"documented but ignored",
175+
found_ignored_documented(len(all_found_ignored) == 1),
176+
),
177+
]
178+
for name_list, what, message in to_check:
179+
if not name_list:
180+
continue
181+
182+
s = "s" if len(name_list) != 1 else ""
183+
print(f"-- {len(name_list)} {what} C API{s} --")
184+
for name in name_list:
185+
print(f" - {name}")
186+
print(message)
187+
fail = True
188+
189+
sys.exit(1 if fail else 0)
190+
191+
192+
if __name__ == "__main__":
193+
main()

0 commit comments

Comments
 (0)