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,253 @@
|
|
|
1
|
+
# pyright: basic
|
|
2
|
+
"""The dialog watchdog: detects, reports and dismisses modal dialogs of *one owned* Access process.
|
|
3
|
+
|
|
4
|
+
A modal dialog inside a hidden Access blocks the current COM call forever. pywin32 releases the GIL while
|
|
5
|
+
it waits for the call, so this thread keeps running (spike S8). While a call is in flight it:
|
|
6
|
+
|
|
7
|
+
1. looks for visible top-level windows belonging to **our PID only** (a hidden Access has no other visible
|
|
8
|
+
windows; in visible mode only standard dialog windows count);
|
|
9
|
+
2. records title, text and buttons, then dismisses the dialog: ``WM_CLOSE`` first, then a click on
|
|
10
|
+
Cancel/No/OK (Yes/No boxes ignore ``WM_CLOSE``), and finally terminates our Access if nothing works;
|
|
11
|
+
3. terminates our Access when a call exceeds ``call_timeout``.
|
|
12
|
+
|
|
13
|
+
It implements :class:`~pyaccesskit._com.gateway.CallMonitor`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import contextlib
|
|
19
|
+
import logging
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import Callable, Iterator
|
|
23
|
+
|
|
24
|
+
import win32con
|
|
25
|
+
import win32gui
|
|
26
|
+
import win32process
|
|
27
|
+
|
|
28
|
+
from pyaccesskit._win.console import register_interrupt_callback, unregister_interrupt_callback
|
|
29
|
+
from pyaccesskit.enums import DialogPolicy
|
|
30
|
+
from pyaccesskit.errors import DialogInfo
|
|
31
|
+
|
|
32
|
+
__all__ = ["DialogWatchdog"]
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("pyaccesskit.process")
|
|
35
|
+
|
|
36
|
+
BM_CLICK = 0x00F5
|
|
37
|
+
DIALOG_CLASS = "#32770"
|
|
38
|
+
BUTTON_PREFERENCE = ("cancel", "no", "ok", "close", "abort", "ignore", "end")
|
|
39
|
+
INTERRUPT_GRACE = 0.5
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _plain(text: str) -> str:
|
|
43
|
+
return text.replace("&", "").strip().rstrip(".").lower()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DialogWatchdog:
|
|
47
|
+
"""Watches one Access process for modal dialogs and runaway calls.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
pid: PID of the owned Access process.
|
|
51
|
+
policy: What to do about dialogs (``OFF`` disables dialog handling; timeouts still apply).
|
|
52
|
+
call_timeout: Seconds a single operation may run before Access is terminated (``None`` = never).
|
|
53
|
+
visible: Whether the Access window is visible (then only standard dialogs are considered).
|
|
54
|
+
terminate: Callback that terminates the owned Access process (given a reason).
|
|
55
|
+
poll_interval: Seconds between window scans while a call is in flight.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
pid: int,
|
|
61
|
+
*,
|
|
62
|
+
policy: DialogPolicy,
|
|
63
|
+
call_timeout: float | None,
|
|
64
|
+
visible: bool,
|
|
65
|
+
terminate: Callable[[str], None],
|
|
66
|
+
poll_interval: float = 0.2,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.pid = pid
|
|
69
|
+
self.policy = policy
|
|
70
|
+
self._call_timeout = call_timeout
|
|
71
|
+
self._visible = visible
|
|
72
|
+
self._terminate = terminate
|
|
73
|
+
self._poll = poll_interval
|
|
74
|
+
self._lock = threading.Lock()
|
|
75
|
+
self._idle = threading.Condition(self._lock)
|
|
76
|
+
self._busy = 0
|
|
77
|
+
self._inflight_since: float | None = None
|
|
78
|
+
self._description = ""
|
|
79
|
+
self._events: list[DialogInfo] = []
|
|
80
|
+
self._handled: set[int] = set()
|
|
81
|
+
self._termination: tuple[str, str] | None = None
|
|
82
|
+
self._stop = threading.Event()
|
|
83
|
+
self._thread = threading.Thread(
|
|
84
|
+
target=self._run, name=f"pyaccesskit-watchdog-{pid}", daemon=True
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# ----------------------------------------------------------------------------------- lifecycle
|
|
88
|
+
def start(self) -> None:
|
|
89
|
+
"""Start the watchdog thread and hook Ctrl+C."""
|
|
90
|
+
self._thread.start()
|
|
91
|
+
register_interrupt_callback(self._on_interrupt)
|
|
92
|
+
|
|
93
|
+
def stop(self) -> None:
|
|
94
|
+
"""Stop the watchdog thread."""
|
|
95
|
+
unregister_interrupt_callback(self._on_interrupt)
|
|
96
|
+
self._stop.set()
|
|
97
|
+
if self._thread.is_alive() and self._thread is not threading.current_thread():
|
|
98
|
+
self._thread.join(2.0)
|
|
99
|
+
|
|
100
|
+
# --------------------------------------------------------------------------------- CallMonitor
|
|
101
|
+
def begin(self, description: str) -> None:
|
|
102
|
+
with self._lock:
|
|
103
|
+
self._inflight_since = time.monotonic()
|
|
104
|
+
self._description = description
|
|
105
|
+
self._events = []
|
|
106
|
+
|
|
107
|
+
def end(self) -> tuple[DialogInfo, ...]:
|
|
108
|
+
with self._idle:
|
|
109
|
+
# A dismissed dialog unblocks the COM call immediately; wait until its handling is recorded.
|
|
110
|
+
self._idle.wait_for(lambda: self._busy == 0, timeout=10.0)
|
|
111
|
+
self._inflight_since = None
|
|
112
|
+
events = tuple(self._events)
|
|
113
|
+
self._events = []
|
|
114
|
+
return events
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def termination(self) -> tuple[str, str] | None:
|
|
118
|
+
"""``(kind, reason)`` if the watchdog terminated Access; kind is timeout/dialog/interrupt."""
|
|
119
|
+
return self._termination
|
|
120
|
+
|
|
121
|
+
@contextlib.contextmanager
|
|
122
|
+
def watching(self, description: str) -> Iterator[None]:
|
|
123
|
+
"""Monitor an operation whose dialogs are handled but not reported (e.g. shutdown)."""
|
|
124
|
+
self.begin(description)
|
|
125
|
+
try:
|
|
126
|
+
yield
|
|
127
|
+
finally:
|
|
128
|
+
self.end()
|
|
129
|
+
|
|
130
|
+
# ------------------------------------------------------------------------------------- thread
|
|
131
|
+
def _run(self) -> None:
|
|
132
|
+
while not self._stop.wait(self._poll):
|
|
133
|
+
with self._lock:
|
|
134
|
+
since = self._inflight_since
|
|
135
|
+
description = self._description
|
|
136
|
+
if since is None:
|
|
137
|
+
continue
|
|
138
|
+
try:
|
|
139
|
+
if self.policy is not DialogPolicy.OFF:
|
|
140
|
+
for hwnd in self._dialog_windows():
|
|
141
|
+
self._handle(hwnd)
|
|
142
|
+
if self._call_timeout is not None and time.monotonic() - since > self._call_timeout:
|
|
143
|
+
self._kill(
|
|
144
|
+
"timeout", f"'{description}' did not finish within {self._call_timeout:g}s"
|
|
145
|
+
)
|
|
146
|
+
with self._lock:
|
|
147
|
+
self._inflight_since = None
|
|
148
|
+
except Exception:
|
|
149
|
+
logger.exception("dialog watchdog error")
|
|
150
|
+
|
|
151
|
+
def _dialog_windows(self) -> list[int]:
|
|
152
|
+
found: list[int] = []
|
|
153
|
+
|
|
154
|
+
def collect(hwnd: int, _extra: object) -> bool:
|
|
155
|
+
with contextlib.suppress(Exception):
|
|
156
|
+
if win32process.GetWindowThreadProcessId(hwnd)[1] != self.pid:
|
|
157
|
+
return True
|
|
158
|
+
if not win32gui.IsWindowVisible(hwnd):
|
|
159
|
+
return True
|
|
160
|
+
if self._visible and win32gui.GetClassName(hwnd) != DIALOG_CLASS:
|
|
161
|
+
return True
|
|
162
|
+
found.append(hwnd)
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
with contextlib.suppress(Exception):
|
|
166
|
+
win32gui.EnumWindows(collect, None)
|
|
167
|
+
return [hwnd for hwnd in found if hwnd not in self._handled]
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _children(hwnd: int) -> list[tuple[int, str, str]]:
|
|
171
|
+
children: list[tuple[int, str, str]] = []
|
|
172
|
+
|
|
173
|
+
def collect(child: int, _extra: object) -> bool:
|
|
174
|
+
with contextlib.suppress(Exception):
|
|
175
|
+
children.append(
|
|
176
|
+
(child, win32gui.GetClassName(child) or "", win32gui.GetWindowText(child) or "")
|
|
177
|
+
)
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
with contextlib.suppress(Exception):
|
|
181
|
+
win32gui.EnumChildWindows(hwnd, collect, None)
|
|
182
|
+
return children
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def _gone(hwnd: int, wait: float) -> bool:
|
|
186
|
+
deadline = time.monotonic() + wait
|
|
187
|
+
while time.monotonic() < deadline:
|
|
188
|
+
if not win32gui.IsWindow(hwnd) or not win32gui.IsWindowVisible(hwnd):
|
|
189
|
+
return True
|
|
190
|
+
time.sleep(0.05)
|
|
191
|
+
return not win32gui.IsWindow(hwnd) or not win32gui.IsWindowVisible(hwnd)
|
|
192
|
+
|
|
193
|
+
def _handle(self, hwnd: int) -> None:
|
|
194
|
+
self._handled.add(hwnd)
|
|
195
|
+
title = win32gui.GetWindowText(hwnd)
|
|
196
|
+
children = self._children(hwnd)
|
|
197
|
+
buttons = [(h, text) for h, cls, text in children if cls == "Button" and text]
|
|
198
|
+
texts = [text for _h, cls, text in children if cls != "Button" and text.strip()]
|
|
199
|
+
with contextlib.suppress(Exception):
|
|
200
|
+
message = win32gui.GetDlgItemText(hwnd, 0xFFFF)
|
|
201
|
+
if message and message not in texts:
|
|
202
|
+
texts.insert(0, message)
|
|
203
|
+
logger.warning("Access (PID %s) showed a dialog: %r %r", self.pid, title, texts)
|
|
204
|
+
|
|
205
|
+
info = DialogInfo(
|
|
206
|
+
title=title,
|
|
207
|
+
text=" ".join(" ".join(texts).split()),
|
|
208
|
+
buttons=tuple(text.replace("&", "") for _h, text in buttons),
|
|
209
|
+
action="dismissing",
|
|
210
|
+
)
|
|
211
|
+
# Record the dialog *before* dismissing it: dismissal unblocks the COM call, whose end() must see it.
|
|
212
|
+
with self._idle:
|
|
213
|
+
self._busy += 1
|
|
214
|
+
events = self._events
|
|
215
|
+
events.append(info)
|
|
216
|
+
position = len(events) - 1
|
|
217
|
+
action = "dismissal failed"
|
|
218
|
+
try:
|
|
219
|
+
action = self._dismiss(hwnd, buttons)
|
|
220
|
+
finally:
|
|
221
|
+
with self._idle:
|
|
222
|
+
if position < len(events) and events[position] is info:
|
|
223
|
+
events[position] = DialogInfo(info.title, info.text, info.buttons, action)
|
|
224
|
+
self._busy -= 1
|
|
225
|
+
self._idle.notify_all()
|
|
226
|
+
|
|
227
|
+
def _dismiss(self, hwnd: int, buttons: list[tuple[int, str]]) -> str:
|
|
228
|
+
with contextlib.suppress(Exception):
|
|
229
|
+
win32gui.PostMessage(hwnd, win32con.WM_CLOSE, 0, 0)
|
|
230
|
+
if self._gone(hwnd, 1.0):
|
|
231
|
+
return "closed"
|
|
232
|
+
by_text = {_plain(text): (button, text) for button, text in buttons}
|
|
233
|
+
for wanted in BUTTON_PREFERENCE:
|
|
234
|
+
if wanted in by_text:
|
|
235
|
+
button, text = by_text[wanted]
|
|
236
|
+
with contextlib.suppress(Exception):
|
|
237
|
+
win32gui.PostMessage(button, BM_CLICK, 0, 0)
|
|
238
|
+
if self._gone(hwnd, 1.5):
|
|
239
|
+
return f"clicked {text.replace('&', '')!r}"
|
|
240
|
+
self._kill("dialog", "a modal dialog could not be dismissed")
|
|
241
|
+
return "terminated Access"
|
|
242
|
+
|
|
243
|
+
def _kill(self, kind: str, reason: str) -> None:
|
|
244
|
+
if self._termination is None:
|
|
245
|
+
self._termination = (kind, reason)
|
|
246
|
+
logger.error("terminating owned Access process %s: %s", self.pid, reason)
|
|
247
|
+
self._terminate(reason)
|
|
248
|
+
|
|
249
|
+
def _on_interrupt(self) -> None:
|
|
250
|
+
with self._lock:
|
|
251
|
+
since = self._inflight_since
|
|
252
|
+
if since is not None and time.monotonic() - since > INTERRUPT_GRACE:
|
|
253
|
+
self._kill("interrupt", "interrupted by the user (Ctrl+C)")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Command-line interface (``pyaccesskit``). Heavy imports happen lazily inside :func:`main`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
"""Entry point for the ``pyaccesskit`` console script."""
|
|
8
|
+
from pyaccesskit.cli.app import run # noqa: PLC0415 - keep `import pyaccesskit` light
|
|
9
|
+
|
|
10
|
+
run()
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Console helpers shared by the commands: exit codes, error reporting, JSON output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Generator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.markup import escape
|
|
15
|
+
|
|
16
|
+
from pyaccesskit.errors import EnvironmentProblem, PyAccessKitError
|
|
17
|
+
|
|
18
|
+
EXIT_OK = 0
|
|
19
|
+
EXIT_ERROR = 1
|
|
20
|
+
EXIT_USAGE = 2
|
|
21
|
+
EXIT_ENVIRONMENT = 3
|
|
22
|
+
|
|
23
|
+
_SECRET_KEYS = re.compile(r"(?i)PWD|PASSWORD")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def stdout() -> Console:
|
|
27
|
+
"""A console for normal output (created per call so tests can capture it)."""
|
|
28
|
+
return Console(highlight=False, soft_wrap=False)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def stderr() -> Console:
|
|
32
|
+
"""A console for errors."""
|
|
33
|
+
return Console(stderr=True, highlight=False)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _segments(connect: str) -> list[str]:
|
|
37
|
+
"""Split a connection string at the ``;`` separators, honouring ODBC ``{...}`` values (``}}`` escapes)."""
|
|
38
|
+
segments: list[str] = []
|
|
39
|
+
current: list[str] = []
|
|
40
|
+
index, braced, at_value_start = 0, False, False
|
|
41
|
+
while index < len(connect):
|
|
42
|
+
char = connect[index]
|
|
43
|
+
if braced:
|
|
44
|
+
current.append(char)
|
|
45
|
+
if char == "}":
|
|
46
|
+
if connect[index + 1 : index + 2] == "}": # escaped closing brace
|
|
47
|
+
current.append("}")
|
|
48
|
+
index += 1
|
|
49
|
+
else:
|
|
50
|
+
braced = False
|
|
51
|
+
elif char == ";":
|
|
52
|
+
segments.append("".join(current))
|
|
53
|
+
current = []
|
|
54
|
+
at_value_start = False
|
|
55
|
+
else:
|
|
56
|
+
current.append(char)
|
|
57
|
+
if char == "{" and at_value_start:
|
|
58
|
+
braced = True
|
|
59
|
+
at_value_start = char == "=" and "=" not in "".join(current[:-1])
|
|
60
|
+
index += 1
|
|
61
|
+
segments.append("".join(current))
|
|
62
|
+
return segments
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def redact(connect: str | None) -> str | None:
|
|
66
|
+
"""Hide password values (``PWD=``/``Password=``, including ``{brace;quoted}`` ones) in a connection string."""
|
|
67
|
+
if connect is None:
|
|
68
|
+
return None
|
|
69
|
+
parts: list[str] = []
|
|
70
|
+
for segment in _segments(connect):
|
|
71
|
+
key, sep, _value = segment.partition("=")
|
|
72
|
+
if sep and _SECRET_KEYS.fullmatch(key.strip()):
|
|
73
|
+
parts.append(f"{key}=***")
|
|
74
|
+
else:
|
|
75
|
+
parts.append(segment)
|
|
76
|
+
return ";".join(parts)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def print_json(data: Any) -> None:
|
|
80
|
+
"""Write ``data`` as ASCII-safe JSON (works whatever the console or pipe encoding)."""
|
|
81
|
+
sys.stdout.write(json.dumps(data, indent=2, default=str) + "\n")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@contextmanager
|
|
85
|
+
def handle_errors() -> Generator[None, None, None]:
|
|
86
|
+
"""Report PyAccessKit errors without a traceback and exit with the documented code."""
|
|
87
|
+
try:
|
|
88
|
+
yield
|
|
89
|
+
except EnvironmentProblem as exc:
|
|
90
|
+
_report(exc)
|
|
91
|
+
raise typer.Exit(EXIT_ENVIRONMENT) from None
|
|
92
|
+
except PyAccessKitError as exc:
|
|
93
|
+
_report(exc)
|
|
94
|
+
raise typer.Exit(EXIT_ERROR) from None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _report(exc: PyAccessKitError) -> None:
|
|
98
|
+
console = stderr()
|
|
99
|
+
console.print(f"[bold red]error:[/] {escape(str(exc))}") # includes any engine diagnosis
|
|
100
|
+
for note in getattr(exc, "__notes__", ()):
|
|
101
|
+
console.print(f" [dim]{escape(str(note))}[/]")
|
pyaccesskit/cli/agent.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""``pyaccesskit guide`` and ``pyaccesskit schema``: material for AI coding agents (and humans)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Annotated, Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from pydantic import TypeAdapter
|
|
11
|
+
|
|
12
|
+
from pyaccesskit.cli._output import EXIT_OK, print_json
|
|
13
|
+
from pyaccesskit.forms import FormSpec
|
|
14
|
+
from pyaccesskit.schema import ColumnSpec, IndexSpec, QuerySpec, RelationshipSpec, TableSpec
|
|
15
|
+
|
|
16
|
+
GUIDE_RESOURCE = "AGENT_GUIDE.md"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SchemaKind(StrEnum):
|
|
20
|
+
"""Spec types whose JSON Schema can be printed."""
|
|
21
|
+
|
|
22
|
+
ALL = "all"
|
|
23
|
+
TABLE = "table"
|
|
24
|
+
COLUMN = "column"
|
|
25
|
+
INDEX = "index"
|
|
26
|
+
RELATIONSHIP = "relationship"
|
|
27
|
+
QUERY = "query"
|
|
28
|
+
FORM = "form"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_MODELS: dict[SchemaKind, Any] = {
|
|
32
|
+
SchemaKind.TABLE: TableSpec,
|
|
33
|
+
SchemaKind.COLUMN: TypeAdapter(ColumnSpec),
|
|
34
|
+
SchemaKind.INDEX: IndexSpec,
|
|
35
|
+
SchemaKind.RELATIONSHIP: RelationshipSpec,
|
|
36
|
+
SchemaKind.QUERY: QuerySpec,
|
|
37
|
+
SchemaKind.FORM: FormSpec,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def guide_text() -> str:
|
|
42
|
+
"""The agent guide shipped inside the package."""
|
|
43
|
+
from importlib.resources import files # noqa: PLC0415 - only needed here
|
|
44
|
+
|
|
45
|
+
return files("pyaccesskit").joinpath(GUIDE_RESOURCE).read_text(encoding="utf-8")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def guide_path() -> str:
|
|
49
|
+
"""Where the installed agent guide lives on disk."""
|
|
50
|
+
from importlib.resources import files # noqa: PLC0415
|
|
51
|
+
|
|
52
|
+
return str(files("pyaccesskit").joinpath(GUIDE_RESOURCE))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def json_schema(kind: SchemaKind) -> dict[str, Any]:
|
|
56
|
+
"""The JSON Schema of one spec type, or of all of them keyed by kind."""
|
|
57
|
+
if kind is SchemaKind.ALL:
|
|
58
|
+
return {k.value: json_schema(k) for k in _MODELS}
|
|
59
|
+
model = _MODELS[kind]
|
|
60
|
+
if isinstance(model, TypeAdapter):
|
|
61
|
+
return model.json_schema() # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
|
62
|
+
return model.model_json_schema()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def guide(
|
|
66
|
+
path: Annotated[
|
|
67
|
+
bool, typer.Option("--path", help="Print where the guide file is instead of its text.")
|
|
68
|
+
] = False,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Print the guide for AI agents that write Access software with PyAccessKit."""
|
|
71
|
+
if path:
|
|
72
|
+
typer.echo(guide_path())
|
|
73
|
+
else:
|
|
74
|
+
write_text(guide_text())
|
|
75
|
+
raise typer.Exit(EXIT_OK)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def write_text(text: str) -> None:
|
|
79
|
+
"""Write to stdout; when piped on Windows (ANSI code page) fall back to UTF-8 instead of failing."""
|
|
80
|
+
try:
|
|
81
|
+
text.encode(sys.stdout.encoding or "utf-8")
|
|
82
|
+
except (UnicodeEncodeError, LookupError):
|
|
83
|
+
buffer = getattr(sys.stdout, "buffer", None)
|
|
84
|
+
if buffer is not None:
|
|
85
|
+
sys.stdout.flush()
|
|
86
|
+
buffer.write(text.encode("utf-8"))
|
|
87
|
+
buffer.flush()
|
|
88
|
+
return
|
|
89
|
+
sys.stdout.write(text)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def schema(
|
|
93
|
+
kind: Annotated[
|
|
94
|
+
SchemaKind, typer.Argument(help="Which spec to describe.", show_default=True)
|
|
95
|
+
] = SchemaKind.ALL,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Print the JSON Schema of PyAccessKit specs (tables, columns, queries, forms...)."""
|
|
98
|
+
print_json(json_schema(kind))
|
|
99
|
+
raise typer.Exit(EXIT_OK)
|
pyaccesskit/cli/app.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""The ``pyaccesskit`` command-line application.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 success, 1 error, 2 usage error, 3 environment unusable (no engine, missing Access...).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from pyaccesskit._version import __version__
|
|
13
|
+
from pyaccesskit.cli.agent import guide, schema
|
|
14
|
+
from pyaccesskit.cli.cleanup import cleanup
|
|
15
|
+
from pyaccesskit.cli.doctor import doctor
|
|
16
|
+
from pyaccesskit.cli.inspection import inspect_database
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
name="pyaccesskit",
|
|
20
|
+
help="Create, inspect and maintain Microsoft Access databases from Python.",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
add_completion=False,
|
|
23
|
+
pretty_exceptions_enable=False,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _show_version(value: bool) -> None:
|
|
28
|
+
if value:
|
|
29
|
+
typer.echo(f"pyaccesskit {__version__}")
|
|
30
|
+
raise typer.Exit
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.callback()
|
|
34
|
+
def _root(
|
|
35
|
+
version: Annotated[
|
|
36
|
+
bool,
|
|
37
|
+
typer.Option(
|
|
38
|
+
"--version", callback=_show_version, is_eager=True, help="Show the version and exit."
|
|
39
|
+
),
|
|
40
|
+
] = False,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Create, inspect and maintain Microsoft Access databases from Python."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
app.command("doctor")(doctor)
|
|
46
|
+
app.command("inspect")(inspect_database)
|
|
47
|
+
app.command("cleanup")(cleanup)
|
|
48
|
+
app.command("guide")(guide)
|
|
49
|
+
app.command("schema")(schema)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run() -> None:
|
|
53
|
+
"""Run the CLI (used by the ``pyaccesskit`` console script and ``python -m pyaccesskit``)."""
|
|
54
|
+
app(prog_name="pyaccesskit")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""``pyaccesskit cleanup``: end orphaned Access processes that PyAccessKit started."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.markup import escape
|
|
9
|
+
|
|
10
|
+
from pyaccesskit.cli._output import EXIT_ERROR, EXIT_OK, handle_errors, print_json, stdout
|
|
11
|
+
from pyaccesskit.maintenance import reap_orphans
|
|
12
|
+
|
|
13
|
+
_DESCRIPTIONS = {
|
|
14
|
+
"terminated": "terminated (its Python owner had exited)",
|
|
15
|
+
"would-terminate": "would be terminated (its Python owner has exited)",
|
|
16
|
+
"already-exited": "already exited; ledger entry removed",
|
|
17
|
+
"owner-alive": "left alone: its Python owner is still running",
|
|
18
|
+
"failed": "could not be terminated",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cleanup(
|
|
23
|
+
dry_run: Annotated[
|
|
24
|
+
bool, typer.Option("--dry-run", help="Only report what would be terminated.")
|
|
25
|
+
] = False,
|
|
26
|
+
json_output: Annotated[bool, typer.Option("--json", help="Print the result as JSON.")] = False,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""End Access processes left behind by crashed PyAccessKit sessions.
|
|
29
|
+
|
|
30
|
+
Only processes recorded in PyAccessKit's ownership ledger are considered, and only when the Python
|
|
31
|
+
process that started them is gone. Access instances you or other tools started are never touched.
|
|
32
|
+
"""
|
|
33
|
+
with handle_errors():
|
|
34
|
+
results = reap_orphans(dry_run=dry_run)
|
|
35
|
+
if json_output:
|
|
36
|
+
print_json(
|
|
37
|
+
[
|
|
38
|
+
{
|
|
39
|
+
"pid": r.entry.pid,
|
|
40
|
+
"owner_pid": r.entry.owner_pid,
|
|
41
|
+
"database": r.entry.database,
|
|
42
|
+
"action": r.action,
|
|
43
|
+
}
|
|
44
|
+
for r in results
|
|
45
|
+
]
|
|
46
|
+
)
|
|
47
|
+
else:
|
|
48
|
+
console = stdout()
|
|
49
|
+
if not results:
|
|
50
|
+
console.print("No Access processes started by PyAccessKit are recorded. Nothing to do.")
|
|
51
|
+
for result in results:
|
|
52
|
+
where = f" ({escape(result.entry.database)})" if result.entry.database else ""
|
|
53
|
+
console.print(
|
|
54
|
+
f"PID {result.entry.pid}{where}: {_DESCRIPTIONS.get(result.action, result.action)}"
|
|
55
|
+
)
|
|
56
|
+
raise typer.Exit(EXIT_ERROR if any(r.action == "failed" for r in results) else EXIT_OK)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""``pyaccesskit doctor``: which engines work on this machine, and why not."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.markup import escape
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from pyaccesskit import diagnostics
|
|
12
|
+
from pyaccesskit.cli._output import EXIT_ENVIRONMENT, EXIT_OK, print_json, stdout
|
|
13
|
+
|
|
14
|
+
_ENGINE_LABELS = {"dao": "in-process DAO", "access": "Microsoft Access"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def doctor(
|
|
18
|
+
json_output: Annotated[bool, typer.Option("--json", help="Print the report as JSON.")] = False,
|
|
19
|
+
probe: Annotated[
|
|
20
|
+
bool,
|
|
21
|
+
typer.Option(
|
|
22
|
+
"--probe",
|
|
23
|
+
help="Also build a scratch database with each available engine (starts and closes an owned, "
|
|
24
|
+
"hidden Access process).",
|
|
25
|
+
),
|
|
26
|
+
] = False,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Check which engines PyAccessKit can use here (exit code 3 if none works)."""
|
|
29
|
+
report = diagnostics.diagnose(probe=probe)
|
|
30
|
+
if json_output:
|
|
31
|
+
print_json(report.to_dict())
|
|
32
|
+
else:
|
|
33
|
+
render(report)
|
|
34
|
+
raise typer.Exit(EXIT_OK if report.usable else EXIT_ENVIRONMENT)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def render(report: diagnostics.Diagnosis) -> None:
|
|
38
|
+
"""Print a human-readable report."""
|
|
39
|
+
console = stdout()
|
|
40
|
+
console.print(f"[bold]PyAccessKit {report.pyaccesskit_version}[/] environment report\n")
|
|
41
|
+
facts = Table.grid(padding=(0, 2))
|
|
42
|
+
facts.add_column(style="bold")
|
|
43
|
+
facts.add_column()
|
|
44
|
+
facts.add_row(
|
|
45
|
+
"Python",
|
|
46
|
+
escape(f"{report.python_version} ({report.python_bits}-bit) {report.python_executable}"),
|
|
47
|
+
)
|
|
48
|
+
facts.add_row("Windows", escape(report.operating_system))
|
|
49
|
+
facts.add_row("pywin32", report.pywin32_version or "[red]not installed[/]")
|
|
50
|
+
access = report.access
|
|
51
|
+
if access is None:
|
|
52
|
+
facts.add_row("Access", "[yellow]not registered[/]")
|
|
53
|
+
else:
|
|
54
|
+
edition = (
|
|
55
|
+
f"Click-to-Run: {', '.join(access.products) or 'unknown products'}"
|
|
56
|
+
if access.click_to_run
|
|
57
|
+
else "MSI"
|
|
58
|
+
)
|
|
59
|
+
facts.add_row(
|
|
60
|
+
"Access",
|
|
61
|
+
escape(f"{access.version or 'unknown version'} ({access.bits or '?'}-bit, {edition})"),
|
|
62
|
+
)
|
|
63
|
+
if access.executable:
|
|
64
|
+
facts.add_row("", escape(access.executable))
|
|
65
|
+
facts.add_row("ACE OLEDB", escape(", ".join(report.ace_oledb)) or "none for this bitness")
|
|
66
|
+
console.print(facts)
|
|
67
|
+
|
|
68
|
+
console.print("\n[bold]Engines[/]")
|
|
69
|
+
engines = Table.grid(padding=(0, 2))
|
|
70
|
+
for check in report.engines:
|
|
71
|
+
state = "[green]available[/] " if check.available else "[red]unavailable[/]"
|
|
72
|
+
engines.add_row(f" {_ENGINE_LABELS[check.engine]}", state, escape(check.detail))
|
|
73
|
+
console.print(engines)
|
|
74
|
+
auto = _ENGINE_LABELS[report.auto_engine] if report.auto_engine else "[red]nothing usable[/]"
|
|
75
|
+
console.print(f" engine='auto' selects: [bold]{auto}[/]")
|
|
76
|
+
|
|
77
|
+
if report.probes:
|
|
78
|
+
console.print("\n[bold]Probes[/]")
|
|
79
|
+
for result in report.probes:
|
|
80
|
+
state = "[green]passed[/]" if result.ok else "[red]failed[/]"
|
|
81
|
+
console.print(
|
|
82
|
+
f" {_ENGINE_LABELS[result.engine]}: {state} in {result.seconds:.1f}s - "
|
|
83
|
+
f"{escape(result.detail)}"
|
|
84
|
+
)
|
|
85
|
+
if report.owned_processes:
|
|
86
|
+
console.print("\n[bold]Access processes started by PyAccessKit[/]")
|
|
87
|
+
for entry in report.owned_processes:
|
|
88
|
+
console.print(
|
|
89
|
+
f" PID {entry.pid} (owner PID {entry.owner_pid}): {entry.status}"
|
|
90
|
+
+ (f" - {escape(entry.database)}" if entry.database else "")
|
|
91
|
+
)
|
|
92
|
+
if report.notes:
|
|
93
|
+
console.print("\n[bold]Notes[/]")
|
|
94
|
+
for note in report.notes:
|
|
95
|
+
console.print(f" - {escape(note)}")
|
|
96
|
+
if report.problems:
|
|
97
|
+
console.print("\n[bold red]Problems[/]")
|
|
98
|
+
for problem in report.problems:
|
|
99
|
+
console.print(f" - {escape(problem)}")
|
|
100
|
+
verdict = "[green]ready[/]" if report.usable else "[red]not usable[/]"
|
|
101
|
+
console.print(f"\nPyAccessKit is {verdict} on this machine.")
|