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,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))}[/]")
@@ -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.")