用 Python 子进程关闭 Excel 自动化中的弹窗

火山方舟向量数据库大模型

picture.image

利用Python进行Excel自动化操作的过程中,尤其是涉及VBA时,可能遇到消息框/弹窗(MsgBox)。此时需要人为响应,否则代码卡死直至超时 1 2。根本的解决方法是VBA代码中不要出现类似弹窗,但有时我们无权修改被操作的Excel文件,例如这是我们进行自动化测试的对象。所以本文记录从代码角度解决此类问题的方法。

假想场景

使用 xlwings (或者其他自动化库)打开Excel文件 test.xlsm ,读取 Sheet1!A1 单元格内容。很简单的一个操作:


          
import xlwings as xw  
  
wb = xw.Book('test.xlsm')  
msg = wb.sheets('Sheet1').range('A1').value  
print(msg)  
wb.close()  

      

然而不幸的是,打开工作簿时进行了热情的欢迎仪式:


          
Private Sub Workbook_Open()  
    MsgBox "Welcome"  
    MsgBox "to open"  
    MsgBox "this file."  
End Sub  

      

第一个弹窗 Welcome 就卡住了Excel,Python代码相应卡死在第一行。

picture.image

基本思路

主程序中不可能直接处理或者绕过此类问题,也不能奢望有人随时蹲守点击下一步——那就开启一个子线程来护航吧。因此,解决方案是 利用子线程监听并随时关闭弹窗 ,直到主程序圆满结束。

解决这个问题,需要以下两个知识点(基础知识请课外学习):

  • Python多线程(本文采用 threading.Thread
  • Python界面自动化库(本文涉及 pywinautopywin32

pywinauto方案

pywinauto 顾名思义是Windows界面自动化库,模拟鼠标和键盘操作窗体和控件 3。不同于先获取句柄再获取属性的传统方式, pywinauto 的API更加友好和pythonic。例如,两行代码搞定窗口捕捉和点击:


          
from pywinauto.application import Application  
  
win = Application(backend="win32").connect(title='Microsoft Excel')  
win.Dialog.Button.click()  

      

本文采用自定义线程类的方式,启动线程后自动执行 run() 函数来完成上述操作。具体代码如下,注意构造函数中的两个参数:

  • title 需要捕捉的弹窗的标题,例如Excel默认弹窗的标题为 Microsoft Excel
  • interval 监听的频率,即每隔多少秒检查一次

          
# listener.py  
  
import time  
from threading import Thread, Event  
from pywinauto.application import Application  
  
  
class MsgBoxListener(Thread):  
  
    def \_\_init\_\_(self, title:str, interval:int):  
        Thread.__init__(self)  
        self._title = title   
        self._interval = interval   
        self._stop_event = Event()     
  
    def stop(self): self._stop_event.set()  
  
    @property  
    def is\_running(self): return not self._stop_event.is_set()  
  
    def run(self):  
        while self.is_running:  
            try:  
                time.sleep(self._interval)  
                self._close_msgbox()  
            except Exception as e:  
                print(e, flush=True)  
  
  
    def \_close\_msgbox(self):  
        '''Close the default Excel MsgBox with title "Microsoft Excel".'''          
        win = Application(backend="win32").connect(title=self._title)  
        win.Dialog.Button.click()  
  
  
if __name__=='\_\_main\_\_':  
    t = MsgBoxListener('Microsoft Excel', 3)  
    t.start()  
    time.sleep(10)  
    t.stop()  

      

于是,整个过程分为三步:

  • 启动子线程监听弹窗
  • 主线程中打开Excel开始自动化操作
  • 关闭子线程

          
import xlwings as xw  
from listener import MsgBoxListener  
  
# start listen thread  
listener = MsgBoxListener('Microsoft Excel', 3)  
listener.start()  
  
# main process as before  
wb = xw.Book('test.xlsm')  
msg = wb.sheets('Sheet1').range('A1').value  
print(msg)  
wb.close()  
  
# stop listener thread  
listener.stop()  

      

到此问题基本解决,本地运行效果完全达到预期。但我的真实需求是以系统服务方式在服务器上进行Excel文件自动化测试,后续发现,当以系统服务方式运行时, pywinauto 竟然捕捉不到弹窗!这或许是 pywinauto 一个潜在的问题 4

win32gui方案

那就只好转向相对底层的 win32gui ,所幸完美解决了上述问题。

win32guipywin32 库的一部分,所以实际安装命令是:


          
pip install pywin32  

      

整个方案和前文描述完全一致,只是替换 MsgBoxListener 类中关闭弹窗的方法:


          
import win32gui, win32con  
  
def \_close\_msgbox(self):  
    # find the top window by title  
    hwnd = win32gui.FindWindow(None, self._title)  
    if not hwnd: return  
  
    # find child button  
    h_btn = win32gui.FindWindowEx(hwnd, None,'Button', None)  
    if not h_btn: return  
  
    # show text  
    text = win32gui.GetWindowText(h_btn)  
    print(text)  
  
    # click button          
    win32gui.PostMessage(h_btn, win32con.WM_LBUTTONDOWN, None, None)  
    time.sleep(0.2)  
    win32gui.PostMessage(h_btn, win32con.WM_LBUTTONUP, None, None)  
    time.sleep(0.2)  

      

更一般的方案

更一般地,当同时存在默认标题和自定义标题的弹窗时,就不便于采用标题方式进行捕捉了。例如


          
MsgBox "Message with default title.", vbInformation,   
MsgBox "Message with title My App 1", vbInformation, "My App 1"  
MsgBox "Message with title My App 2", vbInformation, "My App 2"  

      

那就扩大搜索范围,依次点击所有包含确定性描述的按钮(例如 OKYesConfirm )来关闭弹窗。同理替换 MsgBoxListener 类的 \_close\_msgbox() 方法(同时构造函数中不再需要 title 参数):


          
def \_close\_msgbox(self):  
    '''Click any button ("OK", "Yes" or "Confirm") to close message box.'''  
    # get handles of all top windows  
    h_windows = []  
    win32gui.EnumWindows(lambda hWnd, param: param.append(hWnd), h_windows)   
  
    # check each window      
    for h_window in h_windows:              
        # get child button with text OK, Yes or Confirm of given window  
        h_btn = win32gui.FindWindowEx(h_window, None,'Button', None)  
        if not h_btn: continue  
  
        # check button text  
        text = win32gui.GetWindowText(h_btn)  
        if not text.lower() in ('ok', 'yes', 'confirm'): continue  
  
        # click button  
        win32gui.PostMessage(h_btn, win32con.WM_LBUTTONDOWN, None, None)  
        time.sleep(0.2)  
        win32gui.PostMessage(h_btn, win32con.WM_LBUTTONUP, None, None)  
        time.sleep(0.2)  

      

最后,实例演示结束全文,以后再也不用担心意外弹窗了。

picture.image

Handling VBA popup message boxes in Microsoft Excel

Trying to catch MsgBox text and press button in xlwings

What is pywinauto

Remote Execution Guide

作者:crazyhat,Python及科学计算爱好者

赞 助 作 者

picture.image

更多阅读

用 Python 进行 OCR 图像识别

用 XGBoost 进行时间序列预测

5 分钟快速上手 pytest 测试框架

特别推荐

picture.image

picture.image

点击下方阅读原文加入 社区会员

Footnotes

0
0
0
0
评论
未登录
看完啦,登录分享一下感受吧~
暂无评论