Kanji
・클라우드 엔지니어 / 프리랜서 ・1993년생 ・에히메현 출신 / 도쿄도 시부야구 거주 ・AWS 경력 5년 프로필 상세
목차
이 문서의 Python 스크립트는 PEP8을 따릅니다.
flake8로 문법을 확인하고, 블랙으로 자동 포맷을 합니다.
처리상의 이유로 검사를 건너뛰어야 하는 코드의 경우 ’# noqa’를 추가하세요.
최대 줄 길이( max-line-length )는 한 줄에 200자로 설정됩니다.
max-line-length
스크립트는 Python 3.13.3에서 테스트되었습니다.
macOS에서는 동작이 확인되지 않았습니다.Mac용 Office가 설치된 환경에서 작동할 수 있지만 이 스크립트는 Windows 에서 사용하기 위한 것입니다.
다음 확장은 Visual Studio Code에서 VBA를 편집하는 데 사용됩니다.
VBA - 애플리케이션용 Visual Basic
Visual Studio Code에서 VBA 파일을 열 때 VBA 확장을 자동으로 활성화하려면 .vscode/extensions.json 에 다음을 추가합니다.
.vscode/extensions.json
{ "recommendations": [ "serkonda7.vscode-vba" ] }
Shift-JIS 인코딩으로 VBA 파일을 열려면 Visual Studio Code ‘settings.json’ 파일에 다음 설정을 추가하세요. - Python 및 JSON 파일은 UTF-8 인코딩으로 열리도록 설정되어 있습니다.
{ "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)
Python 스크립트에서는 다음 모듈이 사용됩니다.
표준 라이브러리:
argparse : 명령줄 인수 구문 분석
argparse
difflib : 차이점 표시
difflib
json : 구성 파일 읽기 및 쓰기
json
os , shutil , sys , zipfile : 파일 작업
os
shutil
sys
zipfile
datetime : 날짜 및 시간 작업
datetime
외부 라이브러리:
openpyxl : 엑셀 파일 읽기 및 쓰기
openpyxl
xlwings : Excel VBA 조작
xlwings
oletools : VBA 코드 추출
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.
> 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 는 Excel 파일에서 VBA 코드를 추출하여 지정된 디렉터리에 저장하는 스크립트입니다.
스크립트는 다음과 같은 기능을 제공합니다.
--init 옵션을 사용하여 초기 구성 파일 config.json 을 생성합니다.
--init
--force 옵션을 사용하여 차이점이 발견되면 현재 파일을 마스터로 유지합니다.
Excel 파일에서 VBA 코드를 추출하여 지정된 디렉터리에 저장합니다.
추출된 VBA 코드를 Excel 파일에 다시 씁니다.
차이점이 발견되면 사용자에게 마스터로 유지할 버전을 선택하라는 메시지가 표시됩니다.
스크립트는 다음 단계를 순서대로 처리합니다.
CONFIG_FILE
CACHE_DIR
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()
.cache
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" } ] } }