Kanji
・クラウドエンジニア / フリーランス ・1993年生まれ ・愛媛県出身 / 東京都渋谷区在住 ・AWS歴5年 プロフィールの詳細
目次
本記事に記載の Python スクリプトでは、PEP8 に準拠しています。
# noqa
max-line-length
Python のランタイムバージョンは 3.13.3 を利用して動作確認を行っています。
MacOS での動作確認を行っておりません。Mac 版の Office を導入している環境でも動作する可能性はありますが、 Windows 環境での動作を前提にしています。
VBA を Visual Studio Code で編集するための拡張機能は、以下のものを利用しています。
以下の定義を .vscode\extensions.json に追記することで、Visual Studio Code で VBA ファイルを開いた際に、VBA の拡張機能を自動的に有効化できます。
.vscode\extensions.json
{ "recommendations": [ "serkonda7.vscode-vba" ] }
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
python sync_vba.py --force
vba
# コーディングルール・前提条件で作成したファイル .vscode ├── extensions.json ├── settings.json # 抽出された VBA コードを保存するディレクトリ vba ├── Sheet1.cls ├── ThisWorkbook.cls ├── Module1.bas # 一時ファイルを保存するディレクトリ(エクセルファイルから抽出した VBA コードや、バックアップファイルを保存) .cache ├── Sheet1.cls ├── ThisWorkbook.cls ├── Module1.bas ├── your_xlsm_file_path_here.xlsm.${datetime}.bak # --init オプションで生成される設定ファイル config.json # VBA コードを抽出/書き込みする Python スクリプト本体 sync_vba.py
sync_vba.py は、Excel ファイルから VBA コードを抽出し、指定されたディレクトリに保存するスクリプトです。
スクリプトは以下の機能を持っています。
--init
スクリプトは、以下の順番で処理を行います。
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)
.vscode/extensions.json
{ "recommendations": [ "emeraldwalk.RunOnSave" ] }
.vscode/settings.json
{ "emeraldwalk.runonsave": { "commands": [ { "match": ".*\\.(cls|bas)$", "isAsync": false, "cmd": "python .\\sync_vba.py --force" } ] } }