Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ dependencies = [
]

[project.optional-dependencies]
demangle = [
"pycxxfilt",
]
test = [
"pytest",
"coverage[toml]",
Expand Down Expand Up @@ -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
59 changes: 54 additions & 5 deletions src/elfdeps/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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__":
Expand Down
72 changes: 72 additions & 0 deletions tests/test_elfdeps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Loading