pyvbaharness 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.
- pyvbaharness/__init__.py +66 -0
- pyvbaharness/__main__.py +258 -0
- pyvbaharness/codegen.py +576 -0
- pyvbaharness/dialog_policy.py +149 -0
- pyvbaharness/lock.py +76 -0
- pyvbaharness/numbering.py +283 -0
- pyvbaharness/oracle.py +144 -0
- pyvbaharness/pool.py +181 -0
- pyvbaharness/process_control.py +300 -0
- pyvbaharness/properties.py +129 -0
- pyvbaharness/protocol.py +105 -0
- pyvbaharness/pytest_plugin.py +138 -0
- pyvbaharness/ranges.py +53 -0
- pyvbaharness/results.py +177 -0
- pyvbaharness/screenshot.py +193 -0
- pyvbaharness/session.py +1132 -0
- pyvbaharness/vbasig.py +247 -0
- pyvbaharness/worker/__init__.py +5 -0
- pyvbaharness/worker/__main__.py +448 -0
- pyvbaharness/worker/excel_host.py +796 -0
- pyvbaharness/worker/watcher.py +361 -0
- pyvbaharness-1.0.0.dist-info/METADATA +508 -0
- pyvbaharness-1.0.0.dist-info/RECORD +27 -0
- pyvbaharness-1.0.0.dist-info/WHEEL +5 -0
- pyvbaharness-1.0.0.dist-info/entry_points.txt +5 -0
- pyvbaharness-1.0.0.dist-info/licenses/LICENSE +21 -0
- pyvbaharness-1.0.0.dist-info/top_level.txt +1 -0
pyvbaharness/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""pyVBAharness: a hang-resistant harness for running VBA in desktop Excel.
|
|
2
|
+
|
|
3
|
+
Requires Windows, desktop Excel, and the Excel option "Trust access to the
|
|
4
|
+
VBA project object model" (File > Options > Trust Center > Trust Center
|
|
5
|
+
Settings > Macro Settings). See README.md.
|
|
6
|
+
"""
|
|
7
|
+
from .numbering import (
|
|
8
|
+
add_line_numbers,
|
|
9
|
+
instrument_error_lines,
|
|
10
|
+
instrument_module,
|
|
11
|
+
)
|
|
12
|
+
from .results import (
|
|
13
|
+
COMPILE_ACCEPTED,
|
|
14
|
+
COMPILE_INFRA_FAILURE,
|
|
15
|
+
COMPILE_REJECTED,
|
|
16
|
+
MODAL_BLOCKED,
|
|
17
|
+
PASSED,
|
|
18
|
+
RUNNER_ERROR,
|
|
19
|
+
TIMEOUT,
|
|
20
|
+
VBA_ERROR,
|
|
21
|
+
CompileResult,
|
|
22
|
+
CoverageReport,
|
|
23
|
+
DialogRecord,
|
|
24
|
+
HarnessError,
|
|
25
|
+
ModuleCoverage,
|
|
26
|
+
RunResult,
|
|
27
|
+
SessionDead,
|
|
28
|
+
SessionLockHeld,
|
|
29
|
+
TestCaseResult,
|
|
30
|
+
VbaError,
|
|
31
|
+
WorkerProtocolError,
|
|
32
|
+
)
|
|
33
|
+
from .pool import SessionPool
|
|
34
|
+
from .session import ExcelSession, HarnessConfig, run_vba
|
|
35
|
+
|
|
36
|
+
__version__ = "1.0.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"ExcelSession",
|
|
40
|
+
"SessionPool",
|
|
41
|
+
"HarnessConfig",
|
|
42
|
+
"run_vba",
|
|
43
|
+
"add_line_numbers",
|
|
44
|
+
"instrument_error_lines",
|
|
45
|
+
"instrument_module",
|
|
46
|
+
"RunResult",
|
|
47
|
+
"CompileResult",
|
|
48
|
+
"CoverageReport",
|
|
49
|
+
"ModuleCoverage",
|
|
50
|
+
"DialogRecord",
|
|
51
|
+
"TestCaseResult",
|
|
52
|
+
"VbaError",
|
|
53
|
+
"HarnessError",
|
|
54
|
+
"SessionDead",
|
|
55
|
+
"SessionLockHeld",
|
|
56
|
+
"WorkerProtocolError",
|
|
57
|
+
"PASSED",
|
|
58
|
+
"VBA_ERROR",
|
|
59
|
+
"TIMEOUT",
|
|
60
|
+
"MODAL_BLOCKED",
|
|
61
|
+
"RUNNER_ERROR",
|
|
62
|
+
"COMPILE_ACCEPTED",
|
|
63
|
+
"COMPILE_REJECTED",
|
|
64
|
+
"COMPILE_INFRA_FAILURE",
|
|
65
|
+
"__version__",
|
|
66
|
+
]
|
pyvbaharness/__main__.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Command-line interface: python -m pyvbaharness <command>.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
doctor diagnose the environment (add --live for a real smoke run)
|
|
5
|
+
run FILE run a procedure from a .bas/.vba source file
|
|
6
|
+
check FILE... compile-check source files in a fresh workbook
|
|
7
|
+
check --workbook compile-check an existing workbook's VBA project
|
|
8
|
+
|
|
9
|
+
Exit codes: 0 success/accepted, 1 VBA failure/rejected, 2 harness or
|
|
10
|
+
environment failure. A timeout is infrastructure (2), never a verdict on the
|
|
11
|
+
code.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _print(line: str = "") -> None:
|
|
22
|
+
print(line, flush=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ----- doctor ---------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
def _read_registry(root, path: str, name: str | None):
|
|
28
|
+
import winreg
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
with winreg.OpenKey(root, path) as key:
|
|
32
|
+
value, _kind = winreg.QueryValueEx(key, name)
|
|
33
|
+
return value
|
|
34
|
+
except OSError:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _doctor_checks() -> list[tuple[str, bool | None, str]]:
|
|
39
|
+
"""(label, ok_or_None_for_warn, detail) rows."""
|
|
40
|
+
import winreg
|
|
41
|
+
|
|
42
|
+
rows: list[tuple[str, bool | None, str]] = []
|
|
43
|
+
|
|
44
|
+
ok = sys.platform == "win32"
|
|
45
|
+
rows.append(("Windows platform", ok, sys.platform))
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
import win32com.client # noqa: F401
|
|
49
|
+
import pythoncom # noqa: F401
|
|
50
|
+
rows.append(("pywin32 importable", True, "ok"))
|
|
51
|
+
except ImportError as err:
|
|
52
|
+
rows.append(("pywin32 importable", False,
|
|
53
|
+
f"{err}; pip install pywin32"))
|
|
54
|
+
|
|
55
|
+
progid = _read_registry(winreg.HKEY_LOCAL_MACHINE,
|
|
56
|
+
r"SOFTWARE\Classes\Excel.Application\CurVer", None)
|
|
57
|
+
rows.append(("Excel installed", progid is not None,
|
|
58
|
+
str(progid or "Excel.Application ProgID not registered")))
|
|
59
|
+
|
|
60
|
+
office_version = ""
|
|
61
|
+
if isinstance(progid, str) and progid.rsplit(".", 1)[-1].isdigit():
|
|
62
|
+
office_version = progid.rsplit(".", 1)[-1] + ".0"
|
|
63
|
+
if office_version:
|
|
64
|
+
access = _read_registry(
|
|
65
|
+
winreg.HKEY_CURRENT_USER,
|
|
66
|
+
rf"Software\Microsoft\Office\{office_version}\Excel\Security",
|
|
67
|
+
"AccessVBOM")
|
|
68
|
+
rows.append((
|
|
69
|
+
"Trust access to the VBA project object model",
|
|
70
|
+
access == 1,
|
|
71
|
+
"enabled" if access == 1 else
|
|
72
|
+
"DISABLED. File > Options > Trust Center > Trust Center "
|
|
73
|
+
"Settings > Macro Settings > tick 'Trust access to the VBA "
|
|
74
|
+
"project object model'."))
|
|
75
|
+
else:
|
|
76
|
+
rows.append(("Trust access to the VBA project object model", None,
|
|
77
|
+
"could not determine the Office version"))
|
|
78
|
+
|
|
79
|
+
# VBE error-trapping mode. 1 = Break on All Errors, which stops in the
|
|
80
|
+
# debugger even for handled errors: every managed run would report
|
|
81
|
+
# modal-blocked instead of vba-error.
|
|
82
|
+
break_mode = None
|
|
83
|
+
for vba_version in ("7.1", "7.0", "6.0"):
|
|
84
|
+
break_mode = _read_registry(
|
|
85
|
+
winreg.HKEY_CURRENT_USER,
|
|
86
|
+
rf"Software\Microsoft\VBA\{vba_version}\Common",
|
|
87
|
+
"BreakOnAllErrors")
|
|
88
|
+
if break_mode is not None:
|
|
89
|
+
break
|
|
90
|
+
if break_mode == 1:
|
|
91
|
+
rows.append(("VBE error trapping", False,
|
|
92
|
+
"'Break on All Errors' is set. In the VBE: Tools > "
|
|
93
|
+
"Options > General > Error Trapping > 'Break on "
|
|
94
|
+
"Unhandled Errors'. With break-on-all, handled VBA "
|
|
95
|
+
"errors stop in the debugger and runs report "
|
|
96
|
+
"modal-blocked."))
|
|
97
|
+
else:
|
|
98
|
+
rows.append(("VBE error trapping", True,
|
|
99
|
+
"break on unhandled errors"))
|
|
100
|
+
|
|
101
|
+
return rows
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
105
|
+
rows = _doctor_checks()
|
|
106
|
+
hard_fail = False
|
|
107
|
+
for label, ok, detail in rows:
|
|
108
|
+
if ok is True:
|
|
109
|
+
mark = "pass"
|
|
110
|
+
elif ok is None:
|
|
111
|
+
mark = "warn"
|
|
112
|
+
else:
|
|
113
|
+
mark = "FAIL"
|
|
114
|
+
hard_fail = True
|
|
115
|
+
_print(f"[{mark}] {label}: {detail}")
|
|
116
|
+
|
|
117
|
+
if args.live and not hard_fail:
|
|
118
|
+
from . import ExcelSession
|
|
119
|
+
|
|
120
|
+
_print()
|
|
121
|
+
_print("Live smoke: starting an owned Excel...")
|
|
122
|
+
started = time.perf_counter()
|
|
123
|
+
try:
|
|
124
|
+
with ExcelSession() as session:
|
|
125
|
+
startup = time.perf_counter() - started
|
|
126
|
+
result = session.run_vba(
|
|
127
|
+
"Public Function PyVbaDoctor() As Long\n"
|
|
128
|
+
" PyVbaDoctor = 42\n"
|
|
129
|
+
"End Function\n", proc="PyVbaDoctor")
|
|
130
|
+
if result.outcome == "passed" and result.value == 42:
|
|
131
|
+
_print(f"[pass] live run: startup {startup:.1f}s, "
|
|
132
|
+
f"run {result.duration_s * 1000:.0f}ms")
|
|
133
|
+
else:
|
|
134
|
+
_print(f"[FAIL] live run: {result.outcome} "
|
|
135
|
+
f"{result.message}")
|
|
136
|
+
hard_fail = True
|
|
137
|
+
except Exception as err: # noqa: BLE001 - report, do not crash
|
|
138
|
+
_print(f"[FAIL] live run: {type(err).__name__}: {err}")
|
|
139
|
+
hard_fail = True
|
|
140
|
+
|
|
141
|
+
return 2 if hard_fail else 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ----- run ------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def _parse_cli_arg(text: str):
|
|
147
|
+
for cast in (int, float):
|
|
148
|
+
try:
|
|
149
|
+
return cast(text)
|
|
150
|
+
except ValueError:
|
|
151
|
+
continue
|
|
152
|
+
if text.lower() in ("true", "false"):
|
|
153
|
+
return text.lower() == "true"
|
|
154
|
+
return text
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
158
|
+
from . import ExcelSession
|
|
159
|
+
|
|
160
|
+
source = Path(args.file).read_text(encoding="utf-8-sig")
|
|
161
|
+
call_args = tuple(_parse_cli_arg(a) for a in args.arg)
|
|
162
|
+
with ExcelSession() as session:
|
|
163
|
+
result = session.run_vba(source, proc=args.proc, args=call_args,
|
|
164
|
+
timeout=args.timeout)
|
|
165
|
+
for line in result.output:
|
|
166
|
+
_print(line)
|
|
167
|
+
if result.outcome == "passed":
|
|
168
|
+
if result.value is not None:
|
|
169
|
+
_print(f"value: {result.value!r}")
|
|
170
|
+
return 0
|
|
171
|
+
if result.error is not None:
|
|
172
|
+
_print(f"error: {result.error}")
|
|
173
|
+
return 1
|
|
174
|
+
_print(f"{result.outcome}: {result.message}")
|
|
175
|
+
return 2
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ----- check ----------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
181
|
+
from . import ExcelSession
|
|
182
|
+
from .codegen import is_vba_identifier
|
|
183
|
+
|
|
184
|
+
if not args.files and not args.workbook:
|
|
185
|
+
_print("check needs source files or --workbook")
|
|
186
|
+
return 2
|
|
187
|
+
with ExcelSession() as session:
|
|
188
|
+
if args.workbook:
|
|
189
|
+
# Compile the workbook exactly as-is: no injected modules.
|
|
190
|
+
session.open_workbook(args.workbook, read_only=True)
|
|
191
|
+
result = session.compile_project(watch_seconds=args.watch)
|
|
192
|
+
else:
|
|
193
|
+
session.new_workbook()
|
|
194
|
+
for file_name in args.files:
|
|
195
|
+
path = Path(file_name)
|
|
196
|
+
module = path.stem
|
|
197
|
+
if not is_vba_identifier(module):
|
|
198
|
+
_print(f"skipping {path.name}: file stem is not a valid "
|
|
199
|
+
"VBA module name")
|
|
200
|
+
return 2
|
|
201
|
+
kind = "class" if path.suffix.lower() == ".cls" else "standard"
|
|
202
|
+
session.add_module(module,
|
|
203
|
+
path.read_text(encoding="utf-8-sig"),
|
|
204
|
+
kind=kind)
|
|
205
|
+
# Loose files are destined for the harness: compile them with
|
|
206
|
+
# the support module present, the way they will actually run.
|
|
207
|
+
result = session.compile_project(
|
|
208
|
+
watch_seconds=args.watch, include_harness_support=True)
|
|
209
|
+
if result.outcome == "accepted":
|
|
210
|
+
_print(f"accepted in {result.duration_s:.1f}s")
|
|
211
|
+
return 0
|
|
212
|
+
if result.outcome == "rejected":
|
|
213
|
+
_print("rejected: compile error")
|
|
214
|
+
if result.dialog is not None:
|
|
215
|
+
for text in (result.dialog.texts or [result.dialog.message]):
|
|
216
|
+
_print(f" {text}")
|
|
217
|
+
return 1
|
|
218
|
+
_print(f"infrastructure failure: {result.message}")
|
|
219
|
+
return 2
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ----- entry ----------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
def main(argv: list[str] | None = None) -> int:
|
|
225
|
+
parser = argparse.ArgumentParser(
|
|
226
|
+
prog="python -m pyvbaharness",
|
|
227
|
+
description="Hang-resistant VBA harness for desktop Excel.")
|
|
228
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
229
|
+
|
|
230
|
+
doctor = commands.add_parser(
|
|
231
|
+
"doctor", help="diagnose the environment for harness use")
|
|
232
|
+
doctor.add_argument("--live", action="store_true",
|
|
233
|
+
help="also start Excel and run a smoke test")
|
|
234
|
+
doctor.set_defaults(func=cmd_doctor)
|
|
235
|
+
|
|
236
|
+
run = commands.add_parser("run", help="run a procedure from a VBA file")
|
|
237
|
+
run.add_argument("file")
|
|
238
|
+
run.add_argument("--proc", default="Main")
|
|
239
|
+
run.add_argument("--timeout", type=float, default=None)
|
|
240
|
+
run.add_argument("--arg", action="append", default=[],
|
|
241
|
+
help="argument for the procedure (repeatable; int, "
|
|
242
|
+
"float, and true/false are auto-converted)")
|
|
243
|
+
run.set_defaults(func=cmd_run)
|
|
244
|
+
|
|
245
|
+
check = commands.add_parser(
|
|
246
|
+
"check", help="compile-check VBA files or a workbook")
|
|
247
|
+
check.add_argument("files", nargs="*")
|
|
248
|
+
check.add_argument("--workbook", default=None)
|
|
249
|
+
check.add_argument("--watch", type=float, default=15.0,
|
|
250
|
+
help="dialog watch window in seconds (default 15)")
|
|
251
|
+
check.set_defaults(func=cmd_check)
|
|
252
|
+
|
|
253
|
+
args = parser.parse_args(argv)
|
|
254
|
+
return args.func(args)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
if __name__ == "__main__":
|
|
258
|
+
sys.exit(main())
|