Kanji
・Ingeniero Cloud / Freelance ・Nacido en 1993 ・De Ehime / Residente en Shibuya, Tokio ・5 años de experiencia en AWS Detalles del perfil
Índice
The Python script in this article follows PEP8.
Syntax is checked with flake8, and automatic formatting is done with black.
For code that needs to skip checks for processing reasons, add # noqa .
# noqa
The maximum line length ( max-line-length ) is set to 200 characters per line.
max-line-length
The script has been tested with Python 3.13.3.
Operation has not been verified on macOS. While it may work in environments with Office for Mac installed, this script is intended for use on Windows .
The following extension is used to edit VBA in Visual Studio Code:
VBA - Visual Basic for Applications
Add the following to .vscode/extensions.json to automatically enable the VBA extension 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)
The following modules are used in the Python script:
Standard libraries:
argparse : Command-line argument parsing
argparse
difflib : Displaying differences
difflib
json : Reading and writing configuration files
json
os , shutil , sys , zipfile : File operations
os
shutil
sys
zipfile
datetime : Date and time operations
datetime
External libraries:
openpyxl : Reading and writing Excel files
openpyxl
xlwings : Manipulating Excel VBA
xlwings
oletools : Extracting VBA code
oletools
To install the required modules, run the following command:
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:
Generates the initial configuration file config.json with the --init option
--init
Keeps the current files as master when differences are found by using the --force option
Extracts VBA code from the Excel file and saves it to the specified directory
Writes the extracted VBA code back to the Excel file
When differences are found, prompts the user to choose which version to keep as the master
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" } ] } }