Skip to main content

pyOpenVBA

PyPI version Python versions CI License: MIT Downloads

Read and write VBA macros inside Office files, in pure Python.

No dependencies beyond the standard library. No Office install needed. Works on Windows, macOS and Linux. Python 3.10 or newer.

Four hosts, one API:

  • Excel (.xlsm, .xlsb, .xlam, .xls)
  • Word (.docm, .dotm, .doc)
  • PowerPoint (.pptm, .potm, .ppt)
  • Access (.accdb, .mdb)

Why use this?

Good Python tools exist for reading VBA out of Office files (oletools, olefile and friends), and they remain the right choice for forensics, malware analysis and audits. pyOpenVBA covers the next step: writing changes back so the file still opens cleanly in the host application.

The write path is the point of the library:

  • Modify a module's source in place.
  • Add a standard module, a class module, or code behind a form.
  • Rename a module everywhere its name lives, in one step.
  • Delete a module cleanly.
  • Design forms as well as their code: read a form's controls and properties, edit them, add and remove controls, or build a form from nothing.
  • Create a new .xlsm, .xlsb, .xlam, .docm, .pptm or .accdb file and put code in it.
  • Save, and have the file reopen in the host with no repair dialog.

Every format is verified against live Office: the saved file reopens without a repair prompt, and the code in it runs. Access edits are held to a stricter bar. Each write is compared byte for byte with the same edit made by Access or its database engine.

That makes it a good fit for:

  • Version-controlling VBA in git like any other source, then pushing edits back without opening Office.
  • Diffing two files to see what changed in a module or a form's design.
  • Building forms and macros from a script on a machine without Office.
  • Reading and writing macros on a server or in CI.
  • Letting an AI agent read and change the code in your Office files.

Installation

pip install pyOpenVBA

Requires Python 3.10 or newer. There are no other dependencies.

After installing, the CLI is available as a module or as a script:

python -m pyopenvba --help
pyopenvba --help

From source, for development:

git clone https://github.com/WilliamSmithEdward/pyOpenVBA
cd pyOpenVBA
pip install -e ".[dev]"

30-second tour

The four host classes share the same module API: module_names(), get_module(), set_module(), save().

Excel

from pyopenvba import ExcelFile

with ExcelFile("workbook.xlsm") as wb:
    print(wb.module_names())        # ['ThisWorkbook', 'Sheet1', 'Module1']
    source = wb.get_module("Module1")
    wb.set_module("Module1", 'Sub Hello()\r\n    MsgBox "hi"\r\nEnd Sub\r\n')
    wb.save()                       # in place
    # wb.save("edited.xlsm")        # or to a new file

Word

from pyopenvba import WordFile

with WordFile("document.docm") as doc:
    print(doc.module_names())       # ['ThisDocument', 'Module1']
    doc.set_module("Module1", 'Sub Hello()\r\n    MsgBox "hi"\r\nEnd Sub\r\n')
    doc.save()

PowerPoint

from pyopenvba import PowerPointFile

with PowerPointFile("presentation.pptm") as prs:
    print(prs.module_names())       # ['Module1']
    prs.set_module("Module1", 'Sub Hello()\r\n    MsgBox "hi"\r\nEnd Sub\r\n')
    prs.save()

Access

from pyopenvba import AccessDatabase

with AccessDatabase("database.accdb") as db:
    print(db.module_names())        # ['Module1', 'Form_Orders']
    source = db.get_module("Module1")
    db.set_module("Module1", "Option Compare Database\r\n\r\nPublic Sub Hello()\r\n    MsgBox \"hi\"\r\nEnd Sub")
    db.save()

Access keeps a compiled copy of the project beside the source and runs that copy. Writing source through pyOpenVBA marks the project for recompilation, so Access rebuilds it from the new source the next time it opens the file. The code has to compile, and Access takes a moment on that first open. Every one of these operations is checked by running the result in Access and comparing the value the code returns.


Create a new file

create_new() builds a fresh macro-enabled file from a template Office authored itself, so it opens with no repair prompt. The extension picks the format:

from pyopenvba import AccessDatabase, ExcelFile, PowerPointFile, WordFile

with ExcelFile.create_new("new_book.xlsm") as wb:        # also .xlsb, .xlam
    wb.set_module("Module1", 'Sub Hello()\r\n    MsgBox "xlsm"\r\nEnd Sub\r\n')
    wb.save()

with WordFile.create_new("new_doc.docm") as doc:
    doc.set_module("Module1", 'Sub Hello()\r\n    MsgBox "docm"\r\nEnd Sub\r\n')
    doc.save()

with PowerPointFile.create_new("new_prs.pptm") as prs:
    prs.set_module("Module1", 'Sub Hello()\r\n    MsgBox "pptm"\r\nEnd Sub\r\n')
    prs.save()

with AccessDatabase.create_new("new_db.accdb") as db:
    db.set_module("Module1", "Option Compare Database\r\n\r\nPublic Sub Hello()\r\nEnd Sub")
    db.save()

Add, rename or delete a module

vba_project() gives the project, and the same three calls work on every host:

from pyopenvba import ExcelFile, VBAModuleKind

with ExcelFile("workbook.xlsm") as wb:
    project = wb.vba_project()
    project.add_module("NewModule", 'Sub Hi()\r\n    MsgBox "hi"\r\nEnd Sub\r\n')
    project.add_module("MyClass", "Option Explicit\r\n", kind=VBAModuleKind.other)
    project.rename_module("OldName", "NewName")
    project.delete_module("Obsolete")
    wb.save("out.xlsm")
from pyopenvba import AccessDatabase, VBAModuleKind

with AccessDatabase("database.accdb") as db:
    project = db.vba_project()
    project.add_module("Helpers", "Option Compare Database\r\n\r\nPublic Function Twice(n As Long) As Long\r\n    Twice = n * 2\r\nEnd Function")
    project.add_module("Widget", "Option Compare Database", kind=VBAModuleKind.other)
    project.rename_module("Helpers", "Tools")
    project.delete_module("Widget")
    db.save()

A class source is accepted in any form: a bare body (the header is synthesized), a .cls file exported from the VBE (the VERSION ... CLASS preamble is stripped and the Attribute VB_Base line restored), or a full stream-form source. db.references(), db.add_reference(...) and db.drop_reference(...) manage the libraries an Access project points at.


Edit your macros as files on disk

The easiest way to keep VBA in a git repo: export every module to a folder, edit the files in any editor, push the changes back.

python -m pyopenvba pull workbook.xlsm ./vba     # every module to ./vba/*.bas and *.cls
python -m pyopenvba push ./vba workbook.xlsm     # edits back into the workbook
python -m pyopenvba ls workbook.xlsm             # list modules without extracting

python -m pyopenvba access-pull database.accdb ./vba
python -m pyopenvba access-push ./vba database.accdb
python -m pyopenvba access-ls database.accdb

The same from Python, one pair per host:

from pyopenvba import pull, push, pull_word, push_word, pull_ppt, push_ppt, pull_access, push_access

pull("workbook.xlsm", "./vba")
push("./vba", "workbook.xlsm", out="edited.xlsm")   # omit out= to save in place

pull_word("document.docm", "./vba")
push_word("./vba", "document.docm")

pull_ppt("presentation.pptm", "./vba")
push_ppt("./vba", "presentation.pptm")

pull_access("database.accdb", "./vba")
push_access("./vba", "database.accdb")

Module files use the extensions VBA already uses: .bas for standard modules, .cls for class modules and code-behind. push replaces the source of every module that has a file of its name; a file that matches no module is skipped, or refused with strict=True.


Forms

A form's code is a module like any other. Its design, which controls exist, how they nest and what their properties are, lives beside it and is read and written with the same calls on every host.

UserForms in Excel, Word and PowerPoint

import pyopenvba

with pyopenvba.ExcelFile("book.xlsm") as wb:
    for form in wb.forms():
        print(form.name, len(form.walk()), "controls")
        for control in form.walk():
            print(f"  {control.name:<16} {control.kind:<22} {control.properties()}")

    form = wb.forms()[0]
    form.control("OkButton").set_property("Caption", "Save")
    form.control("NameBox").set_property("MaxLength", 40)
    form.add_control("Label", "Hint", left=12, top=120, width=200)
    form.remove_control("OldCheckbox")
    wb.save()

Containers work too. A Frame gets a storage of its own and removing it takes its children; a MultiPage arrives with the two pages Excel gives it, and pages are added and removed through it:

form.add_control("Frame", "Shipping", left=12, top=160, width=200, height=80)
form.add_control("OptionButton", "Ground", container="Shipping")
form.add_control("MultiPage", "Wizard", left=12, top=40, width=300, height=200)
form.add_page("Wizard", name="Review", caption="Review && confirm")
form.remove_page("Page2", multipage="Wizard")

A form can be built from nothing. add_form creates the designer storage and the code-behind module together:

with pyopenvba.ExcelFile("book.xlsm") as wb:
    form = wb.add_form("Wizard", caption="Setup", width=300, height=200)
    form.add_control("Label", "Prompt", left=12, top=12, width=200)
    form.add_control("TextBox", "Answer", left=12, top=40, width=200)
    form.add_control("CommandButton", "Ok", left=12, top=80)
    wb.set_module("Wizard", "Private Sub Ok_Click()\r\n    Me.Hide\r\nEnd Sub\r\n")
    wb.save()

Geometry is in points, the unit the designer shows. set_property(name, None) clears a property, so the control goes back to its default. MSForms stores a property only when it differs from the control's default, so properties() returns what the developer set, which no live host can tell you. Writing is lossless: an unedited form saves back byte for byte.

The command line shows the tree:

python -m pyopenvba forms book.xlsm

Forms and reports in Access

Access forms and reports read and edit through the same surface. Sizes are in twips, the unit Access keeps, and a report takes kind="report":

from pyopenvba import AccessDatabase

with AccessDatabase("app.accdb") as db:
    for form in db.forms():
        print(form.name, [s.name for s in form.sections])
        for control in form.walk():
            print("  ", control.name, control.kind, control.properties().get("Caption"))

    form = db.add_form("Summary", caption="Totals", width=8000, height=3000)
    form.add_control("Label", "Title", left=240, top=240, width=2000, height=300, caption="Hello")
    form.add_control("TextBox", "Total", top=700, caption="=1+1")
    form.control("Title").set_property("FontSize", 14)
    form.remove_control("Total")
    form.set_code("Option Compare Database\r\n\r\nPrivate Sub Form_Load()\r\n    Me.Caption = \"Loaded\"\r\nEnd Sub")

    report = db.add_report("Monthly")
    report.add_control("Label", "Banner", section="PageHeaderSection", caption="Header band")
    db.delete_form("Old")
    db.save()

examples/access_form_demo.py builds a working order calculator this way: a form whose buttons call a standard module and keep their running total in a class module, laid out and coloured through set_property (fonts, fills, borders, hover colours, a currency format) and opened with the database through db.set_database_properties({"StartUpForm": "Calculator"}), with the database it produces beside it. Twenty-three control types can be written, including a tab control and its pages (form.add_control("Page", "First", parent="Tabs")); a navigation control is read but not written. Each control gets only the properties Access's own designs give its type, and set_property refuses a name the type does not have. A live gate opens every written design in Access's designer and reads back each control, measurement, caption and tab index.


Supported formats

Excel

Extension What it is Read Write create_new
.xlsm Macro-enabled workbook yes yes yes
.xlsb Binary workbook yes yes yes
.xlam Macro-enabled add-in yes yes yes
.xls Legacy (Excel 97-2003) yes yes no

Word

Extension What it is Read Write create_new
.docm Macro-enabled document yes yes yes
.dotm Macro-enabled template yes yes no
.doc Legacy (Word 97-2003) yes yes no

PowerPoint

Extension What it is Read Write create_new
.pptm Macro-enabled presentation yes yes yes
.potm Macro-enabled template yes yes no
.ppt Legacy (PowerPoint 97-2003) yes yes no

Access

Extension What it is Read Write create_new
.accdb Access database (ACE) yes yes yes
.mdb Access database (Jet 4) yes yes no

An Access file keeps its VBA project inside the database itself, in the system tables Access uses for its own objects, so writing a module means writing rows, long values and index entries the way the database engine does. AccessDatabase does that with a pure-Python implementation of the Jet 4 / ACE storage engine, documented rule by rule with how each was measured in docs/access_engine.md. Two things follow. A written module has no compiled copy until Access recompiles the project on its next open, which Access does on its own. And AccessReader, the older read-only class, is still there for inspecting a database: vba_modules(), read_project_info(), identifiers(), disassemble_module() and the MSysObjects catalog.

Every save is verified to reopen in the host application without the "we found a problem with some content" repair dialog.


Safety guards

save() refuses to silently produce a broken file.

Password-protected projects

A mutation to a password-protected project raises VBAProjectError unless you opt in:

wb.save(allow_protected=True)

AccessDatabase does the same: db.vba_is_protected() says whether the project carries a password, and db.save() refuses a VBA change to a protected project without allow_protected=True. The library never decrypts or changes the password; the protection bytes are preserved and the file still asks for the original password in the VBE.

Digitally signed projects

Any change to the macros invalidates a digital signature. On mutation the library drops the stale signature streams and emits a UserWarning:

import warnings
warnings.filterwarnings("error", category=UserWarning)   # treat as fatal

wb.save(allow_invalidate_signature=True)                 # or accept it

Out of scope

Preserved byte for byte but not interpreted:

  • VBA project password decryption or re-encryption.
  • Re-signing digitally signed projects.
  • ActiveX license editing.

docs/roadmap.md has the feature matrix.


Architecture

src/pyopenvba/
  __init__.py        public API: ExcelFile, WordFile, PowerPointFile,
                     AccessDatabase, AccessReader, VBAForm, FormControl,
                     pull/push for each host, VBAModuleKind, exceptions
  _host.py           VBAHostFile: shared open/edit/pull/push/save pipeline
  excel.py           ExcelFile (VBAHostFile subclass, create_new template)
  word.py            WordFile
  powerpoint.py      PowerPointFile (.ppt overrides the container hooks)
  access/            AccessDatabase: the VBA project, forms and reports,
                     and the Jet 4 / ACE storage engine they live in
  access_read.py     AccessReader: the older read-only inspector
  vba.py             VBA project parser and MS-OVBA codec
  vba_pcode.py       VBA7 p-code disassembler
  cfb.py             MS-CFB (Compound File Binary) parser/writer
  forms.py           UserForm designer streams: control tree, read and write
  _oforms_records.py [MS-OFORMS] property table, one per control class
  _oforms_pages.py   a MultiPage's tabs and page bookkeeping
  _ppt_container.py  the VBA project a binary .ppt hides in its document stream
  exceptions.py      exception hierarchy
  _templates/        empty .xlsm/.xlsb/.xlam/.docm/.pptm/.accdb bytes for create_new()
  __main__.py        python -m pyopenvba {pull,push,ls,forms,disasm,access-ls,access-pull,access-push,access-disasm}

For more:


Contributing

Bug reports, files that break the library, and pull requests are welcome. Please include the file, or a minimal redacted version, when filing a parsing bug.

Run the same checks as CI:

pip install -e ".[dev]"
pyright src tests
pytest -p no:randomly

On Windows with desktop Office installed you can also run the live gates, which are skipped by default and in CI. Each builds a file with pyOpenVBA and has the real application open it, run its code, or perform the same edit for a byte-for-byte comparison:

$env:RUN_LIVE_EXCEL = "1"; pytest tests/test_live_excel_gate.py
$env:RUN_LIVE_ACCESS = "1"; pytest tests/test_live_access_engine_gate.py
$env:RUN_LIVE_ACCESS_VBA = "1"; pytest tests/test_live_access_design_gate.py

CI runs the test matrix on Python 3.10 through 3.14 on Linux, plus 3.12 on Windows and macOS, on every push and pull request. Releases go to PyPI when a v*.*.* tag is pushed.


License

MIT.


Support open source

If pyOpenVBA saves you time or helps your team keep VBA maintainable, support keeps the project moving.

Release files for pyOpenVBA 4.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pyOpenVBA 4.0.0
File Size Uploaded
pyopenvba-4.0.0.tar.gz 588.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pyOpenVBA 4.0.0
File Interpreter ABI Platform
pyopenvba-4.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.1 MB

Release files / pyopenvba-4.0.0.tar.gz

Download URL pyopenvba-4.0.0.tar.gz
Size 588.6 kB
Tags Source
SHA-256 checksum
How to use checksums
bbe2ef975c4348023dd0b4f6861d2c3ccd9c3bd2352b39ee2d87a8ff425c7818
BLAKE2b-256 checksum
How to use checksums
ad6ff79d1ad0d47c1dbdacd6f19a37387ebbddeb5a55d62997852a0a5d881396
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 5, 2026.

Transparency log

Release files / pyopenvba-4.0.0-py3-none-any.whl

Download URL pyopenvba-4.0.0-py3-none-any.whl
Size 556.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c8ae52396870dcdaac06c1855efe94279c846ba7993221c7ea8381147961430d
BLAKE2b-256 checksum
How to use checksums
68ce60a558e8c94a4ce97d7e7b638a41c6bdd8219cfdf4f53d42bd68a40aa8c0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 5, 2026.

Transparency log

Release history Release notifications | RSS feed

6.0.0

2 release files

5.2.4

2 release files

5.2.3

2 release files

5.2.2

2 release files

5.2.1

2 release files

5.2.0

2 release files

5.1.3

2 release files

5.1.2

2 release files

5.1.1

2 release files

5.1.0

2 release files

5.0.0

2 release files

This release

4.0.0 This release

2 release files

3.5.1

2 release files

3.5.0

2 release files

3.4.0

2 release files

3.3.0

2 release files

3.2.0

2 release files

3.1.0

2 release files

3.0.1

2 release files

3.0.0

2 release files

2.0.1

2 release files

2.0.0

2 release files

1.1.1

2 release files

1.1.0

2 release files

1.0.1

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page