Skip to content

Commit b2e5803

Browse files
tiranclaude
andcommitted
cli: add --demangle option for C++ symbol names
Add optional dependency 'demangle' with pycxxfilt. The --demangle flag requires --symbols and demanges C++ mangled names in the output. Errors with a clear message if --symbols is missing or pycxxfilt is not installed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b4e11d6 commit b2e5803

4 files changed

Lines changed: 145 additions & 5 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ imported_symbols:
5757
- ...
5858
```
5959

60+
With the optional `demangle` extra (`pip install elfdeps[demangle]`),
61+
`--demangle` demanges C++ symbol names:
62+
63+
```shell-session
64+
$ elfdeps --symbols --demangle torchaudio-2.11.0-cp312-cp312-manylinux_2_28_x86_64.whl
65+
...
66+
exported_symbols:
67+
- torchaudio::cuda_version()
68+
- torchaudio::is_align_available()
69+
- ...
70+
```
71+
6072
## RPM
6173

6274
In Fedora-based distributions, RPM packages provide and require virtual packages with ELF sonames and versions. The package manager can install virtual provides.

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ dependencies = [
3535
]
3636

3737
[project.optional-dependencies]
38+
demangle = [
39+
"pycxxfilt",
40+
]
3841
test = [
3942
"pytest",
4043
"coverage[toml]",
@@ -114,3 +117,7 @@ disallow_untyped_defs = true
114117
[[tool.mypy.overrides]]
115118
module = ["elftools.*"]
116119
ignore_missing_imports = true
120+
121+
[[tool.mypy.overrides]]
122+
module = ["pycxxfilt"]
123+
ignore_missing_imports = true

src/elfdeps/__main__.py

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
import tarfile
99
import zipfile
1010

11+
try:
12+
import pycxxfilt
13+
except ImportError:
14+
pycxxfilt = None # type: ignore[assignment]
15+
1116
from . import _archives, _elfdeps
1217

1318
ZIPEXT = (".zip", ".whl")
@@ -73,9 +78,38 @@
7378
dest="symbols",
7479
help="Include exported and imported dynamic symbols",
7580
)
81+
parser.add_argument(
82+
"--demangle",
83+
action="store_true",
84+
dest="demangle",
85+
help="Demangle C++ symbol names (requires --symbols and pycxxfilt)",
86+
)
7687

7788

78-
def _format_elfinfo(info: _elfdeps.ELFInfo) -> str:
89+
def _format_symbol(
90+
sym: _elfdeps.SymbolInfo,
91+
demangle: bool = False,
92+
) -> str:
93+
"""Format a SymbolInfo, optionally demangling the name."""
94+
name = sym.name
95+
if demangle:
96+
try:
97+
demangled = pycxxfilt.demangle(name)
98+
except ValueError:
99+
# LLVM demangler may not be able to demangle all symbols. Show
100+
# the original, umangled name.
101+
demangled = None
102+
if demangled is not None:
103+
name = demangled
104+
if sym.version:
105+
return f"{name}@{sym.version}"
106+
return name
107+
108+
109+
def _format_elfinfo(
110+
info: _elfdeps.ELFInfo,
111+
demangle: bool = False,
112+
) -> str:
79113
"""Format ELFInfo as human-readable YAML-like output."""
80114
lines: list[str] = []
81115
for field in dataclasses.fields(info):
@@ -89,10 +123,16 @@ def _format_elfinfo(info: _elfdeps.ELFInfo) -> str:
89123
lines.append(f"{field.name}: []")
90124
else:
91125
lines.append(f"{field.name}:")
92-
_sort = field.name in ("exported_symbols", "imported_symbols")
93-
items = sorted(value) if _sort else value
126+
_is_syms = field.name in (
127+
"exported_symbols",
128+
"imported_symbols",
129+
)
130+
items = sorted(value) if _is_syms else value
94131
for item in items:
95-
lines.append(f" - {item}")
132+
if _is_syms:
133+
lines.append(f" - {_format_symbol(item, demangle)}")
134+
else:
135+
lines.append(f" - {item}")
96136
elif value is None:
97137
# skip fields that are None when not requested (e.g. symbols)
98138
continue
@@ -103,6 +143,15 @@ def _format_elfinfo(info: _elfdeps.ELFInfo) -> str:
103143

104144
def main(argv: list[str] | None = None) -> None:
105145
args = parser.parse_args(argv)
146+
if args.demangle:
147+
if not args.symbols:
148+
parser.error("--demangle requires --symbols")
149+
if pycxxfilt is None:
150+
parser.error(
151+
"--demangle requires the 'pycxxfilt' package "
152+
"(install with: pip install elfdeps[demangle])"
153+
)
154+
106155
settings = _elfdeps.ELFAnalyzeSettings(
107156
soname_only=args.soname_only,
108157
fake_soname=args.fake_soname,
@@ -141,7 +190,7 @@ def main(argv: list[str] | None = None) -> None:
141190
for i, info in enumerate(sorted(infos)):
142191
if i > 0:
143192
print("---")
144-
print(_format_elfinfo(info))
193+
print(_format_elfinfo(info, demangle=args.demangle))
145194

146195

147196
if __name__ == "__main__":

tests/test_elfdeps.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
import sys
44
import sysconfig
55
import tarfile
6+
import unittest.mock
67
import zipfile
78

89
import pytest
910

1011
import elfdeps
12+
from elfdeps import __main__ as cli
1113

1214
SYMBOLS_SETTINGS = elfdeps.ELFAnalyzeSettings(include_symbols=True)
1315

@@ -240,3 +242,73 @@ def test_symbols_libpython() -> None:
240242
sym = exported[name]
241243
assert sym.binding == elfdeps.SymbolBinding.GLOBAL
242244
assert sym.type == elfdeps.SymbolType.OBJECT
245+
246+
247+
class TestCLISymbols:
248+
"""Test --symbols and --demangle CLI options."""
249+
250+
def test_symbols_in_output(self, capsys: pytest.CaptureFixture[str]) -> None:
251+
"""--symbols includes exported/imported symbols in output."""
252+
cli.main([str(sys.executable), "--symbols"])
253+
out = capsys.readouterr().out
254+
assert "exported_symbols:" in out
255+
assert "imported_symbols:" in out
256+
257+
def test_no_symbols_in_output(self, capsys: pytest.CaptureFixture[str]) -> None:
258+
"""Without --symbols, symbol fields are omitted."""
259+
cli.main([str(sys.executable)])
260+
out = capsys.readouterr().out
261+
assert "exported_symbols" not in out
262+
assert "imported_symbols" not in out
263+
264+
def test_demangle_requires_symbols(self) -> None:
265+
"""--demangle without --symbols is an error."""
266+
with pytest.raises(SystemExit, match="2"):
267+
cli.main([str(sys.executable), "--demangle"])
268+
269+
def test_demangle_requires_pycxxfilt(self) -> None:
270+
"""--demangle errors when pycxxfilt is not installed."""
271+
with unittest.mock.patch.object(cli, "pycxxfilt", None):
272+
with pytest.raises(SystemExit, match="2"):
273+
cli.main([str(sys.executable), "--symbols", "--demangle"])
274+
275+
def test_format_symbol_no_demangle(self) -> None:
276+
"""_format_symbol without demangle returns name[@version]."""
277+
sym = elfdeps.SymbolInfo(
278+
"_Z3fooi", "V1", elfdeps.SymbolBinding.GLOBAL, elfdeps.SymbolType.FUNC
279+
)
280+
assert cli._format_symbol(sym) == "_Z3fooi@V1"
281+
282+
sym_plain = elfdeps.SymbolInfo(
283+
"_Z3fooi", None, elfdeps.SymbolBinding.GLOBAL, elfdeps.SymbolType.FUNC
284+
)
285+
assert cli._format_symbol(sym_plain) == "_Z3fooi"
286+
287+
def test_format_symbol_demangle(self) -> None:
288+
"""_format_symbol with demangle demanges C++ names."""
289+
pycxxfilt = pytest.importorskip("pycxxfilt") # noqa: F841
290+
sym = elfdeps.SymbolInfo(
291+
"_Z3fooi", "V1", elfdeps.SymbolBinding.GLOBAL, elfdeps.SymbolType.FUNC
292+
)
293+
result = cli._format_symbol(sym, demangle=True)
294+
assert result == "foo(int)@V1"
295+
296+
def test_format_symbol_demangle_not_mangled(self) -> None:
297+
"""_format_symbol with demangle keeps non-mangled names."""
298+
pycxxfilt = pytest.importorskip("pycxxfilt") # noqa: F841
299+
sym = elfdeps.SymbolInfo(
300+
"printf",
301+
"GLIBC_2.34",
302+
elfdeps.SymbolBinding.GLOBAL,
303+
elfdeps.SymbolType.FUNC,
304+
)
305+
result = cli._format_symbol(sym, demangle=True)
306+
assert result == "printf@GLIBC_2.34"
307+
308+
def test_cli_demangle_output(self, capsys: pytest.CaptureFixture[str]) -> None:
309+
"""--symbols --demangle produces demangled output."""
310+
pycxxfilt = pytest.importorskip("pycxxfilt") # noqa: F841
311+
cli.main([str(sys.executable), "--symbols", "--demangle"])
312+
out = capsys.readouterr().out
313+
assert "exported_symbols:" in out
314+
assert "imported_symbols:" in out

0 commit comments

Comments
 (0)