pyaccesskit 0.1.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.
- pyaccesskit/AGENT_GUIDE.md +455 -0
- pyaccesskit/__init__.py +167 -0
- pyaccesskit/__main__.py +6 -0
- pyaccesskit/_backends/__init__.py +0 -0
- pyaccesskit/_backends/access/__init__.py +1 -0
- pyaccesskit/_backends/access/design.py +415 -0
- pyaccesskit/_backends/dao/__init__.py +1 -0
- pyaccesskit/_backends/dao/profile.py +40 -0
- pyaccesskit/_backends/dao/schema.py +805 -0
- pyaccesskit/_backends/dao/typemap.py +390 -0
- pyaccesskit/_backends/fake/__init__.py +3 -0
- pyaccesskit/_backends/fake/backend.py +680 -0
- pyaccesskit/_backends/protocols.py +339 -0
- pyaccesskit/_com/__init__.py +1 -0
- pyaccesskit/_com/constants.py +394 -0
- pyaccesskit/_com/dispatch.py +50 -0
- pyaccesskit/_com/errors.py +184 -0
- pyaccesskit/_com/gateway.py +199 -0
- pyaccesskit/_com/raw.py +164 -0
- pyaccesskit/_com/runtime.py +39 -0
- pyaccesskit/_com/variants.py +72 -0
- pyaccesskit/_engines/__init__.py +48 -0
- pyaccesskit/_engines/access.py +300 -0
- pyaccesskit/_engines/inproc.py +148 -0
- pyaccesskit/_engines/probe.py +231 -0
- pyaccesskit/_ledger.py +158 -0
- pyaccesskit/_ops/__init__.py +0 -0
- pyaccesskit/_ops/design.py +127 -0
- pyaccesskit/_ops/schema.py +471 -0
- pyaccesskit/_session/__init__.py +1 -0
- pyaccesskit/_session/protocols.py +78 -0
- pyaccesskit/_session/session.py +354 -0
- pyaccesskit/_text/__init__.py +0 -0
- pyaccesskit/_text/codec.py +114 -0
- pyaccesskit/_version.py +3 -0
- pyaccesskit/_win/__init__.py +1 -0
- pyaccesskit/_win/access_process.py +348 -0
- pyaccesskit/_win/console.py +56 -0
- pyaccesskit/_win/inspector.py +53 -0
- pyaccesskit/_win/job.py +65 -0
- pyaccesskit/_win/processes.py +159 -0
- pyaccesskit/_win/watchdog.py +253 -0
- pyaccesskit/cli/__init__.py +10 -0
- pyaccesskit/cli/_output.py +101 -0
- pyaccesskit/cli/agent.py +99 -0
- pyaccesskit/cli/app.py +54 -0
- pyaccesskit/cli/cleanup.py +56 -0
- pyaccesskit/cli/doctor.py +101 -0
- pyaccesskit/cli/inspection.py +223 -0
- pyaccesskit/database.py +296 -0
- pyaccesskit/diagnostics.py +319 -0
- pyaccesskit/enums.py +258 -0
- pyaccesskit/errors.py +407 -0
- pyaccesskit/forms/__init__.py +45 -0
- pyaccesskit/forms/builder.py +295 -0
- pyaccesskit/forms/collection.py +117 -0
- pyaccesskit/forms/controls.py +157 -0
- pyaccesskit/forms/layout.py +300 -0
- pyaccesskit/forms/spec.py +169 -0
- pyaccesskit/forms/vba.py +138 -0
- pyaccesskit/maintenance.py +32 -0
- pyaccesskit/modules.py +101 -0
- pyaccesskit/objects.py +81 -0
- pyaccesskit/options.py +40 -0
- pyaccesskit/properties.py +74 -0
- pyaccesskit/py.typed +0 -0
- pyaccesskit/queries.py +190 -0
- pyaccesskit/relationships.py +143 -0
- pyaccesskit/schema/__init__.py +73 -0
- pyaccesskit/schema/_base.py +55 -0
- pyaccesskit/schema/_reserved_words.py +55 -0
- pyaccesskit/schema/columns.py +609 -0
- pyaccesskit/schema/compat.py +57 -0
- pyaccesskit/schema/expressions.py +162 -0
- pyaccesskit/schema/indexes.py +114 -0
- pyaccesskit/schema/names.py +122 -0
- pyaccesskit/schema/queries.py +192 -0
- pyaccesskit/schema/relationships.py +132 -0
- pyaccesskit/schema/tables.py +178 -0
- pyaccesskit/tables.py +333 -0
- pyaccesskit/units.py +301 -0
- pyaccesskit-0.1.0.dist-info/METADATA +201 -0
- pyaccesskit-0.1.0.dist-info/RECORD +86 -0
- pyaccesskit-0.1.0.dist-info/WHEEL +4 -0
- pyaccesskit-0.1.0.dist-info/entry_points.txt +2 -0
- pyaccesskit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# pyright: basic
|
|
2
|
+
"""An ``MSACCESS.EXE`` process that PyAccessKit started and therefore owns.
|
|
3
|
+
|
|
4
|
+
Launch (ADR 0001, spike S1):
|
|
5
|
+
|
|
6
|
+
1. ``CoCreateInstanceEx(CLSCTX_LOCAL_SERVER)`` — always a *new* process, never a user's running Access;
|
|
7
|
+
2. ``hWndAccessApp()`` → PID → an open process handle; identity = PID + creation time + image;
|
|
8
|
+
3. assignment to a kill-on-close job object (Access dies with Python, whatever kills Python);
|
|
9
|
+
4. an ownership-ledger entry (for ``pyaccesskit cleanup`` should everything else fail);
|
|
10
|
+
5. the dialog watchdog; then ``AutomationSecurity``, ``Visible`` and ``SetWarnings``.
|
|
11
|
+
|
|
12
|
+
Shutdown closes open forms/reports without saving, closes the database, calls
|
|
13
|
+
``Quit(acQuitSaveNone)``, waits for the process to exit and — only for *this* process, identified by the
|
|
14
|
+
handle we hold — terminates it if it lingers. Every step runs even if an earlier one failed.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import contextlib
|
|
20
|
+
import gc
|
|
21
|
+
import logging
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import pywintypes
|
|
28
|
+
import win32process
|
|
29
|
+
|
|
30
|
+
from pyaccesskit import _ledger
|
|
31
|
+
from pyaccesskit._com import constants as c
|
|
32
|
+
from pyaccesskit._com.dispatch import create_local_server
|
|
33
|
+
from pyaccesskit._com.errors import OpContext, translate
|
|
34
|
+
from pyaccesskit._com.gateway import Com, call, get, put, retry_busy
|
|
35
|
+
from pyaccesskit._version import __version__
|
|
36
|
+
from pyaccesskit._win.job import KillOnCloseJob
|
|
37
|
+
from pyaccesskit._win.processes import (
|
|
38
|
+
ProcessIdentity,
|
|
39
|
+
close_handle,
|
|
40
|
+
current_identity,
|
|
41
|
+
identity_of,
|
|
42
|
+
is_alive,
|
|
43
|
+
open_process,
|
|
44
|
+
terminate,
|
|
45
|
+
wait_for_exit,
|
|
46
|
+
)
|
|
47
|
+
from pyaccesskit._win.watchdog import DialogWatchdog
|
|
48
|
+
from pyaccesskit.enums import DialogPolicy, MacroSecurity
|
|
49
|
+
from pyaccesskit.errors import AccessNotInstalledError, DaoError
|
|
50
|
+
|
|
51
|
+
__all__ = ["AccessLaunchOptions", "AccessProcess", "read_dao_errors"]
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger("pyaccesskit.process")
|
|
54
|
+
|
|
55
|
+
_MACRO_SECURITY = {
|
|
56
|
+
MacroSecurity.DISABLE: c.MsoAutomationSecurity.msoAutomationSecurityForceDisable,
|
|
57
|
+
MacroSecurity.USE_UI: c.MsoAutomationSecurity.msoAutomationSecurityByUI,
|
|
58
|
+
MacroSecurity.ENABLE: c.MsoAutomationSecurity.msoAutomationSecurityLow,
|
|
59
|
+
}
|
|
60
|
+
_NO_CURRENT_DATABASE = 2467
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class AccessLaunchOptions:
|
|
65
|
+
"""How to start and supervise Access."""
|
|
66
|
+
|
|
67
|
+
progid: str = "Access.Application"
|
|
68
|
+
visible: bool = False
|
|
69
|
+
macro_security: MacroSecurity = MacroSecurity.DISABLE
|
|
70
|
+
dialog_policy: DialogPolicy = DialogPolicy.FAIL
|
|
71
|
+
call_timeout: float | None = 600.0
|
|
72
|
+
quit_timeout: float = 30.0
|
|
73
|
+
kill_on_parent_exit: bool = True
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def read_dao_errors(dbengine: Any) -> list[DaoError]:
|
|
77
|
+
"""Snapshot ``DBEngine.Errors`` (used to enrich error messages)."""
|
|
78
|
+
errors: list[DaoError] = []
|
|
79
|
+
collection = dbengine.Errors
|
|
80
|
+
for index in range(collection.Count):
|
|
81
|
+
item = get(collection, "Item", index)
|
|
82
|
+
errors.append(DaoError(int(item.Number), str(item.Description), str(item.Source)))
|
|
83
|
+
return errors
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AccessProcess:
|
|
87
|
+
"""A hidden Access instance owned by one PyAccessKit session."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, options: AccessLaunchOptions) -> None:
|
|
90
|
+
self.options = options
|
|
91
|
+
self.app: Any = None
|
|
92
|
+
self.identity: ProcessIdentity | None = None
|
|
93
|
+
self.watchdog: DialogWatchdog | None = None
|
|
94
|
+
self.is_runtime = False
|
|
95
|
+
self._handle: Any = None
|
|
96
|
+
self._job: KillOnCloseJob | None = None
|
|
97
|
+
self._ledger_path: Any = None
|
|
98
|
+
self._lock = threading.Lock()
|
|
99
|
+
self._closed = False
|
|
100
|
+
self.com = Com(dao_errors=self._dao_errors)
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------------------------- launch
|
|
103
|
+
@classmethod
|
|
104
|
+
def launch(cls, options: AccessLaunchOptions, *, database: str | None = None) -> AccessProcess:
|
|
105
|
+
"""Start and configure a new, owned Access process.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
AccessNotInstalledError: If ``Access.Application`` cannot be started.
|
|
109
|
+
"""
|
|
110
|
+
process = cls(options)
|
|
111
|
+
try:
|
|
112
|
+
process.app = create_local_server(options.progid)
|
|
113
|
+
except pywintypes.com_error as exc:
|
|
114
|
+
error = translate(exc, OpContext("start Microsoft Access"))
|
|
115
|
+
raise AccessNotInstalledError(
|
|
116
|
+
f"Microsoft Access ({options.progid}) could not be started: {error.details.summary() if error.details else exc}",
|
|
117
|
+
diagnosis=(
|
|
118
|
+
"Install Microsoft Access (Microsoft 365, 2016 or later), or use engine='dao' with the "
|
|
119
|
+
"Microsoft 365 Access Runtime and a Python of the same bitness. Run 'pyaccesskit doctor'."
|
|
120
|
+
),
|
|
121
|
+
details=error.details,
|
|
122
|
+
) from exc
|
|
123
|
+
try:
|
|
124
|
+
process._take_ownership(database)
|
|
125
|
+
process._configure()
|
|
126
|
+
except BaseException:
|
|
127
|
+
process.shutdown()
|
|
128
|
+
raise
|
|
129
|
+
return process
|
|
130
|
+
|
|
131
|
+
def _take_ownership(self, database: str | None) -> None:
|
|
132
|
+
try:
|
|
133
|
+
hwnd = retry_busy(call, self.app, "hWndAccessApp")
|
|
134
|
+
_thread, pid = win32process.GetWindowThreadProcessId(int(hwnd))
|
|
135
|
+
self._handle = open_process(pid)
|
|
136
|
+
identity = identity_of(self._handle, pid)
|
|
137
|
+
except (pywintypes.error, pywintypes.com_error, OSError) as exc:
|
|
138
|
+
logger.warning(
|
|
139
|
+
"could not identify the Access process (%s); it will be shut down with Quit() only",
|
|
140
|
+
exc,
|
|
141
|
+
)
|
|
142
|
+
return
|
|
143
|
+
if identity.image_name.lower() != "msaccess.exe":
|
|
144
|
+
logger.warning(
|
|
145
|
+
"hWndAccessApp resolved to %s, not MSACCESS.EXE; not taking ownership",
|
|
146
|
+
identity.image,
|
|
147
|
+
)
|
|
148
|
+
close_handle(self._handle)
|
|
149
|
+
self._handle = None
|
|
150
|
+
return
|
|
151
|
+
self.identity = identity
|
|
152
|
+
logger.info("started Microsoft Access (PID %s)", identity.pid)
|
|
153
|
+
|
|
154
|
+
self.watchdog = DialogWatchdog(
|
|
155
|
+
identity.pid,
|
|
156
|
+
policy=self.options.dialog_policy,
|
|
157
|
+
call_timeout=self.options.call_timeout,
|
|
158
|
+
visible=self.options.visible,
|
|
159
|
+
terminate=self.terminate,
|
|
160
|
+
)
|
|
161
|
+
self.watchdog.start()
|
|
162
|
+
self.com = Com(monitor=self.watchdog, dao_errors=self._dao_errors)
|
|
163
|
+
|
|
164
|
+
if self.options.kill_on_parent_exit:
|
|
165
|
+
job = KillOnCloseJob()
|
|
166
|
+
if job.assign(self._handle):
|
|
167
|
+
self._job = job
|
|
168
|
+
else:
|
|
169
|
+
job.close()
|
|
170
|
+
try:
|
|
171
|
+
owner = current_identity()
|
|
172
|
+
entry = _ledger.OwnedProcess(
|
|
173
|
+
pid=identity.pid,
|
|
174
|
+
creation_time=identity.creation_time,
|
|
175
|
+
image=identity.image,
|
|
176
|
+
owner_pid=owner.pid,
|
|
177
|
+
owner_creation_time=owner.creation_time,
|
|
178
|
+
database=database,
|
|
179
|
+
started_at=time.time(),
|
|
180
|
+
version=__version__,
|
|
181
|
+
)
|
|
182
|
+
self._ledger_path = _ledger.record(entry)
|
|
183
|
+
except (OSError, pywintypes.error) as exc:
|
|
184
|
+
logger.warning("could not write the ownership ledger entry: %s", exc)
|
|
185
|
+
|
|
186
|
+
def _configure(self) -> None:
|
|
187
|
+
with self.com.op("configure Microsoft Access"):
|
|
188
|
+
put(self.app, "AutomationSecurity", int(_MACRO_SECURITY[self.options.macro_security]))
|
|
189
|
+
if self.options.visible:
|
|
190
|
+
put(self.app, "Visible", True)
|
|
191
|
+
with contextlib.suppress(pywintypes.com_error):
|
|
192
|
+
self.is_runtime = bool(
|
|
193
|
+
call(self.app, "SysCmd", int(c.AcSysCmdAction.acSysCmdRuntime))
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def database_opened(self) -> None:
|
|
197
|
+
"""Session settings that need an open current database (``SetWarnings`` fails without one)."""
|
|
198
|
+
with contextlib.suppress(pywintypes.com_error):
|
|
199
|
+
call(get(self.app, "DoCmd"), "SetWarnings", False)
|
|
200
|
+
|
|
201
|
+
def _dao_errors(self) -> list[DaoError]:
|
|
202
|
+
return read_dao_errors(get(self.app, "DBEngine")) if self.app is not None else []
|
|
203
|
+
|
|
204
|
+
# --------------------------------------------------------------------------------------- state
|
|
205
|
+
@property
|
|
206
|
+
def pid(self) -> int | None:
|
|
207
|
+
"""PID of the owned process (``None`` if ownership could not be established)."""
|
|
208
|
+
return self.identity.pid if self.identity else None
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def alive(self) -> bool:
|
|
212
|
+
"""Whether the process is still running (``True`` if unknown)."""
|
|
213
|
+
return is_alive(self._handle) if self._handle is not None else self.app is not None
|
|
214
|
+
|
|
215
|
+
def terminate(self, reason: str) -> None:
|
|
216
|
+
"""Terminate the owned process immediately (safe from any thread)."""
|
|
217
|
+
with self._lock:
|
|
218
|
+
handle = self._handle
|
|
219
|
+
if handle is None:
|
|
220
|
+
logger.error(
|
|
221
|
+
"cannot terminate Access (%s): its process could not be identified", reason
|
|
222
|
+
)
|
|
223
|
+
return
|
|
224
|
+
with contextlib.suppress(pywintypes.error):
|
|
225
|
+
if is_alive(handle):
|
|
226
|
+
logger.warning("terminating owned Access process %s: %s", self.pid, reason)
|
|
227
|
+
terminate(handle)
|
|
228
|
+
|
|
229
|
+
# ------------------------------------------------------------------------------------ shutdown
|
|
230
|
+
def close_open_objects(self) -> None:
|
|
231
|
+
"""Close every open form and report without saving (avoids 'Save As' prompts)."""
|
|
232
|
+
if self.app is None:
|
|
233
|
+
return
|
|
234
|
+
for collection, object_type in (
|
|
235
|
+
("Forms", c.AcObjectType.acForm),
|
|
236
|
+
("Reports", c.AcObjectType.acReport),
|
|
237
|
+
):
|
|
238
|
+
try:
|
|
239
|
+
opened = get(self.app, collection)
|
|
240
|
+
names = [
|
|
241
|
+
get(get(opened, "Item", index), "Name") for index in range(get(opened, "Count"))
|
|
242
|
+
]
|
|
243
|
+
except pywintypes.com_error:
|
|
244
|
+
continue
|
|
245
|
+
for name in names:
|
|
246
|
+
with contextlib.suppress(pywintypes.com_error):
|
|
247
|
+
call(
|
|
248
|
+
get(self.app, "DoCmd"),
|
|
249
|
+
"Close",
|
|
250
|
+
int(object_type),
|
|
251
|
+
name,
|
|
252
|
+
int(c.AcCloseSave.acSaveNo),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def close_database(self) -> None:
|
|
256
|
+
"""Close the current database, if any (ignoring 'no database open')."""
|
|
257
|
+
if self.app is None:
|
|
258
|
+
return
|
|
259
|
+
try:
|
|
260
|
+
call(self.app, "CloseCurrentDatabase")
|
|
261
|
+
except pywintypes.com_error as exc:
|
|
262
|
+
excepinfo = exc.args[2] if len(exc.args) > 2 else None
|
|
263
|
+
scode = excepinfo[5] if excepinfo else None
|
|
264
|
+
if scode is None or (scode & 0xFFFF) != _NO_CURRENT_DATABASE:
|
|
265
|
+
raise
|
|
266
|
+
|
|
267
|
+
def shutdown(self, timeout: float | None = None) -> list[BaseException]:
|
|
268
|
+
"""Quit Access and make sure the owned process is gone. Returns the errors of failed steps."""
|
|
269
|
+
errors: list[BaseException] = []
|
|
270
|
+
with self._lock:
|
|
271
|
+
if self._closed:
|
|
272
|
+
return errors
|
|
273
|
+
self._closed = True
|
|
274
|
+
wait = self.options.quit_timeout if timeout is None else timeout
|
|
275
|
+
# Release unreachable COM proxies while Access is still alive: releasing them after it exits makes
|
|
276
|
+
# COM raise (and handle) RPC_E_DISCONNECTED, which faulthandler reports as a "fatal exception".
|
|
277
|
+
gc.collect()
|
|
278
|
+
watch = (
|
|
279
|
+
self.watchdog.watching("shut down Microsoft Access")
|
|
280
|
+
if self.watchdog
|
|
281
|
+
else contextlib.nullcontext()
|
|
282
|
+
)
|
|
283
|
+
# If the process is already gone (e.g. terminated by the watchdog), COM steps can only fail.
|
|
284
|
+
dead = self._handle is not None and not is_alive(self._handle)
|
|
285
|
+
try:
|
|
286
|
+
with watch:
|
|
287
|
+
for step in (
|
|
288
|
+
() if dead else (self.close_open_objects, self.close_database, self._quit)
|
|
289
|
+
):
|
|
290
|
+
try:
|
|
291
|
+
step()
|
|
292
|
+
except Exception as exc:
|
|
293
|
+
errors.append(exc)
|
|
294
|
+
self.app = None
|
|
295
|
+
self.com = Com()
|
|
296
|
+
if self._handle is not None and not wait_for_exit(self._handle, wait):
|
|
297
|
+
logger.warning(
|
|
298
|
+
"Access (PID %s) did not exit %.0fs after Quit; terminating it",
|
|
299
|
+
self.pid,
|
|
300
|
+
wait,
|
|
301
|
+
)
|
|
302
|
+
self.terminate("did not exit after Quit")
|
|
303
|
+
wait_for_exit(self._handle, 10)
|
|
304
|
+
finally:
|
|
305
|
+
self._release()
|
|
306
|
+
return errors
|
|
307
|
+
|
|
308
|
+
def _quit(self) -> None:
|
|
309
|
+
if self.app is None:
|
|
310
|
+
return
|
|
311
|
+
with contextlib.suppress(pywintypes.com_error):
|
|
312
|
+
call(self.app, "Quit", int(c.AcQuitOption.acQuitSaveNone))
|
|
313
|
+
|
|
314
|
+
def _release(self) -> None:
|
|
315
|
+
if self.watchdog is not None:
|
|
316
|
+
self.watchdog.stop()
|
|
317
|
+
if self._job is not None:
|
|
318
|
+
self._job.close()
|
|
319
|
+
self._job = None
|
|
320
|
+
exited = self._handle is None or not is_alive(self._handle)
|
|
321
|
+
if self._ledger_path is not None and exited:
|
|
322
|
+
_ledger.remove(self._ledger_path)
|
|
323
|
+
self._ledger_path = None
|
|
324
|
+
if self._handle is not None:
|
|
325
|
+
close_handle(self._handle)
|
|
326
|
+
self._handle = None
|
|
327
|
+
|
|
328
|
+
def detach(self) -> None:
|
|
329
|
+
"""Hand the process over to the user: it keeps running after this Python process exits."""
|
|
330
|
+
if self.app is not None:
|
|
331
|
+
put(self.app, "Visible", True)
|
|
332
|
+
put(self.app, "UserControl", True)
|
|
333
|
+
if self._job is not None:
|
|
334
|
+
self._job.release()
|
|
335
|
+
self.app = None
|
|
336
|
+
with self._lock:
|
|
337
|
+
self._closed = True
|
|
338
|
+
if self.watchdog is not None:
|
|
339
|
+
self.watchdog.stop()
|
|
340
|
+
if self._ledger_path is not None:
|
|
341
|
+
_ledger.remove(self._ledger_path)
|
|
342
|
+
self._ledger_path = None
|
|
343
|
+
if self._job is not None:
|
|
344
|
+
self._job.close()
|
|
345
|
+
self._job = None
|
|
346
|
+
if self._handle is not None:
|
|
347
|
+
close_handle(self._handle)
|
|
348
|
+
self._handle = None
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# pyright: basic
|
|
2
|
+
"""Make Ctrl+C effective while Python is blocked inside a long COM call.
|
|
3
|
+
|
|
4
|
+
Python only raises ``KeyboardInterrupt`` when the main thread runs bytecode again, which does not happen
|
|
5
|
+
while it waits for Access. A console control handler (called by Windows on its own thread) lets the
|
|
6
|
+
watchdogs terminate the *owned* Access process whose call is in flight; the COM call then returns and the
|
|
7
|
+
interrupt surfaces immediately. The handler returns ``False`` so Python's default handling still runs.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
|
|
16
|
+
import win32api
|
|
17
|
+
import win32con
|
|
18
|
+
|
|
19
|
+
__all__ = ["register_interrupt_callback", "unregister_interrupt_callback"]
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("pyaccesskit.process")
|
|
22
|
+
|
|
23
|
+
_lock = threading.Lock()
|
|
24
|
+
_callbacks: set[Callable[[], None]] = set()
|
|
25
|
+
_installed = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _handler(ctrl_type: int) -> bool:
|
|
29
|
+
if ctrl_type in (win32con.CTRL_C_EVENT, win32con.CTRL_BREAK_EVENT):
|
|
30
|
+
with _lock:
|
|
31
|
+
callbacks = list(_callbacks)
|
|
32
|
+
for callback in callbacks:
|
|
33
|
+
try:
|
|
34
|
+
callback()
|
|
35
|
+
except Exception:
|
|
36
|
+
logger.exception("interrupt callback failed")
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def register_interrupt_callback(callback: Callable[[], None]) -> None:
|
|
41
|
+
"""Call ``callback`` (from a Windows-owned thread) when the user presses Ctrl+C or Ctrl+Break."""
|
|
42
|
+
global _installed # noqa: PLW0603 - one process-wide console handler
|
|
43
|
+
with _lock:
|
|
44
|
+
_callbacks.add(callback)
|
|
45
|
+
if not _installed:
|
|
46
|
+
try:
|
|
47
|
+
win32api.SetConsoleCtrlHandler(_handler, True)
|
|
48
|
+
_installed = True
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
logger.debug("console control handler unavailable: %s", exc)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def unregister_interrupt_callback(callback: Callable[[], None]) -> None:
|
|
54
|
+
"""Remove a callback registered with :func:`register_interrupt_callback`."""
|
|
55
|
+
with _lock:
|
|
56
|
+
_callbacks.discard(callback)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# pyright: basic
|
|
2
|
+
"""Win32 implementation of the ledger's :class:`~pyaccesskit._ledger.ProcessInspector`."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from pathlib import PureWindowsPath
|
|
7
|
+
|
|
8
|
+
import pywintypes
|
|
9
|
+
|
|
10
|
+
from pyaccesskit._win.processes import (
|
|
11
|
+
PROCESS_QUERY_LIMITED_INFORMATION,
|
|
12
|
+
PROCESS_TERMINATE,
|
|
13
|
+
SYNCHRONIZE,
|
|
14
|
+
ProcessIdentity,
|
|
15
|
+
close_handle,
|
|
16
|
+
identity_matches,
|
|
17
|
+
is_alive,
|
|
18
|
+
open_process,
|
|
19
|
+
query_image,
|
|
20
|
+
terminate,
|
|
21
|
+
wait_for_exit,
|
|
22
|
+
)
|
|
23
|
+
from pyaccesskit._win.processes import creation_time as process_creation_time
|
|
24
|
+
|
|
25
|
+
__all__ = ["Win32Inspector"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Win32Inspector:
|
|
29
|
+
"""Checks and terminates processes by *identity* (PID + creation time + image name)."""
|
|
30
|
+
|
|
31
|
+
def matches(self, pid: int, creation_time: float) -> bool:
|
|
32
|
+
return identity_matches(ProcessIdentity(pid, creation_time, ""))
|
|
33
|
+
|
|
34
|
+
def terminate(self, pid: int, creation_time: float, image_name: str) -> bool:
|
|
35
|
+
try:
|
|
36
|
+
handle = open_process(
|
|
37
|
+
pid, PROCESS_TERMINATE | SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION
|
|
38
|
+
)
|
|
39
|
+
except pywintypes.error:
|
|
40
|
+
return False
|
|
41
|
+
try:
|
|
42
|
+
if not is_alive(handle):
|
|
43
|
+
return True
|
|
44
|
+
same_process = abs(process_creation_time(handle) - creation_time) < 0.001
|
|
45
|
+
same_image = PureWindowsPath(query_image(handle)).name.lower() == image_name.lower()
|
|
46
|
+
if not (same_process and same_image):
|
|
47
|
+
return False
|
|
48
|
+
terminate(handle)
|
|
49
|
+
return wait_for_exit(handle, 10)
|
|
50
|
+
except (pywintypes.error, OSError):
|
|
51
|
+
return False
|
|
52
|
+
finally:
|
|
53
|
+
close_handle(handle)
|
pyaccesskit/_win/job.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# pyright: basic
|
|
2
|
+
"""A Windows job object that kills its processes when PyAccessKit's Python process dies.
|
|
3
|
+
|
|
4
|
+
Once our Access process is assigned to the job, closing the job handle — which Windows does automatically
|
|
5
|
+
when Python exits for *any* reason (crash, ``os._exit``, Task Manager) — terminates it. Verified in spike S1
|
|
6
|
+
for DCOM-launched Access. Assignment is best effort; the ownership ledger covers the rest.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import pywintypes
|
|
15
|
+
import win32job
|
|
16
|
+
|
|
17
|
+
from pyaccesskit._win.processes import close_handle
|
|
18
|
+
|
|
19
|
+
__all__ = ["KillOnCloseJob"]
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("pyaccesskit.process")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class KillOnCloseJob:
|
|
25
|
+
"""A job object with ``JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE``."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._handle: Any = win32job.CreateJobObject(None, "")
|
|
29
|
+
self._set_kill_on_close(True)
|
|
30
|
+
self.assigned = False
|
|
31
|
+
|
|
32
|
+
def _set_kill_on_close(self, enabled: bool) -> None:
|
|
33
|
+
info = win32job.QueryInformationJobObject(
|
|
34
|
+
self._handle, win32job.JobObjectExtendedLimitInformation
|
|
35
|
+
)
|
|
36
|
+
flags = info["BasicLimitInformation"]["LimitFlags"]
|
|
37
|
+
if enabled:
|
|
38
|
+
flags |= win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
39
|
+
else:
|
|
40
|
+
flags &= ~win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
41
|
+
info["BasicLimitInformation"]["LimitFlags"] = flags
|
|
42
|
+
win32job.SetInformationJobObject(
|
|
43
|
+
self._handle, win32job.JobObjectExtendedLimitInformation, info
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def assign(self, process_handle: Any) -> bool:
|
|
47
|
+
"""Put the process in the job; returns ``False`` (and logs) if Windows refuses."""
|
|
48
|
+
try:
|
|
49
|
+
win32job.AssignProcessToJobObject(self._handle, process_handle)
|
|
50
|
+
except pywintypes.error as exc:
|
|
51
|
+
logger.warning("could not assign the Access process to a kill-on-close job: %s", exc)
|
|
52
|
+
return False
|
|
53
|
+
self.assigned = True
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
def release(self) -> None:
|
|
57
|
+
"""Stop killing the processes when the job closes (used when handing Access over to the user)."""
|
|
58
|
+
if self._handle is not None:
|
|
59
|
+
self._set_kill_on_close(False)
|
|
60
|
+
|
|
61
|
+
def close(self) -> None:
|
|
62
|
+
"""Close the job handle (kills remaining processes unless :meth:`release` was called)."""
|
|
63
|
+
if self._handle is not None:
|
|
64
|
+
close_handle(self._handle)
|
|
65
|
+
self._handle = None
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# pyright: basic
|
|
2
|
+
"""Win32 process helpers: identity (PID + creation time + image), liveness, termination.
|
|
3
|
+
|
|
4
|
+
A process is identified by PID **and** creation time: PIDs are recycled by Windows, so a PID alone could
|
|
5
|
+
point at an unrelated process later. While PyAccessKit holds an open handle, the PID cannot be reused.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ctypes
|
|
11
|
+
import os
|
|
12
|
+
from ctypes import wintypes
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import PureWindowsPath
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import pywintypes
|
|
18
|
+
import win32api
|
|
19
|
+
import win32event
|
|
20
|
+
import win32process
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ProcessIdentity",
|
|
24
|
+
"access_process_ids",
|
|
25
|
+
"creation_time",
|
|
26
|
+
"identity_of",
|
|
27
|
+
"is_alive",
|
|
28
|
+
"open_process",
|
|
29
|
+
"query_image",
|
|
30
|
+
"wait_for_exit",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
SYNCHRONIZE = 0x0010_0000
|
|
34
|
+
PROCESS_TERMINATE = 0x0001
|
|
35
|
+
PROCESS_SET_QUOTA = 0x0100
|
|
36
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
37
|
+
OWNED_PROCESS_RIGHTS = (
|
|
38
|
+
SYNCHRONIZE | PROCESS_TERMINATE | PROCESS_SET_QUOTA | PROCESS_QUERY_LIMITED_INFORMATION
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
42
|
+
_QueryFullProcessImageNameW = _kernel32.QueryFullProcessImageNameW
|
|
43
|
+
_QueryFullProcessImageNameW.argtypes = [
|
|
44
|
+
wintypes.HANDLE,
|
|
45
|
+
wintypes.DWORD,
|
|
46
|
+
wintypes.LPWSTR,
|
|
47
|
+
ctypes.POINTER(wintypes.DWORD),
|
|
48
|
+
]
|
|
49
|
+
_QueryFullProcessImageNameW.restype = wintypes.BOOL
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ProcessIdentity:
|
|
54
|
+
"""A process pinned down by PID, creation time (seconds since the epoch) and image path."""
|
|
55
|
+
|
|
56
|
+
pid: int
|
|
57
|
+
creation_time: float
|
|
58
|
+
image: str
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def image_name(self) -> str:
|
|
62
|
+
"""The executable file name, e.g. ``MSACCESS.EXE``."""
|
|
63
|
+
return PureWindowsPath(self.image).name
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def open_process(pid: int, rights: int = OWNED_PROCESS_RIGHTS) -> Any:
|
|
67
|
+
"""Open a handle to ``pid`` (raises ``pywintypes.error`` if it no longer exists or access is denied)."""
|
|
68
|
+
return win32api.OpenProcess(rights, False, pid)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def query_image(handle: Any) -> str:
|
|
72
|
+
"""Full path of the process image (works with ``PROCESS_QUERY_LIMITED_INFORMATION``)."""
|
|
73
|
+
size = wintypes.DWORD(1024)
|
|
74
|
+
buffer = ctypes.create_unicode_buffer(size.value)
|
|
75
|
+
if not _QueryFullProcessImageNameW(int(handle), 0, buffer, ctypes.byref(size)):
|
|
76
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
77
|
+
return buffer.value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def creation_time(handle: Any) -> float:
|
|
81
|
+
"""Process creation time as a POSIX timestamp."""
|
|
82
|
+
created = win32process.GetProcessTimes(handle)["CreationTime"]
|
|
83
|
+
return float(created.timestamp())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def identity_of(handle: Any, pid: int) -> ProcessIdentity:
|
|
87
|
+
"""Build the identity of an open process."""
|
|
88
|
+
return ProcessIdentity(pid, creation_time(handle), query_image(handle))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def is_alive(handle: Any) -> bool:
|
|
92
|
+
"""Whether the process behind ``handle`` is still running."""
|
|
93
|
+
return win32event.WaitForSingleObject(handle, 0) == win32event.WAIT_TIMEOUT
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def wait_for_exit(handle: Any, timeout: float) -> bool:
|
|
97
|
+
"""Wait up to ``timeout`` seconds for the process to exit; returns whether it exited."""
|
|
98
|
+
milliseconds = max(0, int(timeout * 1000))
|
|
99
|
+
return win32event.WaitForSingleObject(handle, milliseconds) == win32event.WAIT_OBJECT_0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def current_identity() -> ProcessIdentity:
|
|
103
|
+
"""Identity of the running Python process."""
|
|
104
|
+
pid = os.getpid()
|
|
105
|
+
handle = open_process(pid, PROCESS_QUERY_LIMITED_INFORMATION)
|
|
106
|
+
try:
|
|
107
|
+
return identity_of(handle, pid)
|
|
108
|
+
finally:
|
|
109
|
+
win32api.CloseHandle(handle)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def identity_matches(expected: ProcessIdentity) -> bool:
|
|
113
|
+
"""Whether a live process still has exactly this identity (same PID *and* creation time)."""
|
|
114
|
+
try:
|
|
115
|
+
# SYNCHRONIZE is required by the liveness check (WaitForSingleObject), not just query rights.
|
|
116
|
+
handle = open_process(expected.pid, SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION)
|
|
117
|
+
except pywintypes.error:
|
|
118
|
+
return False
|
|
119
|
+
try:
|
|
120
|
+
if not is_alive(handle):
|
|
121
|
+
return False
|
|
122
|
+
return abs(creation_time(handle) - expected.creation_time) < 0.001
|
|
123
|
+
except (pywintypes.error, OSError):
|
|
124
|
+
return False
|
|
125
|
+
finally:
|
|
126
|
+
win32api.CloseHandle(handle)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def access_process_ids() -> set[int]:
|
|
130
|
+
"""PIDs of every running ``MSACCESS.EXE`` visible to this user (used for diagnostics and tests only)."""
|
|
131
|
+
pids: set[int] = set()
|
|
132
|
+
for pid in win32process.EnumProcesses():
|
|
133
|
+
if pid == 0:
|
|
134
|
+
continue
|
|
135
|
+
try:
|
|
136
|
+
handle = open_process(pid, PROCESS_QUERY_LIMITED_INFORMATION)
|
|
137
|
+
except pywintypes.error:
|
|
138
|
+
continue
|
|
139
|
+
try:
|
|
140
|
+
if PureWindowsPath(query_image(handle)).name.lower() == "msaccess.exe":
|
|
141
|
+
pids.add(pid)
|
|
142
|
+
except OSError:
|
|
143
|
+
pass
|
|
144
|
+
finally:
|
|
145
|
+
win32api.CloseHandle(handle)
|
|
146
|
+
return pids
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def terminate(handle: Any, exit_code: int = 1) -> None:
|
|
150
|
+
"""Terminate the process behind an **owned** handle."""
|
|
151
|
+
win32api.TerminateProcess(handle, exit_code)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def close_handle(handle: Any) -> None:
|
|
155
|
+
"""Close a Win32 handle, ignoring errors."""
|
|
156
|
+
try:
|
|
157
|
+
win32api.CloseHandle(handle)
|
|
158
|
+
except pywintypes.error:
|
|
159
|
+
pass
|