diff --git a/README.md b/README.md index 0f79299..52b7644 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,18 @@ imported_symbols: - ... ``` +With the optional `demangle` extra (`pip install elfdeps[demangle]`), +`--demangle` demanges C++ symbol names: + +```shell-session +$ elfdeps --symbols --demangle torchaudio-2.11.0-cp312-cp312-manylinux_2_28_x86_64.whl +... +exported_symbols: + - torchaudio::cuda_version() + - torchaudio::is_align_available() + - ... +``` + ## RPM In Fedora-based distributions, RPM packages provide and require virtual packages with ELF sonames and versions. The package manager can install virtual provides. diff --git a/pyproject.toml b/pyproject.toml index f30fbcb..f40d456 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,9 @@ dependencies = [ ] [project.optional-dependencies] +demangle = [ + "pycxxfilt", +] test = [ "pytest", "coverage[toml]", @@ -114,3 +117,7 @@ disallow_untyped_defs = true [[tool.mypy.overrides]] module = ["elftools.*"] ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["pycxxfilt"] +ignore_missing_imports = true diff --git a/src/elfdeps/__main__.py b/src/elfdeps/__main__.py index fe5869f..66492f3 100644 --- a/src/elfdeps/__main__.py +++ b/src/elfdeps/__main__.py @@ -8,6 +8,11 @@ import tarfile import zipfile +try: + import pycxxfilt +except ImportError: + pycxxfilt = None # type: ignore[assignment] + from . import _archives, _elfdeps ZIPEXT = (".zip", ".whl") @@ -73,9 +78,38 @@ dest="symbols", help="Include exported and imported dynamic symbols", ) +parser.add_argument( + "--demangle", + action="store_true", + dest="demangle", + help="Demangle C++ symbol names (requires --symbols and pycxxfilt)", +) -def _format_elfinfo(info: _elfdeps.ELFInfo) -> str: +def _format_symbol( + sym: _elfdeps.SymbolInfo, + demangle: bool = False, +) -> str: + """Format a SymbolInfo, optionally demangling the name.""" + name = sym.name + if demangle: + try: + demangled = pycxxfilt.demangle(name) + except ValueError: + # LLVM demangler may not be able to demangle all symbols. Show + # the original, umangled name. + demangled = None + if demangled is not None: + name = demangled + if sym.version: + return f"{name}@{sym.version}" + return name + + +def _format_elfinfo( + info: _elfdeps.ELFInfo, + demangle: bool = False, +) -> str: """Format ELFInfo as human-readable YAML-like output.""" lines: list[str] = [] for field in dataclasses.fields(info): @@ -89,10 +123,16 @@ def _format_elfinfo(info: _elfdeps.ELFInfo) -> str: lines.append(f"{field.name}: []") else: lines.append(f"{field.name}:") - _sort = field.name in ("exported_symbols", "imported_symbols") - items = sorted(value) if _sort else value + _is_syms = field.name in ( + "exported_symbols", + "imported_symbols", + ) + items = sorted(value) if _is_syms else value for item in items: - lines.append(f" - {item}") + if _is_syms: + lines.append(f" - {_format_symbol(item, demangle)}") + else: + lines.append(f" - {item}") elif value is None: # skip fields that are None when not requested (e.g. symbols) continue @@ -103,6 +143,15 @@ def _format_elfinfo(info: _elfdeps.ELFInfo) -> str: def main(argv: list[str] | None = None) -> None: args = parser.parse_args(argv) + if args.demangle: + if not args.symbols: + parser.error("--demangle requires --symbols") + if pycxxfilt is None: + parser.error( + "--demangle requires the 'pycxxfilt' package " + "(install with: pip install elfdeps[demangle])" + ) + settings = _elfdeps.ELFAnalyzeSettings( soname_only=args.soname_only, fake_soname=args.fake_soname, @@ -141,7 +190,7 @@ def main(argv: list[str] | None = None) -> None: for i, info in enumerate(sorted(infos)): if i > 0: print("---") - print(_format_elfinfo(info)) + print(_format_elfinfo(info, demangle=args.demangle)) if __name__ == "__main__": diff --git a/tests/test_elfdeps.py b/tests/test_elfdeps.py index 4842385..1339907 100644 --- a/tests/test_elfdeps.py +++ b/tests/test_elfdeps.py @@ -3,11 +3,13 @@ import sys import sysconfig import tarfile +import unittest.mock import zipfile import pytest import elfdeps +from elfdeps import __main__ as cli SYMBOLS_SETTINGS = elfdeps.ELFAnalyzeSettings(include_symbols=True) @@ -240,3 +242,73 @@ def test_symbols_libpython() -> None: sym = exported[name] assert sym.binding == elfdeps.SymbolBinding.GLOBAL assert sym.type == elfdeps.SymbolType.OBJECT + + +class TestCLISymbols: + """Test --symbols and --demangle CLI options.""" + + def test_symbols_in_output(self, capsys: pytest.CaptureFixture[str]) -> None: + """--symbols includes exported/imported symbols in output.""" + cli.main([str(sys.executable), "--symbols"]) + out = capsys.readouterr().out + assert "exported_symbols:" in out + assert "imported_symbols:" in out + + def test_no_symbols_in_output(self, capsys: pytest.CaptureFixture[str]) -> None: + """Without --symbols, symbol fields are omitted.""" + cli.main([str(sys.executable)]) + out = capsys.readouterr().out + assert "exported_symbols" not in out + assert "imported_symbols" not in out + + def test_demangle_requires_symbols(self) -> None: + """--demangle without --symbols is an error.""" + with pytest.raises(SystemExit, match="2"): + cli.main([str(sys.executable), "--demangle"]) + + def test_demangle_requires_pycxxfilt(self) -> None: + """--demangle errors when pycxxfilt is not installed.""" + with unittest.mock.patch.object(cli, "pycxxfilt", None): + with pytest.raises(SystemExit, match="2"): + cli.main([str(sys.executable), "--symbols", "--demangle"]) + + def test_format_symbol_no_demangle(self) -> None: + """_format_symbol without demangle returns name[@version].""" + sym = elfdeps.SymbolInfo( + "_Z3fooi", "V1", elfdeps.SymbolBinding.GLOBAL, elfdeps.SymbolType.FUNC + ) + assert cli._format_symbol(sym) == "_Z3fooi@V1" + + sym_plain = elfdeps.SymbolInfo( + "_Z3fooi", None, elfdeps.SymbolBinding.GLOBAL, elfdeps.SymbolType.FUNC + ) + assert cli._format_symbol(sym_plain) == "_Z3fooi" + + def test_format_symbol_demangle(self) -> None: + """_format_symbol with demangle demanges C++ names.""" + pycxxfilt = pytest.importorskip("pycxxfilt") # noqa: F841 + sym = elfdeps.SymbolInfo( + "_Z3fooi", "V1", elfdeps.SymbolBinding.GLOBAL, elfdeps.SymbolType.FUNC + ) + result = cli._format_symbol(sym, demangle=True) + assert result == "foo(int)@V1" + + def test_format_symbol_demangle_not_mangled(self) -> None: + """_format_symbol with demangle keeps non-mangled names.""" + pycxxfilt = pytest.importorskip("pycxxfilt") # noqa: F841 + sym = elfdeps.SymbolInfo( + "printf", + "GLIBC_2.34", + elfdeps.SymbolBinding.GLOBAL, + elfdeps.SymbolType.FUNC, + ) + result = cli._format_symbol(sym, demangle=True) + assert result == "printf@GLIBC_2.34" + + def test_cli_demangle_output(self, capsys: pytest.CaptureFixture[str]) -> None: + """--symbols --demangle produces demangled output.""" + pycxxfilt = pytest.importorskip("pycxxfilt") # noqa: F841 + cli.main([str(sys.executable), "--symbols", "--demangle"]) + out = capsys.readouterr().out + assert "exported_symbols:" in out + assert "imported_symbols:" in out