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.
Files changed (86) hide show
  1. pyaccesskit/AGENT_GUIDE.md +455 -0
  2. pyaccesskit/__init__.py +167 -0
  3. pyaccesskit/__main__.py +6 -0
  4. pyaccesskit/_backends/__init__.py +0 -0
  5. pyaccesskit/_backends/access/__init__.py +1 -0
  6. pyaccesskit/_backends/access/design.py +415 -0
  7. pyaccesskit/_backends/dao/__init__.py +1 -0
  8. pyaccesskit/_backends/dao/profile.py +40 -0
  9. pyaccesskit/_backends/dao/schema.py +805 -0
  10. pyaccesskit/_backends/dao/typemap.py +390 -0
  11. pyaccesskit/_backends/fake/__init__.py +3 -0
  12. pyaccesskit/_backends/fake/backend.py +680 -0
  13. pyaccesskit/_backends/protocols.py +339 -0
  14. pyaccesskit/_com/__init__.py +1 -0
  15. pyaccesskit/_com/constants.py +394 -0
  16. pyaccesskit/_com/dispatch.py +50 -0
  17. pyaccesskit/_com/errors.py +184 -0
  18. pyaccesskit/_com/gateway.py +199 -0
  19. pyaccesskit/_com/raw.py +164 -0
  20. pyaccesskit/_com/runtime.py +39 -0
  21. pyaccesskit/_com/variants.py +72 -0
  22. pyaccesskit/_engines/__init__.py +48 -0
  23. pyaccesskit/_engines/access.py +300 -0
  24. pyaccesskit/_engines/inproc.py +148 -0
  25. pyaccesskit/_engines/probe.py +231 -0
  26. pyaccesskit/_ledger.py +158 -0
  27. pyaccesskit/_ops/__init__.py +0 -0
  28. pyaccesskit/_ops/design.py +127 -0
  29. pyaccesskit/_ops/schema.py +471 -0
  30. pyaccesskit/_session/__init__.py +1 -0
  31. pyaccesskit/_session/protocols.py +78 -0
  32. pyaccesskit/_session/session.py +354 -0
  33. pyaccesskit/_text/__init__.py +0 -0
  34. pyaccesskit/_text/codec.py +114 -0
  35. pyaccesskit/_version.py +3 -0
  36. pyaccesskit/_win/__init__.py +1 -0
  37. pyaccesskit/_win/access_process.py +348 -0
  38. pyaccesskit/_win/console.py +56 -0
  39. pyaccesskit/_win/inspector.py +53 -0
  40. pyaccesskit/_win/job.py +65 -0
  41. pyaccesskit/_win/processes.py +159 -0
  42. pyaccesskit/_win/watchdog.py +253 -0
  43. pyaccesskit/cli/__init__.py +10 -0
  44. pyaccesskit/cli/_output.py +101 -0
  45. pyaccesskit/cli/agent.py +99 -0
  46. pyaccesskit/cli/app.py +54 -0
  47. pyaccesskit/cli/cleanup.py +56 -0
  48. pyaccesskit/cli/doctor.py +101 -0
  49. pyaccesskit/cli/inspection.py +223 -0
  50. pyaccesskit/database.py +296 -0
  51. pyaccesskit/diagnostics.py +319 -0
  52. pyaccesskit/enums.py +258 -0
  53. pyaccesskit/errors.py +407 -0
  54. pyaccesskit/forms/__init__.py +45 -0
  55. pyaccesskit/forms/builder.py +295 -0
  56. pyaccesskit/forms/collection.py +117 -0
  57. pyaccesskit/forms/controls.py +157 -0
  58. pyaccesskit/forms/layout.py +300 -0
  59. pyaccesskit/forms/spec.py +169 -0
  60. pyaccesskit/forms/vba.py +138 -0
  61. pyaccesskit/maintenance.py +32 -0
  62. pyaccesskit/modules.py +101 -0
  63. pyaccesskit/objects.py +81 -0
  64. pyaccesskit/options.py +40 -0
  65. pyaccesskit/properties.py +74 -0
  66. pyaccesskit/py.typed +0 -0
  67. pyaccesskit/queries.py +190 -0
  68. pyaccesskit/relationships.py +143 -0
  69. pyaccesskit/schema/__init__.py +73 -0
  70. pyaccesskit/schema/_base.py +55 -0
  71. pyaccesskit/schema/_reserved_words.py +55 -0
  72. pyaccesskit/schema/columns.py +609 -0
  73. pyaccesskit/schema/compat.py +57 -0
  74. pyaccesskit/schema/expressions.py +162 -0
  75. pyaccesskit/schema/indexes.py +114 -0
  76. pyaccesskit/schema/names.py +122 -0
  77. pyaccesskit/schema/queries.py +192 -0
  78. pyaccesskit/schema/relationships.py +132 -0
  79. pyaccesskit/schema/tables.py +178 -0
  80. pyaccesskit/tables.py +333 -0
  81. pyaccesskit/units.py +301 -0
  82. pyaccesskit-0.1.0.dist-info/METADATA +201 -0
  83. pyaccesskit-0.1.0.dist-info/RECORD +86 -0
  84. pyaccesskit-0.1.0.dist-info/WHEEL +4 -0
  85. pyaccesskit-0.1.0.dist-info/entry_points.txt +2 -0
  86. 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)
@@ -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