Skip to content

linexcel.vba

linexcel.vba

Extraction and static lineage of embedded VBA code.

Extraction relies on oletools (olevba). Analysis is static and deliberately heuristic: it identifies procedures, the internal call graph, and cell/range access expressed literally (Range("A1"), Cells(2, 3), [A1:B4], Worksheets("X").Range(...)).

VbaRef dataclass

A range access detected in a procedure.

Source code in src/linexcel/vba.py
@dataclass
class VbaRef:
    """A range access detected in a procedure."""

    sheet: str | None
    ref: str
    access: str  # "read" | "write"
    line: int

extract_vba_modules

extract_vba_modules(data: bytes, filename: str, warnings: list[str] | None = None) -> dict[str, str]

Extract {module_name: code} via olevba.

A workbook holding no macro yields an empty mapping, and so did every failure below: an unreadable VBA project, a module stream olevba chokes on, oletools missing altogether. The report then showed a macro workbook as having no code at all and said nothing about it — the one reading it cannot tell "no macros" from "we could not read them". Every such reason is therefore appended to warnings when a list is given.

A failure part-way through keeps the modules already read. Their procedures and call graph are real, and the warning says the rest is missing; dropping them would trade a partial answer for none.

olevba dispatches on the file's own header rather than on filename, so a macro workbook is found whatever it is called — including the default name :func:linexcel.analyze gives a workbook handed to it as bytes.

Source code in src/linexcel/vba.py
def extract_vba_modules(
    data: bytes, filename: str, warnings: list[str] | None = None
) -> dict[str, str]:
    """Extract ``{module_name: code}`` via olevba.

    A workbook holding no macro yields an empty mapping, and so did every
    failure below: an unreadable VBA project, a module stream olevba chokes on,
    oletools missing altogether. The report then showed a macro workbook as
    having no code at all and said nothing about it — the one reading it cannot
    tell "no macros" from "we could not read them". Every such reason is
    therefore appended to ``warnings`` when a list is given.

    A failure part-way through keeps the modules already read. Their procedures
    and call graph are real, and the warning says the rest is missing; dropping
    them would trade a partial answer for none.

    olevba dispatches on the file's own header rather than on ``filename``, so
    a macro workbook is found whatever it is called — including the default name
    :func:`linexcel.analyze` gives a workbook handed to it as bytes.
    """
    try:
        from oletools.olevba import VBA_Parser
    except ImportError:  # pragma: no cover - dependency installed in prod
        _warn(warnings, "oletools is not installed; VBA code was not extracted")
        return {}
    try:
        parser = VBA_Parser(filename, data=data)
    except Exception as exc:
        _warn(warnings, f"the VBA project could not be opened: {exc}")
        return {}
    modules: dict[str, str] = {}
    try:
        if not parser.detect_vba_macros():
            return {}
        streams = 0
        for _f, _path, vba_filename, code in parser.extract_macros():
            streams += 1
            # oletools may yield bytes (undecodable module streams)
            if isinstance(vba_filename, bytes):
                vba_filename = vba_filename.decode("utf-8", "replace")
            if isinstance(code, bytes):
                code = code.decode("utf-8", "replace")
            name = (vba_filename or "Module").rsplit("/", 1)[-1]
            name = re.sub(r"\.(bas|cls|frm)$", "", name, flags=re.IGNORECASE)
            if code and code.strip() and not _is_attribute_only(code):
                existing = modules.get(name)
                modules[name] = (existing + "\n" + code) if existing else code
        # Only the case where olevba announces macros and then hands back no
        # stream at all. A macro-enabled workbook nobody wrote code in — saved
        # from a template, say — yields the sheet shells and no module, and
        # that is not a defect to report.
        if not streams:
            _warn(
                warnings,
                "the workbook declares VBA macros but no module stream could be read",
            )
    except Exception as exc:
        _warn(
            warnings,
            f"VBA extraction stopped after {len(modules)} module(s) read: {exc}",
        )
    finally:
        try:
            parser.close()
        except Exception:
            pass
    return modules

analyze_vba

analyze_vba(modules: dict[str, str]) -> list[VbaProc]

Full static analysis: procedures, range access, call graph.

Source code in src/linexcel/vba.py
def analyze_vba(modules: dict[str, str]) -> list[VbaProc]:
    """Full static analysis: procedures, range access, call graph."""
    procs: list[VbaProc] = []
    for module, code in modules.items():
        procs.extend(_split_procedures(module, code))
    known = {p.name.lower(): p.name for p in procs}
    modules_by_name = {module.lower(): module for module in modules}
    for proc in procs:
        proc.refs = _find_refs(proc)
        proc.calls = _find_calls(proc, known, modules_by_name)
    return procs