purepdb
A minimal, dependency-free pure-Python parser for Microsoft PDB debug-info files. Purpose-built to answer one question well: what are the functions in this binary and where are their entry points?
It is not a reimplementation of llvm-pdbutil — it is a thin vertical slice
through the same format stack, written from the published format
documentation. See NOTICE for provenance and prior art.
Install
uv pip install -e . --group dev # pytest, ruff and ty
Runtime dependencies: none. Python 3.11+.
Usage
from purepdb import PDB
pdb = PDB.open("app.pdb")
for fn in pdb.functions():
print(hex(fn.rva or 0), fn.name)
# fn.segment, fn.offset, fn.code_size, fn.source, fn.aliases, fn.module
module is the linker input the address came from — an .obj path, a library
member, or Import:foo.dll for an import thunk — taken from DBI's Section
Contribution table. It is what separates library code from application code
without guessing from the name: 3453 of sqlite3 x86's 3620 functions come from
sqlite3.lo, the rest from the CRT and from import thunks.
rva is image-relative. Add the PE image base yourself if you need virtual
addresses.
source is "proc", "public" or "thunk", naming the record the entry came
from. Incremental-link trampolines are not in this list — they carry no name,
so pdb.trampolines() reports them separately, as a code range plus the address
it jumps to.
aliases holds the other names at the same entry point. Linkers fold identical
bodies (/OPT:ICF, and rust-lld by default), so one address legitimately
carries several correct names; fn.names gives all of them with fn.name
first. On sqlite3 x86 that is 438 of 3620 functions, worst case 4 names.
pdb.lines() yields (rva, file, line) for every source line the PDB records —
70157 of them in sqlite3 x86, across 133 files. It is a generator; the file
names come from the /names stream, which pdb.named_streams() locates.
CLI:
purepdb functions app.pdb # name + entry-point RVA
purepdb publics app.pdb
purepdb info app.pdb
purepdb diagnose app.pdb # what the PDB contains, and why a listing is thin
When a listing comes back short
Every failure mode this parser has on real files produces an empty result
rather than an exception, so diagnose() exists to tell them apart:
$ purepdb diagnose app.pdb
proc records : 0
public records : 7400
WARNING: no procedure records in 285 module streams (dominant kinds:
0x1167x110161, S_TRAMPOLINEx4610, ...); function names can only come from the
7400 public records. This is what /DEBUG:FASTLINK and some pre-2010 toolchains
produce
The CLI prints these warnings automatically after functions and publics.
Two things worth knowing about publics
They live in the symbol-record stream. DBI's PublicStreamIndex names a
hash stream holding offsets, not records — scanning it for S_PUB32 finds
nothing at all, silently. purepdb.gsi documents the layout; the publics stream
is used only for its address map, which supplies address ordering.
The function flag is not reliable across linkers. link.exe sets
PUBLIC_FLAG_FUNCTION on every code public (all 438 of sqlite3 x86's).
rust-lld leaves it clear on 143 of 280, including mainCRTStartup and
__chkstk. So a public also counts as a function when it resolves into an
executable section — worth 36% of the functions in a Rust PE.
This means functions() deliberately returns more than the flag alone would.
On the Rust fixture, 164 entries are public-sourced while only 142 publics carry
the function flag. The extra ones are real code — every one resolves inside
.text, verified against the image — but a consumer that previously filtered on
PublicSymbol.is_function will see entries it does not expect. Pass
functions(code_publics=False) for flag-only behaviour, and note that
public_symbols() is unfiltered either way, so is_function still means exactly
what the record says.
Inlined functions
An inlined body has no entry point, so it has no procedure record and no public
— functions() cannot see it by construction. pdb.inline_sites() reports them
separately, each with its name, the code ranges it occupies inside its caller,
and which function that is:
for site in pdb.inline_sites():
print(hex(site.rva or 0), site.name, "inlined into", site.parent)
On the Rust fixture that is 3797 sites against 248 procedure records — fifteen inlined bodies for every function with an entry point, and the largest naming gap the parser had.
Scope
Supported: MSF 7.00 container; PDB info stream; DBI stream (module list,
section contributions, publics/symbol-record streams, optional debug header);
CodeView S_PUB32, S_GPROC32/S_LPROC32 (and _ID variants),
S_GDATA32/S_LDATA32, S_PROCREF/S_LPROCREF, S_CONSTANT, S_UDT,
S_THUNK32, S_TRAMPOLINE, S_INLINESITE with its binary annotations;
section-header table for segment:offset -> RVA, with DBI's Section Map as the
fallback when that table is absent; OMAP address translation for images whose
code was moved after linking; the named stream map, the /names string table
and the C13 DEBUG_S_LINES / DEBUG_S_FILECHECKSUMS subsections for rva -> file:line; the IPI id records that name an inlinee.
Not supported: TPI type decoding, column info, demangling (names come back
raw). The IPI stream is read only for the names inlined bodies refer to by id;
no type is decoded. /DEBUG:FASTLINK PDBs yield publics only, and say so.
Where the section-header stream is missing, addresses are rebuilt from the
Section Map, which records segment sizes but no addresses. diagnose() says
when that happened, because the result is a reconstruction — taking the stream
away from each fixture leaves every function at the address it had before, but
it assumes the default 0x1000 section alignment.
Tests
.venv/bin/python -m pytest -q
make lint # ruff, then ty
make fuzz # malformed input must not escape as an exception
Two layers. Synthetic tests build MSF/PDB byte streams with a builder
independent of the reader, so they exercise a real serialise→parse round trip.
Golden tests run against real link.exe and rust-lld output in tests/data/,
32- and 64-bit, and cross-check against the companion PE image — section table,
and the address of every exported function after following its jmp thunk. The
PE reader in tests/_pe.py is stdlib-only and never consults the PDB, so
agreement is evidence rather than a shared assumption.
A third layer runs outside pytest. tools/fuzz.py drives every public entry
point over random, structurally-corrupted and bit-flipped input, and fails if
anything other than PdbError escapes -- the contract a caller writes
except PdbError against. GitHub Actions runs a short pass on every change
and a longer one nightly with a rotating seed; a failing input is saved and
uploaded as an artefact so it can be replayed.
Releasing
Versions follow Semantic Versioning,
and CHANGELOG.md follows
Keep a Changelog. What the version
number covers is stated at the top of that file: the API in __all__, not the
count of symbols a release happens to recover from a given PDB.
To cut a release:
- Move the
Unreleasedentries under a new## [x.y.z] - YYYY-MM-DDheading, and update the link definitions at the bottom of the file. - Set the same version in
pyproject.toml. - Tag it:
git tag -a vx.y.z -m 'purepdb x.y.z'and push the tag.
Pushing the tag runs .github/workflows/release.yml, which builds the sdist
and wheel, checks their metadata with twine, fails if the tag and the
packaged version disagree, runs the suite against what it built, and
attaches the artefacts to the GitHub release for that tag — creating the
release with generated notes if it does not already exist.
Publishing to PyPI stays manual (make publish). Automating it needs either a
stored token or a Trusted Publisher configured against the repository, which
is a maintainer decision rather than something a workflow should assume.
tests/data/ is in the repository but excluded from the sdist and wheel, so
installing purepdb does not pull down 12 MB of binaries. Those tests skip when
the data is absent — clone the repo to run them.
The suite needs no external tool. Results are also cross-checked
record-by-record against llvm-pdbutil during development, where that toolchain
is available.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file purepdb-0.3.0.tar.gz.
File metadata
- Download URL: purepdb-0.3.0.tar.gz
- Upload date:
- Size: 90.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
47e3170770e42d669ade3bd6b5fbbb581811bc2bdca0f4757526832273eb0d88
|
|
| MD5 |
e180fc7f698f2b4e88df143c7759230d
|
|
| BLAKE2b-256 |
82ae58ed7e09346c409d48441ae149b6c58f9840f40f32a15e838a89ba5ca7b2
|
File details
Details for the file purepdb-0.3.0-py3-none-any.whl.
File metadata
- Download URL: purepdb-0.3.0-py3-none-any.whl
- Upload date:
- Size: 50.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f1a8ab9b08e9879b03a0e0a2c93b28027f47b9c58b4d8e1326c66fbb2095e13c
|
|
| MD5 |
23956e1b5f796a6e08312a0770a74765
|
|
| BLAKE2b-256 |
b7c96318709011fe373981a20bf4e6a8b6f093ae761fac4fc92ed842b3f752e3
|