Skip to content

Commit 6aba921

Browse files
committed
new feature: introduce win32com
2 parents ad831e7 + 8691ec3 commit 6aba921

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1441
-845
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ dist/
55

66
# test
77
test/
8+
sample/
9+
*.xlam
810

911
# temp file
1012
*.pyc

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2022 dothinking
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README_CN.md

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
中文 | [English](./README_CN.md)
2+
3+
# PyAddin
4+
5+
[![pypi-version](https://img.shields.io/pypi/v/pyaddin.svg)](https://pypi.python.org/pypi/pyaddin/)
6+
![license](https://img.shields.io/pypi/l/pyaddin.svg)
7+
8+
VBA日渐式微,但 Excel 依旧是表格数据存储、处理和传阅的通用工具。对于一些复杂的业务逻辑,一个带宏的 Excel 文件(xlsm)通常包含数据本身和处理数据的VBA脚本。当需要频繁复用这些脚本时,例如按相同的业务逻辑处理月度数据,建议将其拆分为两部分: Excel 纯数据文件(xlsx),以及一个负责数据处理的 Excel 插件(xlam)。
9+
10+
`PyAddin`是一个辅助创建上述 Excel 插件的命令行工具,同时支持使用 Python 语言来实现原本 VBA 负责的数据处理流程。两个基本功能如下:
11+
12+
- 创建一个模板插件,支持自定义菜单功能区(Ribbon)和近“无缝”调用 Python 脚本。
13+
- 在开发插件过程中,方便根据自定义的`CustomUI.xml`更新插件菜单功能区。
14+
15+
集成 VBA 和 Python 的主要思路:VBA 通过后台运行控制台程序调用 Python 脚本,Python 脚本执行计算并以写临时文件的方式保存返回值,最后 VBA 读取返回值。此外,借助 Python 第三方库`pywin32`,可以直接在 Python 脚本中进行与 Excel 的交互,例如获取/设置单元格内容,设置单元格样式等等。
16+
17+
18+
## 限制
19+
20+
- 仅支持 Windows 平台
21+
- 要求 Microsoft Excel 2007 及以上
22+
- VBA 和 Python 之间传参仅限于 **字符串** 格式简单数据类型
23+
- 与 Excel 的交互能力取决于 `pywin32/win32com`
24+
25+
26+
## 安装
27+
28+
支持 `Pypi` 或者本地安装:
29+
30+
```
31+
# pypi
32+
pip install pyaddin
33+
34+
# local
35+
python setup.py install
36+
37+
# local in development mode
38+
python setup.py develop
39+
```
40+
41+
使用 `pip` 卸载:
42+
43+
```
44+
pip uninstall pyaddin
45+
```
46+
47+
## 命令说明
48+
49+
- 创建模板插件
50+
51+
```
52+
pyaddin init --name=xxx --quiet=True|False
53+
```
54+
55+
- 更新插件功能区
56+
57+
```
58+
pyaddin update --name=xxx --quiet=True|False
59+
```
60+
61+
其中,`quiet`是可选参数,表明是否以后台模式创建插件(不显式打开 Excel)。默认值`True`,即后台模式。
62+
63+
## 使用帮助
64+
65+
### 1. 初始化模板插件
66+
67+
```
68+
D:\WorkSpace>pyaddin init --name=sample
69+
```
70+
71+
在当前目录下新建了文件夹`sample`,其中包含模板插件`sample.xlam`,以及实现VBA 与 Python 互联所需的支持文件。目录结构如下:
72+
73+
```
74+
sample\
75+
|- scripts\
76+
| |- utils\
77+
| | |- __init__.py
78+
| | |- context.py
79+
| |- __init__.py
80+
| |- sample.py
81+
|- main.cfg
82+
|- main.py
83+
|- CustomUI.xml
84+
|- sample.xlam
85+
```
86+
87+
其中,
88+
89+
- `main.py`是 VBA 调用 Python 脚本的入口文件。
90+
- `main.cfg`是基本配置参数文件,例如指定 Python 解释器的路径。
91+
- `scripts`存放处理具体业务的 Python 脚本,例如`sample.py`是其中的一个示意模块,开发者根据需要在此目录下创建其他模块。
92+
- `CustomUI.xml`定义了插件的 Ribbon 界面,例如包含的控件及样式。
93+
- `sample.xlam`为模板插件,开发者可以在此基础上添加和扩展自定义的功能。
94+
95+
当前模板的 Ribbon 区域参考下图。
96+
97+
![add-in.png](add-in.png)
98+
99+
100+
### 2. 自定义 Ribbon 区域
101+
102+
在上一步创建的 [`CustomUI.xml`](./pyaddin/resources/CustomUI.xml) 的基础上,根据具体需求设计界面和样式,然后运行以下命令将其更新到插件中去。当然, Excel 2007及以上的文件本质上是一个压缩文件,因此可以手动解压后替换相应的`CustomUI.xml`,或者直接借助其他的 Ribbon XML 编辑器进行修改。
103+
104+
105+
```
106+
D:\WorkSpace\sample>pyaddin update --name=sample
107+
```
108+
109+
110+
以此模板为例,定义了两个分组:
111+
112+
- `setting`组提供基础功能,请直接保留在你的项目中。例如,运行前需要设置 Python 解释器路径。
113+
- `Your Group 1`是一个示例分组,其中设计了两个按钮。实际开发中请替换为需要的控件,或者增加其他更多分组。
114+
115+
关于 Ribbon 界面的具体介绍及格式规范,参考以下链接。
116+
117+
- [General Format of XML Markup Files](https://docs.microsoft.com/en-us/previous-versions/office/developer/office-2007/aa338202(v%3doffice.12)#general-format-of-xml-markup-files)
118+
- [Custom UI](https://docs.microsoft.com/en-us/openspecs/office_standards/ms-customui/edc80b05-9169-4ff7-95ee-03af067f35b1)
119+
120+
121+
### 3. 实现 Ribbon 控件响应函数
122+
123+
这一步需要在 VBA 中实现 `CustomUI.xml`定义的控件事件及其响应函数。
124+
125+
以模板插件为例,下面的 XML 片段表明按钮`Sample 1`的点击事件由 VBA 过程`CB_Sample_1`响应。
126+
127+
```xml
128+
<button id="sample1" label="Sample 1"
129+
imageMso="AppointmentColor3" size="large"
130+
onAction="CB_Sample_1"
131+
...>
132+
```
133+
134+
进一步,查看插件的`UserRibbon`模块中定义的两个响应过程:
135+
136+
137+
```vb
138+
Sub CB_Sample_1(control As IRibbonControl)
139+
'''onAction for control: Sample 1'''
140+
Dim res As Object
141+
Dim x As Integer: x = Range("A1").Value
142+
Dim y As Integer: y = Range("A2").Value
143+
144+
Set res = RunPython("scripts.sample.run_example_1", x, y)
145+
Range("A3") = res("value")
146+
End Sub
147+
148+
Sub CB_Sample_2(control As IRibbonControl)
149+
'''onAction for control: Sample 2'''
150+
RunPython "scripts.sample.run_example_2"
151+
End Sub
152+
```
153+
154+
可以看到,二者都是通过`RunPython()`函数来调用 Python 脚本并获取返回值,函数签名如下:
155+
156+
```vb
157+
Function RunPython(methodName As String, ParamArray args()) As Object
158+
```
159+
160+
- 第一个参数`methodName`表示调用的 Python 方法,具体格式为`package.module.method`。本例 "scripts.sample.run_example_1" 表示调用脚本`sample/scripts/sample.py`中的`run_example_1`方法。
161+
162+
- 第二个参数`args`是可变长参数,可以传入任意个数的参数给相应 Python 方法。注意,仅支持字符串、数字等简单数据类型,并且经过控制台参数传递,最终到 Python 端都被转成了字符串格式。
163+
164+
- 返回值为 VBA 的`Dictionary`格式,其中包含两个键:
165+
- `status`: 调用成功与否,True 或者 False
166+
- `value`: 返回值(错误信息如果`status`为 False)
167+
168+
169+
### 4. 实现 Python 脚本
170+
171+
根据 VBA 端`RunPython()`调用路径和参数列表,创建相应的 Python 脚本文件及函数。
172+
173+
以模板插件为例,在项目目录下查看`scripts\sample.py`文件:
174+
175+
```python
176+
# sample.py
177+
from .utils import context
178+
179+
def run_example_1(x:str, y:str):
180+
return int(x) + int(y)
181+
182+
def run_example_2():
183+
# get the workbook calling this method, then do anything with win32com
184+
wb = context.get_caller()
185+
sheet = wb.ActiveSheet
186+
187+
# get cells value
188+
x = sheet.Range('A1').Value
189+
y = sheet.Range('A2').Value
190+
191+
# set cell value
192+
sheet.Range('A3').Value = x + y
193+
```
194+
195+
根据上下文可知,上述两个方法实现相同的事情,将单元格 A1、A2 的和写到单元格 A3,进一步可以分为三步:取值,求和,设置值。但实现的思路略有不同:
196+
197+
- `run_example_1`的思路是仅将 Excel 无关的流程(求和)交由 Python 实现,所有 Excel 相关的操作(取值、设置值)仍通过 VBA 执行,二者的桥梁是参数传递。
198+
199+
- `run_example_2`的思路是在 Python 端获取到调用此脚本的工作簿,然后直接对其做所有需要的操作(取值,求和,设置值)。
200+
201+
对比可以发现,前者按各自强项分工,运行效率较高,但 VBA 和 Python 之间存在强耦合,且容易受限于参数传递的复杂度;后者几乎完全解耦 VBA 和 Python,且避免了参数传递,但操作 Excel 的能力受限于`pywin32`,某些操作可能无法实现。
202+
203+
204+
### 5. 交付插件
205+
206+
因为依赖关系,整个工程应作为整体(第一步中所示结构)交付,且需要最终用户配置好 Python 环境及相应第三方库(主要是`pywin32`)。有时候 Python 环境对用户来说是一个挑战,因此可以考虑可移植的便携式 Python,事先安装好依赖库后随插件工程一起发布,如此便于最终用户开箱即用。
207+
208+
209+
## 许可
210+
211+
MIT License

add-in.png

7.21 KB
Loading

pyaddin/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +0,0 @@
1-
from . import src

pyaddin/addin.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import os
2+
import logging
3+
import shutil
4+
import win32com.client
5+
from .xlam.ui import UI
6+
from .xlam.vba import VBA
7+
from .share import AddInException
8+
9+
10+
# logging
11+
logging.basicConfig(
12+
level=logging.INFO,
13+
format="[%(levelname)s] %(message)s")
14+
15+
16+
# configuration path
17+
SCRIPT_PATH = os.path.dirname(os.path.abspath(__file__))
18+
RESOURCE_PATH = os.path.join(SCRIPT_PATH, 'resources')
19+
20+
RESOURCE_ADDIN = 'xlam'
21+
RESOURCE_PYTHON = 'scripts'
22+
RESOURCE_VBA = 'vba'
23+
PYTHON_MAIN = 'main.py'
24+
PYTHON_CONFIG = 'main.cfg'
25+
VBA_GENERAL = 'General'
26+
VBA_MENU = 'Ribbon'
27+
VBA_USER_MENU = 'UserRibbon'
28+
CUSTOM_UI = 'CustomUI.xml'
29+
30+
31+
class Addin:
32+
33+
def __init__(self, xlam_file:str, visible:bool=False) -> None:
34+
'''The Excel add-in object, including ribbon UI and VBA modules.
35+
36+
Args:
37+
xlam_file (str): Add-in file path.
38+
visible (bool): Process the add-in with Excel application running in the background if False.
39+
'''
40+
# work path
41+
self.xlam_file = xlam_file
42+
self.path = os.path.dirname(xlam_file)
43+
44+
# Add-in VBA modules
45+
self.excel_app = win32com.client.Dispatch('Excel.Application') # win32 COM object
46+
self.excel_app.Visible = visible
47+
self.excel_app.DisplayAlerts = False
48+
49+
50+
def close(self):
51+
'''Close add-in and exit Excel.'''
52+
self.excel_app.Application.Quit()
53+
54+
55+
def create(self, vba_only:bool=False):
56+
'''Create addin file.
57+
- customize ribbon tab and associated VBA callback according to ui file
58+
- include VBA modules, e.g., general VBA subroutines for data transferring.
59+
60+
Args:
61+
vba_only (bool, optional): Whether simple VBA addin (without Python related modules).
62+
Defaults to False.
63+
'''
64+
N = 2 if vba_only else 3
65+
66+
# 1 create addin file
67+
logging.info('(1/%d) Creating add-in structure...', N)
68+
ui = UI(self.xlam_file)
69+
template = os.path.join(RESOURCE_PATH, RESOURCE_ADDIN)
70+
custom_ui = os.path.join(self.path, CUSTOM_UI)
71+
ui.create(template, custom_ui)
72+
73+
if not os.path.exists(self.xlam_file):
74+
raise AddInException('Create add-in structures failed.')
75+
76+
# 2 update VBA modules
77+
vba = VBA(xlam_file=self.xlam_file, excel_app=self.excel_app)
78+
79+
# 2.1 import ribbon module
80+
logging.info('(2/%d) Creating menu callback subroutines...', N)
81+
base_menu = os.path.join(RESOURCE_PATH, RESOURCE_VBA, f'{VBA_MENU}.bas')
82+
user_menu = os.path.join(RESOURCE_PATH, RESOURCE_VBA, f'{VBA_USER_MENU}.bas')
83+
vba.import_module(base_menu)
84+
vba.import_module(user_menu)
85+
86+
# extra steps for VBA-Python combined addin
87+
if not vba_only:
88+
logging.info('(3/%d) Creating Python-VBA interaction modules...', N)
89+
90+
# 2. import general module
91+
general_module = os.path.join(RESOURCE_PATH, RESOURCE_VBA, f'{VBA_GENERAL}.bas')
92+
vba.import_module(general_module)
93+
94+
# 3. copy main python scripts
95+
if RESOURCE_PATH.upper()!=self.path.upper():
96+
python_scripts = os.path.join(RESOURCE_PATH, RESOURCE_PYTHON)
97+
target_scripts = os.path.join(self.path, RESOURCE_PYTHON)
98+
shutil.copytree(python_scripts, target_scripts)
99+
100+
python_main = os.path.join(RESOURCE_PATH, PYTHON_MAIN)
101+
python_config = os.path.join(RESOURCE_PATH, PYTHON_CONFIG)
102+
shutil.copy(python_main, self.path)
103+
shutil.copy(python_config, self.path)
104+
105+
# save vba modules
106+
vba.save()
107+
108+
109+
def update(self):
110+
'''Update Ribbon UI. Note that the menu callback functions should be updated manually.'''
111+
# update addin with customized ui file
112+
logging.info('(1/1) Updating ribbon structures...')
113+
custom_ui = os.path.join(self.path, CUSTOM_UI)
114+
ui = UI(self.xlam_file)
115+
ui.update(custom_ui)

0 commit comments

Comments
 (0)