Skip to main content

pyOpenVBA

PyPI version Python versions CI License: MIT Downloads

Read and write the code inside Office files, in pure Python: VBA macros in four hosts, and Power Query in Excel.

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

VBA, four hosts and one API:

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

Power Query, in any Excel package:

from pyopenvba import PowerQueryWorkbook

with PowerQueryWorkbook("orders.xlsx") as book:
    print(book.query_names())
    book.query("Orders").formula = "let Source = Excel.CurrentWorkbook() in Source"
    book.save()

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.
  • Query as well as code: read, edit, add, group and load Power Queries, or write a whole workbook of them from nothing.
  • Create a new .xlsx, .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. Two parts are held to a stricter bar. Access edits are compared byte for byte with the same edit made by Access or its database engine, and the Power Query writer is compared with Microsoft's own packaging assemblies.

That makes it a good fit for:

  • Version-controlling VBA and M in git like any other source, then pushing edits back without opening Office.
  • Diffing two files to see what changed in a module, a form's design or a query.
  • Building forms, macros and queries from a script on a machine without Office.
  • Reading and writing Office code 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 and PowerPoint

The same three calls, on the class for the host:

from pyopenvba import PowerPointFile, WordFile

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

with PowerPointFile("presentation.pptm") as prs:
    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()

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()

A workbook with no macros in it comes from PowerQueryWorkbook, which adds the Power Query package the first time a query is written:

from pyopenvba import PowerQueryWorkbook

with PowerQueryWorkbook.create_new("new_book.xlsx") as book:
    book.add_query("Numbers", "let Source = {1..10} in Source")
    book.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.rename_form("Draft", "Invoice")
    db.delete_form("Old")
    db.save()

Renaming and deleting reach the code behind a design as well as the design itself. A form's module is bound to it by name, so Form_Draft becomes Form_Invoice and a deleted form takes its module with it.

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.


Power Query

Excel keeps its Get and Transform queries in one custom XML part of the workbook: an M section document, a metadata document beside it, and a permission list, all inside a base64 blob. PowerQueryWorkbook reads that, edits it and writes it back, for any Excel package including plain .xlsx.

from pyopenvba import PowerQueryWorkbook

with PowerQueryWorkbook("orders.xlsx") as book:
    for query in book.queries():
        print(query.name, query.load_target, query.steps)

    book.query("Orders").formula = 'let\r\n    Source = Csv.Document(File.Contents("o.csv"))\r\nin\r\n    Source'
    book.query("Orders").description = "Every order, straight from the export."
    book.add_query("Totals", "let Source = Table.RowCount(Orders) in Source")
    book.rename_query("Orders", "Order Lines")     # rewrites the queries that name it
    book.remove_query("Scratch")
    book.save()

Groups are the folders in the Queries pane, and a query can be loaded onto a sheet or taken back off it:

with PowerQueryWorkbook.create_new("report.xlsx") as book:
    staging = book.add_group("Staging")
    book.add_query("Raw", "let Source = 1 in Source", group=staging)
    book.add_query("Report", "let Source = Raw in Source")
    book.load_to_sheet("Report", ["Value"], cell="A1")
    book.save()

load_to_sheet writes the connection, the query table, the table and the sheet's reference to it, because the metadata alone does not make Excel load anything. The column names are yours to give: knowing them means running the query, and Excel settles them against the real result on its first refresh. unload() takes every piece back out.

A loaded query also carries the settings behind Excel's Connection Properties dialog:

settings = book.query("Report").refresh
settings.on_open = True             # refresh when the workbook opens
settings.interval_minutes = 60      # and every hour after that
settings.background = False         # in the foreground, so a macro can wait
settings.keep_data = False          # save the query, not its rows
settings.in_refresh_all = False     # leave it out of Refresh All
settings.enabled = True             # or False to stop it refreshing at all

"Enable Fast Data Load" is missing from that list on purpose. Excel's object model does not expose it, so where Excel writes it could not be measured, and nothing here is written on a guess.

Queries go to disk and back like modules do:

python -m pyopenvba pq-ls   workbook.xlsx        # name, where it loads, its group
python -m pyopenvba pq-pull workbook.xlsx ./queries
python -m pyopenvba pq-push ./queries workbook.xlsx

Each query becomes one .m file, beside a queries.json manifest that carries what a file name cannot: the real name, the description and the group. A query called Sales/EU survives the round trip.

Three examples ship with the library, and every query in each was refreshed in Excel before it was committed:

  • power_query_demo.py builds twelve queries in four groups, six of them loaded onto a sheet, reading JSON from four public APIs (PokeAPI, USGS, Frankfurter, Hacker News) in five different shapes.
  • power_query_steps.py builds a sales pipeline whose main query has eleven applied steps. It keeps the steps in a list and writes #PREV where a step means the one before it, so inserting or removing a step is a list operation and the references rewire themselves.
  • power_query_refresh.py gives three loaded queries three refresh profiles: one that refreshes on open and keeps its rows, one on an hourly timer in the foreground, and one that keeps no rows in the file at all.

What the format is, and how each rule was measured, is in docs/power_query.md.


Supported formats

Excel

Extension What it is VBA Power Query create_new
.xlsx Workbook - yes yes
.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 - no

A .xlsx file has no VBA project by design, and Power Query lives outside the project, so it is read and written in every package above.

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
  powerquery/        PowerQueryWorkbook: the DataMashup blob, the M section
                     document, the metadata beside it, and worksheet loads
  _deflate.py        classic zlib's deflate, for the bytes Office writes
  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 .xlsx/.xlsm/.xlsb/.xlam/.docm/.pptm/.accdb bytes for create_new()
  __main__.py        python -m pyopenvba {pull,push,ls,forms,disasm,access-*,pq-ls,pq-pull,pq-push}

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
$env:RUN_LIVE_POWER_QUERY = "1"; pytest tests/test_live_powerquery_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 5.2.4

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 5.2.4
File Size Uploaded
pyopenvba-5.2.4.tar.gz 651.8 kB Details

Built distribution (wheel)

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

Total release size: 1.3 MB

Release files / pyopenvba-5.2.4.tar.gz

Download URL pyopenvba-5.2.4.tar.gz
Size 651.8 kB
Tags Source
SHA-256 checksum
How to use checksums
2f44a43653a6ef637702fea964d2c51c71202d8b08b705cc4c15ed532b311b88
BLAKE2b-256 checksum
How to use checksums
66b00a8817a99581a99cb263fa614e14f6fce2a2a617fcfb19dc1acbb0d9f806
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 17, 2026.

Transparency log

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

Download URL pyopenvba-5.2.4-py3-none-any.whl
Size 621.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
02b8168959ae65ae1675c62f80df1fcd715a9cefd111762453cde0cf2349371d
BLAKE2b-256 checksum
How to use checksums
cd4e870cd39167431e5a0495be09b37ba2e4cf28c87f17e4ce54a18696956e89
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 17, 2026.

Transparency log

Release history Release notifications | RSS feed

6.0.0

2 release files

This release

5.2.4 This release

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

4.0.0

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