Skip to content

Commit 70e67f5

Browse files
authored
gh-141376: smelly.py: Print only smelly symbols, or all of them with --verbose (GH-141394)
Instead of long and uninteresting output for all checked libraries, only print found issues by default. Add a new -v/--verbose option to list all symbols (useful for checking that the script finds the symbols).
1 parent 03e651d commit 70e67f5

File tree

1 file changed

+124
-100
lines changed

1 file changed

+124
-100
lines changed

Tools/build/smelly.py

Lines changed: 124 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
#!/usr/bin/env python
2-
# Script checking that all symbols exported by libpython start with Py or _Py
2+
"""Check exported symbols
33
4-
import os.path
4+
Check that all symbols exported by CPython (libpython, stdlib extension
5+
modules, and similar) start with Py or _Py, or are covered by an exception.
6+
"""
7+
8+
import argparse
9+
import dataclasses
10+
import functools
11+
import pathlib
512
import subprocess
613
import sys
714
import sysconfig
@@ -23,150 +30,167 @@
2330
IGNORED_EXTENSION = "_ctypes_test"
2431

2532

26-
def is_local_symbol_type(symtype):
27-
# Ignore local symbols.
28-
29-
# If lowercase, the symbol is usually local; if uppercase, the symbol
30-
# is global (external). There are however a few lowercase symbols that
31-
# are shown for special global symbols ("u", "v" and "w").
32-
if symtype.islower() and symtype not in "uvw":
33+
@dataclasses.dataclass
34+
class Library:
35+
path: pathlib.Path
36+
is_dynamic: bool
37+
38+
@functools.cached_property
39+
def is_ignored(self):
40+
name_without_extemnsions = self.path.name.partition('.')[0]
41+
return name_without_extemnsions == IGNORED_EXTENSION
42+
43+
44+
@dataclasses.dataclass
45+
class Symbol:
46+
name: str
47+
type: str
48+
library: str
49+
50+
def __str__(self):
51+
return f"{self.name!r} (type {self.type}) from {self.library.path}"
52+
53+
@functools.cached_property
54+
def is_local(self):
55+
# If lowercase, the symbol is usually local; if uppercase, the symbol
56+
# is global (external). There are however a few lowercase symbols that
57+
# are shown for special global symbols ("u", "v" and "w").
58+
if self.type.islower() and self.type not in "uvw":
59+
return True
60+
61+
return False
62+
63+
@functools.cached_property
64+
def is_smelly(self):
65+
if self.is_local:
66+
return False
67+
if self.name.startswith(ALLOWED_PREFIXES):
68+
return False
69+
if self.name in EXCEPTIONS:
70+
return False
71+
if not self.library.is_dynamic and self.name.startswith(
72+
ALLOWED_STATIC_PREFIXES):
73+
return False
74+
if self.library.is_ignored:
75+
return False
3376
return True
3477

35-
return False
78+
@functools.cached_property
79+
def _sort_key(self):
80+
return self.name, self.library.path
3681

82+
def __lt__(self, other_symbol):
83+
return self._sort_key < other_symbol._sort_key
3784

38-
def get_exported_symbols(library, dynamic=False):
39-
print(f"Check that {library} only exports symbols starting with Py or _Py")
4085

86+
def get_exported_symbols(library):
87+
# Only look at dynamic symbols
4188
args = ['nm', '--no-sort']
42-
if dynamic:
89+
if library.is_dynamic:
4390
args.append('--dynamic')
44-
args.append(library)
45-
print(f"+ {' '.join(args)}")
91+
args.append(library.path)
4692
proc = subprocess.run(args, stdout=subprocess.PIPE, encoding='utf-8')
4793
if proc.returncode:
94+
print("+", args)
4895
sys.stdout.write(proc.stdout)
4996
sys.exit(proc.returncode)
5097

5198
stdout = proc.stdout.rstrip()
5299
if not stdout:
53100
raise Exception("command output is empty")
54-
return stdout
55-
56-
57-
def get_smelly_symbols(stdout, dynamic=False):
58-
smelly_symbols = []
59-
python_symbols = []
60-
local_symbols = []
61101

102+
symbols = []
62103
for line in stdout.splitlines():
63-
# Split line '0000000000001b80 D PyTextIOWrapper_Type'
64104
if not line:
65105
continue
66106

107+
# Split lines like '0000000000001b80 D PyTextIOWrapper_Type'
67108
parts = line.split(maxsplit=2)
109+
# Ignore lines like ' U PyDict_SetItemString'
110+
# and headers like 'pystrtod.o:'
68111
if len(parts) < 3:
69112
continue
70113

71-
symtype = parts[1].strip()
72-
symbol = parts[-1]
73-
result = f'{symbol} (type: {symtype})'
74-
75-
if (symbol.startswith(ALLOWED_PREFIXES) or
76-
symbol in EXCEPTIONS or
77-
(not dynamic and symbol.startswith(ALLOWED_STATIC_PREFIXES))):
78-
python_symbols.append(result)
79-
continue
80-
81-
if is_local_symbol_type(symtype):
82-
local_symbols.append(result)
83-
else:
84-
smelly_symbols.append(result)
85-
86-
if local_symbols:
87-
print(f"Ignore {len(local_symbols)} local symbols")
88-
return smelly_symbols, python_symbols
89-
90-
91-
def check_library(library, dynamic=False):
92-
nm_output = get_exported_symbols(library, dynamic)
93-
smelly_symbols, python_symbols = get_smelly_symbols(nm_output, dynamic)
114+
symbol = Symbol(name=parts[-1], type=parts[1], library=library)
115+
if not symbol.is_local:
116+
symbols.append(symbol)
94117

95-
if not smelly_symbols:
96-
print(f"OK: no smelly symbol found ({len(python_symbols)} Python symbols)")
97-
return 0
118+
return symbols
98119

99-
print()
100-
smelly_symbols.sort()
101-
for symbol in smelly_symbols:
102-
print(f"Smelly symbol: {symbol}")
103-
104-
print()
105-
print(f"ERROR: Found {len(smelly_symbols)} smelly symbols!")
106-
return len(smelly_symbols)
107120

108-
109-
def check_extensions():
110-
print(__file__)
121+
def get_extension_libraries():
111122
# This assumes pybuilddir.txt is in same directory as pyconfig.h.
112123
# In the case of out-of-tree builds, we can't assume pybuilddir.txt is
113124
# in the source folder.
114-
config_dir = os.path.dirname(sysconfig.get_config_h_filename())
115-
filename = os.path.join(config_dir, "pybuilddir.txt")
125+
config_dir = pathlib.Path(sysconfig.get_config_h_filename()).parent
116126
try:
117-
with open(filename, encoding="utf-8") as fp:
118-
pybuilddir = fp.readline()
119-
except FileNotFoundError:
120-
print(f"Cannot check extensions because {filename} does not exist")
121-
return True
122-
123-
print(f"Check extension modules from {pybuilddir} directory")
124-
builddir = os.path.join(config_dir, pybuilddir)
125-
nsymbol = 0
126-
for name in os.listdir(builddir):
127-
if not name.endswith(".so"):
128-
continue
129-
if IGNORED_EXTENSION in name:
130-
print()
131-
print(f"Ignore extension: {name}")
127+
config_dir = config_dir.relative_to(pathlib.Path.cwd(), walk_up=True)
128+
except ValueError:
129+
pass
130+
filename = config_dir / "pybuilddir.txt"
131+
pybuilddir = filename.read_text().strip()
132+
133+
builddir = config_dir / pybuilddir
134+
result = []
135+
for path in sorted(builddir.glob('**/*.so')):
136+
if path.stem == IGNORED_EXTENSION:
132137
continue
138+
result.append(Library(path, is_dynamic=True))
133139

134-
print()
135-
filename = os.path.join(builddir, name)
136-
nsymbol += check_library(filename, dynamic=True)
137-
138-
return nsymbol
140+
return result
139141

140142

141143
def main():
142-
nsymbol = 0
144+
parser = argparse.ArgumentParser(
145+
description=__doc__.split('\n', 1)[-1])
146+
parser.add_argument('-v', '--verbose', action='store_true',
147+
help='be verbose (currently: print out all symbols)')
148+
args = parser.parse_args()
149+
150+
libraries = []
143151

144152
# static library
145-
LIBRARY = sysconfig.get_config_var('LIBRARY')
146-
if not LIBRARY:
147-
raise Exception("failed to get LIBRARY variable from sysconfig")
148-
if os.path.exists(LIBRARY):
149-
nsymbol += check_library(LIBRARY)
153+
try:
154+
LIBRARY = pathlib.Path(sysconfig.get_config_var('LIBRARY'))
155+
except TypeError as exc:
156+
raise Exception("failed to get LIBRARY sysconfig variable") from exc
157+
LIBRARY = pathlib.Path(LIBRARY)
158+
if LIBRARY.exists():
159+
libraries.append(Library(LIBRARY, is_dynamic=False))
150160

151161
# dynamic library
152-
LDLIBRARY = sysconfig.get_config_var('LDLIBRARY')
153-
if not LDLIBRARY:
154-
raise Exception("failed to get LDLIBRARY variable from sysconfig")
162+
try:
163+
LDLIBRARY = pathlib.Path(sysconfig.get_config_var('LDLIBRARY'))
164+
except TypeError as exc:
165+
raise Exception("failed to get LDLIBRARY sysconfig variable") from exc
155166
if LDLIBRARY != LIBRARY:
156-
print()
157-
nsymbol += check_library(LDLIBRARY, dynamic=True)
167+
libraries.append(Library(LDLIBRARY, is_dynamic=True))
158168

159169
# Check extension modules like _ssl.cpython-310d-x86_64-linux-gnu.so
160-
nsymbol += check_extensions()
170+
libraries.extend(get_extension_libraries())
161171

162-
if nsymbol:
163-
print()
164-
print(f"ERROR: Found {nsymbol} smelly symbols in total!")
165-
sys.exit(1)
172+
smelly_symbols = []
173+
for library in libraries:
174+
symbols = get_exported_symbols(library)
175+
if args.verbose:
176+
print(f"{library.path}: {len(symbols)} symbol(s) found")
177+
for symbol in sorted(symbols):
178+
if args.verbose:
179+
print(" -", symbol.name)
180+
if symbol.is_smelly:
181+
smelly_symbols.append(symbol)
166182

167183
print()
168-
print(f"OK: all exported symbols of all libraries "
169-
f"are prefixed with {' or '.join(map(repr, ALLOWED_PREFIXES))}")
184+
185+
if smelly_symbols:
186+
print(f"Found {len(smelly_symbols)} smelly symbols in total!")
187+
for symbol in sorted(smelly_symbols):
188+
print(f" - {symbol.name} from {symbol.library.path}")
189+
sys.exit(1)
190+
191+
print(f"OK: all exported symbols of all libraries",
192+
f"are prefixed with {' or '.join(map(repr, ALLOWED_PREFIXES))}",
193+
f"or are covered by exceptions")
170194

171195

172196
if __name__ == "__main__":

0 commit comments

Comments
 (0)