pyOpenVBA 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyopenvba/__init__.py +80 -0
- pyopenvba/__main__.py +68 -0
- pyopenvba/cfb.py +931 -0
- pyopenvba/excel.py +487 -0
- pyopenvba/exceptions.py +17 -0
- pyopenvba/vba.py +1543 -0
- pyopenvba-1.0.0.dist-info/METADATA +285 -0
- pyopenvba-1.0.0.dist-info/RECORD +11 -0
- pyopenvba-1.0.0.dist-info/WHEEL +4 -0
- pyopenvba-1.0.0.dist-info/entry_points.txt +2 -0
- pyopenvba-1.0.0.dist-info/licenses/LICENSE.md +21 -0
pyopenvba/excel.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Excel file handler.
|
|
3
|
+
|
|
4
|
+
Supports:
|
|
5
|
+
- .xlsm (OOXML macro-enabled workbook — ZIP containing xl/vbaProject.bin)
|
|
6
|
+
- .xlsb (Binary workbook — ZIP containing xl/vbaProject.bin)
|
|
7
|
+
- .xls (Legacy BIFF8 — the entire file is a CFB)
|
|
8
|
+
|
|
9
|
+
Usage
|
|
10
|
+
-----
|
|
11
|
+
with ExcelFile("book.xlsm") as wb:
|
|
12
|
+
project = wb.vba_project() # -> VBAProject
|
|
13
|
+
modules = wb.vba_modules() # -> dict[str, str]
|
|
14
|
+
wb.set_module("Module1", src)
|
|
15
|
+
wb.save("book_out.xlsm")
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import io
|
|
21
|
+
import warnings
|
|
22
|
+
import zipfile
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Union
|
|
25
|
+
|
|
26
|
+
from pyopenvba.cfb import CFB
|
|
27
|
+
from pyopenvba.exceptions import UnsupportedFormatError, VBAProjectError
|
|
28
|
+
from pyopenvba.vba import VBAProject, parse_vba_project, write_back_modules
|
|
29
|
+
from pyopenvba.vba import VBAModuleKind
|
|
30
|
+
from pyopenvba.vba import (
|
|
31
|
+
compress,
|
|
32
|
+
detect_signature,
|
|
33
|
+
invalidate_vba_project_cache,
|
|
34
|
+
rebuild_module_stream,
|
|
35
|
+
serialize_dir_stream,
|
|
36
|
+
serialize_project_stream,
|
|
37
|
+
serialize_projectwm,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_ZIP_FORMATS = frozenset({".xlsm", ".xlsb", ".xlam"})
|
|
41
|
+
_CFB_FORMATS = frozenset({".xls"})
|
|
42
|
+
_VBA_ENTRY = "xl/vbaProject.bin"
|
|
43
|
+
|
|
44
|
+
# File extensions used by the VBE export/import workflow.
|
|
45
|
+
# - Standard procedural modules -> .bas
|
|
46
|
+
# - Everything else (class, document/sheet/workbook, designer/form) -> .cls
|
|
47
|
+
# We do not write .frm/.frx layout bytes; UserForm layout is preserved
|
|
48
|
+
# inside the CFB and never round-trips through disk.
|
|
49
|
+
_BAS_EXT = ".bas"
|
|
50
|
+
_CLS_EXT = ".cls"
|
|
51
|
+
_SOURCE_EXTS = frozenset({_BAS_EXT, _CLS_EXT})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ExcelFile:
|
|
55
|
+
"""
|
|
56
|
+
Open an Excel file and provide access to its VBA project.
|
|
57
|
+
|
|
58
|
+
Can be used as a context manager::
|
|
59
|
+
|
|
60
|
+
with ExcelFile("book.xlsm") as wb:
|
|
61
|
+
...
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, path: Union[str, Path]) -> None:
|
|
65
|
+
self._path = Path(path)
|
|
66
|
+
self._suffix = self._path.suffix.lower()
|
|
67
|
+
self._zip: zipfile.ZipFile | None = None
|
|
68
|
+
self._cfb: CFB | None = None
|
|
69
|
+
self._zip_bytes: bytes | None = None
|
|
70
|
+
self._project: VBAProject | None = None
|
|
71
|
+
self._open()
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
# Context manager
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def __enter__(self) -> "ExcelFile":
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(self, *_: object) -> None:
|
|
81
|
+
self.close()
|
|
82
|
+
|
|
83
|
+
def close(self) -> None:
|
|
84
|
+
if self._zip is not None:
|
|
85
|
+
self._zip.close()
|
|
86
|
+
self._zip = None
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
# Public API
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def vba_project(self) -> VBAProject:
|
|
93
|
+
"""Parse and return the VBAProject (cached after first call)."""
|
|
94
|
+
if self._project is None:
|
|
95
|
+
cfb = self._get_cfb()
|
|
96
|
+
self._project = parse_vba_project(cfb)
|
|
97
|
+
return self._project
|
|
98
|
+
|
|
99
|
+
def vba_project_bytes(self) -> bytes:
|
|
100
|
+
"""Return the raw bytes of ``xl/vbaProject.bin`` (or the whole CFB
|
|
101
|
+
file for legacy ``.xls``)."""
|
|
102
|
+
if self._suffix in _CFB_FORMATS:
|
|
103
|
+
return self._path.read_bytes()
|
|
104
|
+
assert self._zip is not None
|
|
105
|
+
return self._zip.read(_VBA_ENTRY)
|
|
106
|
+
|
|
107
|
+
def vba_modules(self) -> dict[str, str]:
|
|
108
|
+
"""Return a mapping of module name -> source code."""
|
|
109
|
+
return {m.name: m.source for m in self.vba_project().modules}
|
|
110
|
+
|
|
111
|
+
def module_names(self) -> list[str]:
|
|
112
|
+
"""Return the list of VBA module names."""
|
|
113
|
+
return self.vba_project().module_names()
|
|
114
|
+
|
|
115
|
+
def get_module(self, name: str) -> str:
|
|
116
|
+
"""Return the source code of a named VBA module."""
|
|
117
|
+
return self.vba_project().get_module(name).source
|
|
118
|
+
|
|
119
|
+
def set_module(self, name: str, source: str) -> None:
|
|
120
|
+
"""
|
|
121
|
+
Replace the source code of an existing VBA module in memory.
|
|
122
|
+
|
|
123
|
+
Changes are not written to disk until :meth:`save` is called.
|
|
124
|
+
"""
|
|
125
|
+
project = self.vba_project()
|
|
126
|
+
for m in project.modules:
|
|
127
|
+
if m.name.casefold() == name.casefold():
|
|
128
|
+
m.source = source
|
|
129
|
+
m.dirty = True
|
|
130
|
+
return
|
|
131
|
+
raise KeyError(f"Module not found: {name!r}")
|
|
132
|
+
|
|
133
|
+
def validate(self) -> list[str]:
|
|
134
|
+
"""Return cross-structure inconsistency messages; empty list means OK."""
|
|
135
|
+
return self.vba_project().validate(self._get_cfb())
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
# Push / pull (disk-based module sync)
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def pull_modules(
|
|
142
|
+
self,
|
|
143
|
+
dest_dir: Union[str, Path],
|
|
144
|
+
*,
|
|
145
|
+
encoding: str = "utf-8",
|
|
146
|
+
overwrite: bool = True,
|
|
147
|
+
) -> list[Path]:
|
|
148
|
+
"""
|
|
149
|
+
Export every VBA module's source to a file in ``dest_dir``.
|
|
150
|
+
|
|
151
|
+
Standard procedural modules are written as ``<name>.bas``; class,
|
|
152
|
+
document, and designer modules are written as ``<name>.cls``.
|
|
153
|
+
Source bytes use CRLF line endings to match VBE's own export
|
|
154
|
+
format. UserForm layout (``.frx``) is **not** exported — it is
|
|
155
|
+
preserved verbatim inside the workbook on save.
|
|
156
|
+
|
|
157
|
+
Returns the list of file paths written.
|
|
158
|
+
"""
|
|
159
|
+
out_dir = Path(dest_dir)
|
|
160
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
|
|
162
|
+
written: list[Path] = []
|
|
163
|
+
for m in self.vba_project().modules:
|
|
164
|
+
ext = _BAS_EXT if m.kind == VBAModuleKind.standard else _CLS_EXT
|
|
165
|
+
target = out_dir / f"{m.name}{ext}"
|
|
166
|
+
if target.exists() and not overwrite:
|
|
167
|
+
raise FileExistsError(
|
|
168
|
+
f"Refusing to overwrite {target} (overwrite=False)."
|
|
169
|
+
)
|
|
170
|
+
text = m.source.replace("\r\n", "\n").replace("\r", "\n")
|
|
171
|
+
data = text.replace("\n", "\r\n").encode(encoding, errors="replace")
|
|
172
|
+
target.write_bytes(data)
|
|
173
|
+
written.append(target)
|
|
174
|
+
return written
|
|
175
|
+
|
|
176
|
+
def push_modules(
|
|
177
|
+
self,
|
|
178
|
+
src_dir: Union[str, Path],
|
|
179
|
+
*,
|
|
180
|
+
encoding: str = "utf-8",
|
|
181
|
+
strict: bool = False,
|
|
182
|
+
) -> list[str]:
|
|
183
|
+
"""
|
|
184
|
+
Update module source from files in ``src_dir``.
|
|
185
|
+
|
|
186
|
+
Each ``<name>.bas`` or ``<name>.cls`` file is matched (case-
|
|
187
|
+
insensitively) to a module of the same logical name and its
|
|
188
|
+
source replaces the module's current source. Line endings are
|
|
189
|
+
normalised to CRLF.
|
|
190
|
+
|
|
191
|
+
Files whose stem does not match any module are reported via
|
|
192
|
+
``strict``:
|
|
193
|
+
|
|
194
|
+
- ``strict=False`` (default): unmatched files are ignored.
|
|
195
|
+
- ``strict=True``: raises :class:`KeyError` on the first
|
|
196
|
+
unmatched file.
|
|
197
|
+
|
|
198
|
+
Does **not** write to disk — call :meth:`save` afterwards to
|
|
199
|
+
persist.
|
|
200
|
+
|
|
201
|
+
Returns the list of module names that were updated.
|
|
202
|
+
"""
|
|
203
|
+
src = Path(src_dir)
|
|
204
|
+
if not src.is_dir():
|
|
205
|
+
raise NotADirectoryError(f"Not a directory: {src}")
|
|
206
|
+
|
|
207
|
+
project = self.vba_project()
|
|
208
|
+
by_name = {m.name.casefold(): m for m in project.modules}
|
|
209
|
+
updated: list[str] = []
|
|
210
|
+
|
|
211
|
+
for child in sorted(src.iterdir()):
|
|
212
|
+
if not child.is_file():
|
|
213
|
+
continue
|
|
214
|
+
if child.suffix.lower() not in _SOURCE_EXTS:
|
|
215
|
+
continue
|
|
216
|
+
key = child.stem.casefold()
|
|
217
|
+
module = by_name.get(key)
|
|
218
|
+
if module is None:
|
|
219
|
+
if strict:
|
|
220
|
+
raise KeyError(
|
|
221
|
+
f"No module matches file {child.name!r} "
|
|
222
|
+
f"(known: {sorted(m.name for m in project.modules)})."
|
|
223
|
+
)
|
|
224
|
+
continue
|
|
225
|
+
raw = child.read_bytes().decode(encoding, errors="replace")
|
|
226
|
+
text = raw.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\r\n")
|
|
227
|
+
if module.source != text:
|
|
228
|
+
module.source = text
|
|
229
|
+
module.dirty = True
|
|
230
|
+
updated.append(module.name)
|
|
231
|
+
return updated
|
|
232
|
+
|
|
233
|
+
def save(
|
|
234
|
+
self,
|
|
235
|
+
dest: Union[str, Path, None] = None,
|
|
236
|
+
*,
|
|
237
|
+
allow_protected: bool = False,
|
|
238
|
+
allow_invalidate_signature: bool = False,
|
|
239
|
+
) -> None:
|
|
240
|
+
"""
|
|
241
|
+
Save the workbook, applying any pending module edits.
|
|
242
|
+
|
|
243
|
+
``dest`` defaults to the original file path (in-place overwrite).
|
|
244
|
+
|
|
245
|
+
Only ``xl/vbaProject.bin`` is rewritten; every other ZIP entry is
|
|
246
|
+
preserved byte-for-byte along with its compression method and
|
|
247
|
+
metadata so the workbook's non-VBA structure remains intact.
|
|
248
|
+
|
|
249
|
+
Legacy ``.xls`` (raw CFB) writes the CFB bytes directly.
|
|
250
|
+
|
|
251
|
+
Safety gates:
|
|
252
|
+
|
|
253
|
+
- If the project is password-protected (``has_password``) and the
|
|
254
|
+
save would emit any change, raise ``VBAProjectError`` unless
|
|
255
|
+
``allow_protected=True`` is passed. Saving a protected project
|
|
256
|
+
without re-encrypting the password material would leave the
|
|
257
|
+
workbook in an inconsistent state.
|
|
258
|
+
- If the project carries any digital-signature stream and the
|
|
259
|
+
save would emit any change, the existing signature streams are
|
|
260
|
+
dropped (they are guaranteed to be stale) and a
|
|
261
|
+
``UserWarning`` is emitted. Set
|
|
262
|
+
``allow_invalidate_signature=True`` to silence the warning.
|
|
263
|
+
- If the save would emit any change, the ``_VBA_PROJECT``
|
|
264
|
+
performance cache body is zeroed (header preserved) so Office
|
|
265
|
+
regenerates the cache on next open ([MS-OVBA] 2.3.4.1).
|
|
266
|
+
"""
|
|
267
|
+
cfb = self._get_cfb()
|
|
268
|
+
if self._project is not None:
|
|
269
|
+
project = self._project
|
|
270
|
+
# Snapshot pending mutations. Logical name == stream name in
|
|
271
|
+
# Excel-saved files, so these dicts drive both the CFB-level
|
|
272
|
+
# operations and the PROJECT-stream rewrite.
|
|
273
|
+
rename_map = dict(project.pending_renames)
|
|
274
|
+
add_names = set(project.pending_adds)
|
|
275
|
+
delete_names = set(project.pending_deletes)
|
|
276
|
+
has_source_edits = any(m.dirty for m in project.modules)
|
|
277
|
+
mutating = bool(
|
|
278
|
+
rename_map or add_names or delete_names or has_source_edits
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Safety gate 1: refuse to mutate a password-protected project
|
|
282
|
+
# unless the caller explicitly opts in.
|
|
283
|
+
if (
|
|
284
|
+
mutating
|
|
285
|
+
and project.protection is not None
|
|
286
|
+
and project.protection.has_password
|
|
287
|
+
and not allow_protected
|
|
288
|
+
):
|
|
289
|
+
raise VBAProjectError(
|
|
290
|
+
"Refusing to save: the VBA project is password-protected. "
|
|
291
|
+
"Pass allow_protected=True to override (the password "
|
|
292
|
+
"material will be preserved verbatim, which may leave "
|
|
293
|
+
"the workbook inconsistent)."
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Safety gate 2: any change invalidates a present digital
|
|
297
|
+
# signature. Drop the stale signature streams and warn.
|
|
298
|
+
if mutating:
|
|
299
|
+
sig_info = detect_signature(cfb)
|
|
300
|
+
if sig_info.present:
|
|
301
|
+
for sig_stream in (
|
|
302
|
+
"_VBA_PROJECT_SIGNATURE",
|
|
303
|
+
"_VBA_PROJECT_SIGNATURE_AGILE",
|
|
304
|
+
"_VBA_PROJECT_SIGNATURE_V3",
|
|
305
|
+
):
|
|
306
|
+
try:
|
|
307
|
+
cfb.remove_stream_in_storage("VBA", sig_stream)
|
|
308
|
+
except KeyError:
|
|
309
|
+
pass
|
|
310
|
+
try:
|
|
311
|
+
cfb.remove_stream(sig_stream)
|
|
312
|
+
except KeyError:
|
|
313
|
+
pass
|
|
314
|
+
if not allow_invalidate_signature:
|
|
315
|
+
warnings.warn(
|
|
316
|
+
"Dropped stale VBA digital signature streams "
|
|
317
|
+
f"({', '.join(sig_info.kinds)}) because the "
|
|
318
|
+
"project was modified. Re-sign externally to "
|
|
319
|
+
"restore trust. Pass "
|
|
320
|
+
"allow_invalidate_signature=True to silence.",
|
|
321
|
+
UserWarning,
|
|
322
|
+
stacklevel=2,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# 1. Apply renames first so that pre-existing streams are at
|
|
326
|
+
# their new names before any other lookup runs.
|
|
327
|
+
for old, new in rename_map.items():
|
|
328
|
+
try:
|
|
329
|
+
cfb.rename_stream_in_storage("VBA", old, new)
|
|
330
|
+
except KeyError:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
# 2. Create brand-new streams for pending adds.
|
|
334
|
+
add_modules_for_project: list[tuple[str, str]] = []
|
|
335
|
+
for name in add_names:
|
|
336
|
+
module = next(
|
|
337
|
+
(m for m in project.modules if m.stream_name == name), None
|
|
338
|
+
)
|
|
339
|
+
if module is None:
|
|
340
|
+
continue
|
|
341
|
+
seed = rebuild_module_stream(module, project.code_page)
|
|
342
|
+
try:
|
|
343
|
+
cfb.add_stream_to_storage("VBA", name, seed)
|
|
344
|
+
except ValueError:
|
|
345
|
+
# Stream already exists (e.g. add-then-save called twice).
|
|
346
|
+
cfb.write_stream_in_storage("VBA", name, seed)
|
|
347
|
+
module.dirty = False
|
|
348
|
+
decl_key = (
|
|
349
|
+
"Module" if module.kind == VBAModuleKind.standard else "Class"
|
|
350
|
+
)
|
|
351
|
+
add_modules_for_project.append((module.name, decl_key))
|
|
352
|
+
|
|
353
|
+
# 3. Delete streams the user removed in-memory.
|
|
354
|
+
for name in delete_names:
|
|
355
|
+
try:
|
|
356
|
+
cfb.remove_stream_in_storage("VBA", name)
|
|
357
|
+
except KeyError:
|
|
358
|
+
pass
|
|
359
|
+
|
|
360
|
+
project.pending_renames.clear()
|
|
361
|
+
project.pending_adds.clear()
|
|
362
|
+
project.pending_deletes.clear()
|
|
363
|
+
|
|
364
|
+
# 4. Replace contents of any remaining dirty (pre-existing) modules.
|
|
365
|
+
write_back_modules(cfb, project)
|
|
366
|
+
|
|
367
|
+
# 5. Rewrite the dir + PROJECT streams when the module set's
|
|
368
|
+
# identity has changed (add / rename / delete).
|
|
369
|
+
if project.dir_structure_dirty:
|
|
370
|
+
new_dir_raw = serialize_dir_stream(project)
|
|
371
|
+
cfb.write_stream_in_storage("VBA", "dir", compress(new_dir_raw))
|
|
372
|
+
try:
|
|
373
|
+
project_raw = cfb.get_stream("PROJECT")
|
|
374
|
+
except KeyError:
|
|
375
|
+
project_raw = None
|
|
376
|
+
if project_raw is not None and (
|
|
377
|
+
rename_map or add_modules_for_project or delete_names
|
|
378
|
+
):
|
|
379
|
+
new_project = serialize_project_stream(
|
|
380
|
+
project_raw,
|
|
381
|
+
rename_map,
|
|
382
|
+
add_modules=add_modules_for_project,
|
|
383
|
+
delete_names=delete_names,
|
|
384
|
+
)
|
|
385
|
+
cfb.write_stream("PROJECT", new_project)
|
|
386
|
+
# Rewrite PROJECTwm to enumerate the current module set in
|
|
387
|
+
# both MBCS and Unicode forms. Required whenever the module
|
|
388
|
+
# identity set changes ([MS-OVBA] 2.3.4.4).
|
|
389
|
+
try:
|
|
390
|
+
cfb.get_stream_in_storage("VBA", "PROJECTwm")
|
|
391
|
+
except KeyError:
|
|
392
|
+
pass
|
|
393
|
+
else:
|
|
394
|
+
wm_pairs = [
|
|
395
|
+
(m.name, m.name_unicode or m.name)
|
|
396
|
+
for m in project.modules
|
|
397
|
+
]
|
|
398
|
+
cfb.write_stream_in_storage(
|
|
399
|
+
"VBA",
|
|
400
|
+
"PROJECTwm",
|
|
401
|
+
serialize_projectwm(wm_pairs, code_page=project.code_page),
|
|
402
|
+
)
|
|
403
|
+
project.dir_structure_dirty = False
|
|
404
|
+
|
|
405
|
+
# 6. Invalidate the _VBA_PROJECT performance cache so Office
|
|
406
|
+
# regenerates it on next open ([MS-OVBA] 2.3.4.1 -- the
|
|
407
|
+
# cache MUST be ignored on read; the verbatim cache may
|
|
408
|
+
# reference offsets that no longer match the updated
|
|
409
|
+
# module set or source).
|
|
410
|
+
if mutating:
|
|
411
|
+
invalidate_vba_project_cache(cfb)
|
|
412
|
+
# [MS-OVBA] writers MUST NOT emit performance-cache (__SRP_*) streams.
|
|
413
|
+
try:
|
|
414
|
+
cfb.drop_streams_in_storage("VBA", lambda n: n.startswith("__SRP_"))
|
|
415
|
+
except KeyError:
|
|
416
|
+
pass
|
|
417
|
+
new_cfb_bytes = cfb.to_bytes()
|
|
418
|
+
out_path = Path(dest) if dest is not None else self._path
|
|
419
|
+
|
|
420
|
+
if self._suffix in _CFB_FORMATS:
|
|
421
|
+
out_path.write_bytes(new_cfb_bytes)
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
if self._zip is None:
|
|
425
|
+
raise RuntimeError("ExcelFile is not open.")
|
|
426
|
+
|
|
427
|
+
buf = io.BytesIO()
|
|
428
|
+
with zipfile.ZipFile(buf, "w") as out_zip:
|
|
429
|
+
for info in self._zip.infolist():
|
|
430
|
+
if info.filename == _VBA_ENTRY:
|
|
431
|
+
out_info = zipfile.ZipInfo(
|
|
432
|
+
filename=info.filename,
|
|
433
|
+
date_time=info.date_time,
|
|
434
|
+
)
|
|
435
|
+
out_info.compress_type = info.compress_type
|
|
436
|
+
out_info.external_attr = info.external_attr
|
|
437
|
+
out_info.create_system = info.create_system
|
|
438
|
+
out_zip.writestr(out_info, new_cfb_bytes)
|
|
439
|
+
else:
|
|
440
|
+
data = self._zip.read(info.filename)
|
|
441
|
+
out_info = zipfile.ZipInfo(
|
|
442
|
+
filename=info.filename,
|
|
443
|
+
date_time=info.date_time,
|
|
444
|
+
)
|
|
445
|
+
out_info.compress_type = info.compress_type
|
|
446
|
+
out_info.external_attr = info.external_attr
|
|
447
|
+
out_info.create_system = info.create_system
|
|
448
|
+
out_zip.writestr(out_info, data)
|
|
449
|
+
out_path.write_bytes(buf.getvalue())
|
|
450
|
+
|
|
451
|
+
# ------------------------------------------------------------------
|
|
452
|
+
# Internal helpers
|
|
453
|
+
# ------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
def _open(self) -> None:
|
|
456
|
+
if self._suffix in _ZIP_FORMATS:
|
|
457
|
+
self._open_zip()
|
|
458
|
+
elif self._suffix in _CFB_FORMATS:
|
|
459
|
+
self._open_cfb_direct()
|
|
460
|
+
else:
|
|
461
|
+
raise UnsupportedFormatError(
|
|
462
|
+
f"Unsupported file extension: {self._suffix!r}. "
|
|
463
|
+
f"Supported: {sorted(_ZIP_FORMATS | _CFB_FORMATS)}"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
def _open_zip(self) -> None:
|
|
467
|
+
raw = self._path.read_bytes()
|
|
468
|
+
self._zip_bytes = raw
|
|
469
|
+
self._zip = zipfile.ZipFile(io.BytesIO(raw), mode="r")
|
|
470
|
+
if _VBA_ENTRY not in self._zip.namelist():
|
|
471
|
+
raise VBAProjectError(
|
|
472
|
+
f"{self._path.name!r} contains no {_VBA_ENTRY!r}. "
|
|
473
|
+
"Make sure the workbook has a VBA project (save as .xlsm in Excel)."
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
def _open_cfb_direct(self) -> None:
|
|
477
|
+
raw = self._path.read_bytes()
|
|
478
|
+
self._cfb = CFB.from_bytes(raw)
|
|
479
|
+
|
|
480
|
+
def _get_cfb(self) -> CFB:
|
|
481
|
+
if self._cfb is not None:
|
|
482
|
+
return self._cfb
|
|
483
|
+
if self._zip is None:
|
|
484
|
+
raise RuntimeError("ExcelFile is not open.")
|
|
485
|
+
vba_bin = self._zip.read(_VBA_ENTRY)
|
|
486
|
+
self._cfb = CFB.from_bytes(vba_bin)
|
|
487
|
+
return self._cfb
|
pyopenvba/exceptions.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Custom exceptions for pyOpenVBA."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PyOpenVBAError(Exception):
|
|
5
|
+
"""Base exception for all pyOpenVBA errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CFBError(PyOpenVBAError):
|
|
9
|
+
"""Raised when the Compound File Binary data is malformed or unsupported."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VBAProjectError(PyOpenVBAError):
|
|
13
|
+
"""Raised when the VBA project structure is invalid."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnsupportedFormatError(PyOpenVBAError):
|
|
17
|
+
"""Raised for file formats that pyOpenVBA cannot handle."""
|