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/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
@@ -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."""