Kanji
・ Cloud engineer / freelance ・ Born in 1993 ・ Born in Ehime Prefecture / Lives in Shibuya-ku, Tokyo ・ AWS history 5 years Profile details
Table of Contents
The Python script described in this article follows PEP8.
# noqa
max-line-length
The script has been tested with Python runtime version 3.13.3.
Operation has not been verified on macOS. While it may work in environments with Office for Mac installed, the script is intended for use on Windows environments .
The following extension is used to edit VBA in Visual Studio Code:
By adding the following definition to .vscode/extensions.json , the VBA extension will be automatically enabled when opening VBA files in Visual Studio Code.
.vscode/extensions.json
{ "recommendations": [ "serkonda7.vscode-vba" ] }
Add the following settings to your Visual Studio Code settings.json file to open VBA files with Shift-JIS encoding. - Python and JSON files are set to open with UTF-8 encoding.
settings.json
{ "files.encoding": "shiftjis", "[python]": { "files.encoding": "utf8" }, "[json]": { "files.encoding": "utf8" } }
sync_vba.py
import argparse import difflib import json import os import shutil import sys import zipfile from datetime import datetime import openpyxl import xlwings as xw from oletools.olevba import VBA_Parser CONFIG_FILE = "config.json" CACHE_DIR = ".cache" BACKUP_KEEP_COUNT = 3 def create_config(): config = {"xlsm_file_path": "your_xlsm_file_path_here.xlsm"} with open(CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(config, f, indent=4, ensure_ascii=False) print(f"{CONFIG_FILE} has been created. Please edit xlsm_file_path.") def load_config(): if not os.path.exists(CONFIG_FILE): print( f"{CONFIG_FILE} not found. Please initialize with the --init option." ) sys.exit(1) with open(CONFIG_FILE, "r", encoding="utf-8") as f: return json.load(f) def extract_vba(xlsm_file_path, output_dir="vba", force_master=False): if not os.path.exists(xlsm_file_path): sys.exit(1) if not os.path.exists(output_dir): os.makedirs(output_dir) if not os.path.exists(CACHE_DIR): os.makedirs(CACHE_DIR) with zipfile.ZipFile(xlsm_file_path, "r") as z: vba_bin_path = None for f in z.namelist(): if f == "xl/vbaProject.bin": vba_bin_path = f break if not vba_bin_path: sys.exit(1) tmp_bin = os.path.join(CACHE_DIR, "vbaProject.bin") with z.open(vba_bin_path) as src, open(tmp_bin, "wb") as dst: dst.write(src.read()) vba_parser = VBA_Parser(tmp_bin) if not vba_parser.detect_vba_macros(): return for (filename, stream_path, vba_filename, vba_code) in vba_parser.extract_macros(): if vba_code: filtered_code = "\n".join( line for line in vba_code.splitlines() if not line.strip().startswith("Attribute VB_") ) cache_path = os.path.join(CACHE_DIR, vba_filename) with open(cache_path, "w", encoding="shift-jis") as f: f.write(filtered_code) vba_path = os.path.join(output_dir, vba_filename) if os.path.exists(vba_path): try: with open(vba_path, "r", encoding="shift-jis") as f1: old = f1.readlines() except UnicodeDecodeError: with open(vba_path, "r", encoding="utf-8") as f1: content = f1.read() with open(vba_path, "w", encoding="shift-jis") as f1: f1.write(content) old = content.splitlines(keepends=True) with open(cache_path, "r", encoding="shift-jis") as f2: new = f2.readlines() diff = list(difflib.unified_diff(old, new, fromfile=vba_path, tofile=cache_path)) if diff: print(f"\nDiff for {vba_filename}:") print("") print("".join(diff)) print("") print("* + : New content extracted from xlsm file, - : Content in current file)") if force_master: print(f"--force option: {vba_filename} will keep the current file as master.") continue while True: sel = input(f"Difference found in {vba_filename}. Which do you want to keep as master? [k] Keep current file/[n] Overwrite with new extracted content > ").strip().lower() if sel == "k": break elif sel == "n": with open(vba_path, "w", encoding="shift-jis") as f: f.write(filtered_code) break else: with open(vba_path, "w", encoding="shift-jis") as f: f.write(filtered_code) def write_vba_to_xlsm(xlsm_file_path, vba_dir="vba"): abs_path = os.path.abspath(xlsm_file_path) if not os.path.exists(abs_path): print("The specified xlsm file does not exist. Please check the path.") sys.exit(1) app = xw.App(visible=False) try: wb = xw.Book(abs_path) vba_modules = {mod.Name: mod for mod in wb.api.VBProject.VBComponents} for fname in os.listdir(vba_dir): if fname.startswith("."): continue fpath = os.path.join(vba_dir, fname) if not os.path.isfile(fpath): continue mod_name, ext = os.path.splitext(fname) import re if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", mod_name): print(f"Invalid module name: {mod_name} (Must start with a letter or _, and only contain alphanumeric and _)") sys.exit(1) if mod_name in vba_modules: with open(fpath, "r", encoding="shift-jis") as f: code = f.read() code_module = vba_modules[mod_name].CodeModule code_module.DeleteLines(1, code_module.CountOfLines) code_module.AddFromString(code) else: if ext.lower() == ".bas": kind = 1 elif ext.lower() == ".cls": kind = 2 elif ext.lower() == ".frm": kind = 3 else: continue with open(fpath, "r", encoding="shift-jis") as f: code = f.read() mod = wb.api.VBProject.VBComponents.Add(kind) mod.Name = mod_name mod.CodeModule.AddFromString(code) wb.save() wb.close() except Exception as e: print("Error opening workbook:", e) raise finally: app.quit() def backup_xlsm(xlsm_file_path): if not os.path.exists(CACHE_DIR): os.makedirs(CACHE_DIR) base = os.path.basename(xlsm_file_path) dt = datetime.now().strftime("%Y%m%d_%H%M%S") backup_name = f"{base}.{dt}.bak" backup_path = os.path.join(CACHE_DIR, backup_name) shutil.copy2(xlsm_file_path, backup_path) backups = sorted( [f for f in os.listdir(CACHE_DIR) if f.startswith(base) and f.endswith(".bak")], reverse=True ) for old in backups[BACKUP_KEEP_COUNT:]: try: os.remove(os.path.join(CACHE_DIR, old)) except Exception: pass def main(force_master=False): config = load_config() xlsm_file_path = config.get("xlsm_file_path") if not xlsm_file_path: print("xlsm_file_path is not set in config.json.") sys.exit(1) backup_xlsm(xlsm_file_path) extract_vba(xlsm_file_path, force_master=force_master) write_vba_to_xlsm(xlsm_file_path) print("Completed successfully.") if __name__ == "__main__": parser = argparse.ArgumentParser( description="VBA module sync tool" ) parser.add_argument("--init", action="store_true", help="Initialize config.json.") parser.add_argument("--force", action="store_true", help="Keep existing vba files as master even if there are differences.") args = parser.parse_args() if args.init: create_config() sys.exit(0) main(force_master=args.force)
argparse
difflib
json
os
shutil
sys
zipfile
datetime
openpyxl
xlwings
oletools
pip install openpyxl xlwings oletools
config.json
python sync_vba.py --init
xlsm_file_path
{ "xlsm_file_path": "your_xlsm_file_path_here.xlsm" }
python sync_vba.py
> python .\sync_vba.py Completed successfully.
n
> python .\sync_vba.py Diff for Sheet1.cls: --- vba\Sheet1.cls +++ .cache\Sheet1.cls @@ -6,6 +6,7 @@ ActiveWindow.ScrollRow = 1 ActiveWindow.ScrollColumn = 1 Next ws + Debug.Print ("TEST") ThisWorkbook.Worksheets(1).Activate ThisWorkbook.Worksheets(1).Range("A1").Select End Sub * + : New content extracted from xlsm file, - : Content in current file) Difference found in Sheet1.cls. Which do you want to keep as master? [k] Keep current file/[n] Overwrite with new extracted content >
--force
vba
python sync_vba.py --force
# Files created as described in Coding Rules and Prerequisites .vscode ├── extensions.json ├── settings.json # Directory where the extracted VBA code is saved vba ├── Sheet1.cls ├── ThisWorkbook.cls ├── Module1.bas # Directory for temporary files (stores VBA code extracted from the Excel file and backup files) .cache ├── Sheet1.cls ├── ThisWorkbook.cls ├── Module1.bas ├── your_xlsm_file_path_here.xlsm.${datetime}.bak # Configuration file generated with the --init option config.json # Main Python script for extracting/writing VBA code sync_vba.py
sync_vba.py is a script that extracts VBA code from an Excel file and saves it to a specified directory.
The script provides the following features:
--init
The script processes the following steps in order:
CONFIG_FILE
CACHE_DIR
.cache
BACKUP_KEEP_COUNT
def create_config(): config = {"xlsm_file_path": "your_xlsm_file_path_here.xlsm"} with open(CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(config, f, indent=4, ensure_ascii=False) print(f"{CONFIG_FILE} has been created. Please edit xlsm_file_path.")
def load_config(): if not os.path.exists(CONFIG_FILE): print( f"{CONFIG_FILE} not found. Please initialize with the --init option." ) sys.exit(1) with open(CONFIG_FILE, "r", encoding="utf-8") as f: return json.load(f)
output_dir
force_master
False
def extract_vba(xlsm_file_path, output_dir="vba", force_master=False): if not os.path.exists(xlsm_file_path): sys.exit(1) if not os.path.exists(output_dir): os.makedirs(output_dir) if not os.path.exists(CACHE_DIR): os.makedirs(CACHE_DIR) with zipfile.ZipFile(xlsm_file_path, "r") as z: vba_bin_path = None for f in z.namelist(): if f == "xl/vbaProject.bin": vba_bin_path = f break if not vba_bin_path: sys.exit(1) tmp_bin = os.path.join(CACHE_DIR, "vbaProject.bin") with z.open(vba_bin_path) as src, open(tmp_bin, "wb") as dst: dst.write(src.read()) vba_parser = VBA_Parser(tmp_bin) if not vba_parser.detect_vba_macros(): return for (filename, stream_path, vba_filename, vba_code) in vba_parser.extract_macros(): if vba_code: filtered_code = "\n".join( line for line in vba_code.splitlines() if not line.strip().startswith("Attribute VB_") ) cache_path = os.path.join(CACHE_DIR, vba_filename) with open(cache_path, "w", encoding="shift-jis") as f: f.write(filtered_code) vba_path = os.path.join(output_dir, vba_filename) if os.path.exists(vba_path): try: with open(vba_path, "r", encoding="shift-jis") as f1: old = f1.readlines() except UnicodeDecodeError: with open(vba_path, "r", encoding="utf-8") as f1: content = f1.read() with open(vba_path, "w", encoding="shift-jis") as f1: f1.write(content) old = content.splitlines(keepends=True) with open(cache_path, "r", encoding="shift-jis") as f2: new = f2.readlines() diff = list(difflib.unified_diff(old, new, fromfile=vba_path, tofile=cache_path)) if diff: print(f"\nDiff for {vba_filename}:") print("") print("".join(diff)) print("") print("* + : New content extracted from xlsm file, - : Content in current file)") if force_master: print(f"--force option: {vba_filename} will keep the current file as master.") continue while True: sel = input(f"Difference found in {vba_filename}. Which do you want to keep as master? [k] Keep current file/[n] Overwrite with new extracted content > ").strip().lower() if sel == "k": break elif sel == "n": with open(vba_path, "w", encoding="shift-jis") as f: f.write(filtered_code) break else: with open(vba_path, "w", encoding="shift-jis") as f: f.write(filtered_code)
vba_dir
def write_vba_to_xlsm(xlsm_file_path, vba_dir="vba"): abs_path = os.path.abspath(xlsm_file_path) if not os.path.exists(abs_path): print("The specified xlsm file does not exist. Please check the path.") sys.exit(1) app = xw.App(visible=False) try: wb = xw.Book(abs_path) vba_modules = {mod.Name: mod for mod in wb.api.VBProject.VBComponents} for fname in os.listdir(vba_dir): if fname.startswith("."): continue fpath = os.path.join(vba_dir, fname) if not os.path.isfile(fpath): continue mod_name, ext = os.path.splitext(fname) import re if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", mod_name): print(f"Invalid module name: {mod_name} (Must start with a letter or _, and only contain alphanumeric and _)") sys.exit(1) if mod_name in vba_modules: with open(fpath, "r", encoding="shift-jis") as f: code = f.read() code_module = vba_modules[mod_name].CodeModule code_module.DeleteLines(1, code_module.CountOfLines) code_module.AddFromString(code) else: if ext.lower() == ".bas": kind = 1 elif ext.lower() == ".cls": kind = 2 elif ext.lower() == ".frm": kind = 3 else: continue with open(fpath, "r", encoding="shift-jis") as f: code = f.read() mod = wb.api.VBProject.VBComponents.Add(kind) mod.Name = mod_name mod.CodeModule.AddFromString(code) wb.save() wb.close() except Exception as e: print("Error opening workbook:", e) raise finally: app.quit()
def backup_xlsm(xlsm_file_path): if not os.path.exists(CACHE_DIR): os.makedirs(CACHE_DIR) base = os.path.basename(xlsm_file_path) dt = datetime.now().strftime("%Y%m%d_%H%M%S") backup_name = f"{base}.{dt}.bak" backup_path = os.path.join(CACHE_DIR, backup_name) shutil.copy2(xlsm_file_path, backup_path) backups = sorted( [f for f in os.listdir(CACHE_DIR) if f.startswith(base) and f.endswith(".bak")], reverse=True ) for old in backups[BACKUP_KEEP_COUNT:]: try: os.remove(os.path.join(CACHE_DIR, old)) except Exception: pass
def main(force_master=False): config = load_config() xlsm_file_path = config.get("xlsm_file_path") if not xlsm_file_path: print("xlsm_file_path is not set in config.json.") sys.exit(1) backup_xlsm(xlsm_file_path) extract_vba(xlsm_file_path, force_master=force_master) write_vba_to_xlsm(xlsm_file_path) print("Completed successfully.")
if __name__ == "__main__":
main()
if __name__ == "__main__": parser = argparse.ArgumentParser( description="VBA module sync tool" ) parser.add_argument("--init", action="store_true", help="Initialize config.json.") parser.add_argument("--force", action="store_true", help="Keep existing vba files as master even if there are differences.") args = parser.parse_args() if args.init: create_config() sys.exit(0) main(force_master=args.force)
{ "recommendations": [ "emeraldwalk.RunOnSave" ] }
.vscode/settings.json
{ "emeraldwalk.runonsave": { "commands": [ { "match": ".*\\.(cls|bas)$", "isAsync": false, "cmd": "python .\\sync_vba.py --force" } ] } }