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,223 @@
1
+ """``pyaccesskit inspect``: describe a database without changing it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated, Any
7
+
8
+ import typer
9
+ from rich.markup import escape
10
+ from rich.table import Table
11
+
12
+ from pyaccesskit.cli._output import EXIT_OK, handle_errors, print_json, redact, stdout
13
+ from pyaccesskit.database import AccessDatabase
14
+ from pyaccesskit.enums import DataType, Engine, ObjectKind
15
+ from pyaccesskit.errors import PyAccessKitError
16
+ from pyaccesskit.schema import (
17
+ AutoNumberColumn,
18
+ ColumnBase,
19
+ DecimalColumn,
20
+ Expr,
21
+ LongTextColumn,
22
+ NumberColumn,
23
+ TableSpec,
24
+ TextColumn,
25
+ render_literal,
26
+ )
27
+ from pyaccesskit.schema.columns import UnsupportedColumn
28
+
29
+ OTHER_OBJECTS = (ObjectKind.FORM, ObjectKind.REPORT, ObjectKind.MACRO, ObjectKind.MODULE)
30
+ _TYPE_NAMES = {
31
+ DataType.TEXT: "Short Text",
32
+ DataType.LONG_TEXT: "Long Text",
33
+ DataType.NUMBER: "Number",
34
+ DataType.DECIMAL: "Decimal",
35
+ DataType.CURRENCY: "Currency",
36
+ DataType.AUTONUMBER: "AutoNumber",
37
+ DataType.DATE_TIME: "Date/Time",
38
+ DataType.YES_NO: "Yes/No",
39
+ DataType.HYPERLINK: "Hyperlink",
40
+ DataType.OLE_OBJECT: "OLE Object",
41
+ }
42
+
43
+
44
+ def inspect_database(
45
+ database: Annotated[Path, typer.Argument(help="The .accdb or .mdb file.", show_default=False)],
46
+ json_output: Annotated[bool, typer.Option("--json", help="Print the report as JSON.")] = False,
47
+ counts: Annotated[
48
+ bool, typer.Option("--counts", help="Also count the rows of every table.")
49
+ ] = False,
50
+ password: Annotated[
51
+ str | None,
52
+ typer.Option(
53
+ "--password",
54
+ envvar="PYACCESSKIT_PASSWORD",
55
+ help="Database password (prefer the PYACCESSKIT_PASSWORD environment variable).",
56
+ show_default=False,
57
+ ),
58
+ ] = None,
59
+ engine: Annotated[Engine, typer.Option(help="Engine to use.")] = Engine.AUTO,
60
+ ) -> None:
61
+ """Describe tables, relationships, queries and other objects (read-only; no startup code runs)."""
62
+ with handle_errors():
63
+ db = AccessDatabase.open(database, readonly=True, password=password, engine=engine)
64
+ with db:
65
+ report = describe(db, counts=counts)
66
+ if json_output:
67
+ print_json(report)
68
+ else:
69
+ render(report)
70
+ raise typer.Exit(EXIT_OK)
71
+
72
+
73
+ def describe(db: AccessDatabase, *, counts: bool = False) -> dict[str, Any]:
74
+ """Everything ``inspect`` reports, as JSON-ready data. Problems with single objects become warnings.
75
+
76
+ Passwords in linked-table and pass-through connection strings are redacted.
77
+ """
78
+ warnings: list[str] = []
79
+ tables: list[dict[str, Any]] = []
80
+ for table in db.tables:
81
+ entry: dict[str, Any] = {"name": table.name, "linked": None, "rows": None, "spec": None}
82
+ try:
83
+ if table.is_linked:
84
+ entry["linked"] = {
85
+ "connect": redact(table.connect),
86
+ "source_table": table.source_table,
87
+ }
88
+ entry["spec"] = table.to_spec().model_dump(mode="json")
89
+ if counts:
90
+ entry["rows"] = table.record_count()
91
+ except PyAccessKitError as exc:
92
+ warnings.append(f"table {table.name!r}: {exc}")
93
+ tables.append(entry)
94
+
95
+ queries: list[dict[str, Any]] = []
96
+ for query in db.queries:
97
+ try:
98
+ spec = query.to_spec().model_dump(mode="json")
99
+ passthrough = spec.get("pass_through")
100
+ if isinstance(passthrough, dict):
101
+ passthrough["connect"] = redact(str(passthrough.get("connect"))) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
102
+ queries.append({"kind": query.kind.value, **spec})
103
+ except PyAccessKitError as exc:
104
+ warnings.append(f"query {query.name!r}: {exc}")
105
+
106
+ return {
107
+ "path": str(db.path),
108
+ "format_version": db.format_version,
109
+ "transport": db.transport.value,
110
+ "tables": tables,
111
+ "relationships": [spec.model_dump(mode="json") for spec in db.relationships.specs()],
112
+ "queries": queries,
113
+ **{f"{kind.value}s": db.objects.names(kind) for kind in OTHER_OBJECTS},
114
+ "warnings": warnings,
115
+ }
116
+
117
+
118
+ # ------------------------------------------------------------------------------------ rendering
119
+ def column_type(column: ColumnBase) -> str:
120
+ """How the Access table designer would describe a column's type."""
121
+ if isinstance(column, UnsupportedColumn):
122
+ return f"{column.detail} (unsupported)"
123
+ name = _TYPE_NAMES.get(column.data_type, column.data_type.value)
124
+ if isinstance(column, TextColumn):
125
+ return f"{name}({column.length})"
126
+ if isinstance(column, NumberColumn):
127
+ return f"{name} ({column.size.value.replace('_', ' ')})"
128
+ if isinstance(column, DecimalColumn):
129
+ return f"{name}({column.precision},{column.scale})"
130
+ if isinstance(column, AutoNumberColumn) and column.replication_id:
131
+ return f"{name} (replication ID)"
132
+ if isinstance(column, LongTextColumn):
133
+ flags = [
134
+ flag
135
+ for flag, on in (("rich text", column.rich_text), ("append only", column.append_only))
136
+ if on
137
+ ]
138
+ return f"{name} ({', '.join(flags)})" if flags else name
139
+ return name
140
+
141
+
142
+ def _default_text(column: ColumnBase) -> str:
143
+ default = column.default
144
+ if default is None:
145
+ return ""
146
+ return default.expr if isinstance(default, Expr) else render_literal(default)
147
+
148
+
149
+ def _render_table(entry: dict[str, Any]) -> None:
150
+ console = stdout()
151
+ details: list[str] = []
152
+ if entry["linked"]:
153
+ link = entry["linked"]
154
+ details.append(f"linked to {link['source_table']} ({link['connect']})")
155
+ if entry["rows"] is not None:
156
+ details.append(f"{entry['rows']} row{'' if entry['rows'] == 1 else 's'}")
157
+ suffix = f" [dim]{escape('; '.join(details))}[/]" if details else ""
158
+ console.print(f"\n[bold cyan]Table {escape(entry['name'])}[/]{suffix}")
159
+ if entry["spec"] is None:
160
+ console.print(" [yellow]could not be read (see warnings)[/]")
161
+ return
162
+ spec = TableSpec.model_validate(entry["spec"])
163
+ grid = Table(box=None, padding=(0, 2), show_edge=False, header_style="dim")
164
+ for heading in ("Field", "Type", "Required", "Default", "Description"):
165
+ grid.add_column(heading)
166
+ for column in spec.columns:
167
+ grid.add_row(
168
+ escape(column.name),
169
+ escape(column_type(column)),
170
+ "yes" if column.required else "",
171
+ escape(_default_text(column)),
172
+ escape(column.description or ""),
173
+ )
174
+ console.print(grid)
175
+ for index in spec.indexes:
176
+ kind = "primary key" if index.primary else ("unique" if index.unique else "")
177
+ columns = ", ".join(f.name + (" DESC" if f.descending else "") for f in index.fields)
178
+ console.print(
179
+ f" [dim]index[/] {escape(index.name)} ({escape(columns)})"
180
+ + (f" [dim]{kind}[/]" if kind else "")
181
+ )
182
+
183
+
184
+ def render(report: dict[str, Any]) -> None:
185
+ """Print a human-readable report."""
186
+ console = stdout()
187
+ console.print(
188
+ f"[bold]{escape(report['path'])}[/] "
189
+ f"[dim](format {report['format_version']}, via {report['transport']})[/]"
190
+ )
191
+ for entry in report["tables"]:
192
+ _render_table(entry)
193
+ if report["relationships"]:
194
+ console.print("\n[bold cyan]Relationships[/]")
195
+ for rel in report["relationships"]:
196
+ rules = [
197
+ rule
198
+ for rule, on in (
199
+ ("enforced", rel["enforce_integrity"]),
200
+ ("cascade update", rel["cascade_update"]),
201
+ ("cascade delete", rel["cascade_delete"]),
202
+ ("one-to-one", rel["one_to_one"]),
203
+ )
204
+ if on
205
+ ]
206
+ primary = f"{rel['primary_table']}({', '.join(rel['primary_columns'])})"
207
+ foreign = f"{rel['foreign_table']}({', '.join(rel['foreign_columns'])})"
208
+ console.print(
209
+ f" {escape(rel['name'] or '')}: {escape(primary)} -> {escape(foreign)}"
210
+ + (f" [dim]{', '.join(rules)}[/]" if rules else "")
211
+ )
212
+ if report["queries"]:
213
+ console.print("\n[bold cyan]Queries[/]")
214
+ for query in report["queries"]:
215
+ lines = query["sql"].strip().splitlines()
216
+ first = lines[0][:100] if lines else ""
217
+ console.print(f" {escape(query['name'])} [dim]({query['kind']})[/] {escape(first)}")
218
+ for kind in OTHER_OBJECTS:
219
+ names = report[f"{kind.value}s"]
220
+ if names:
221
+ console.print(f"\n[bold cyan]{kind.value.title()}s[/] {escape(', '.join(names))}")
222
+ for warning in report["warnings"]:
223
+ console.print(f"\n[yellow]warning:[/] {escape(warning)}")
@@ -0,0 +1,296 @@
1
+ """The :class:`AccessDatabase` facade — the entry point of PyAccessKit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import threading
7
+ import uuid
8
+ import warnings
9
+ import weakref
10
+ from collections.abc import Mapping
11
+ from pathlib import Path
12
+ from types import TracebackType
13
+ from typing import Any, Self
14
+
15
+ from pyaccesskit._backends.protocols import PropertyTarget
16
+ from pyaccesskit._session.session import Session
17
+ from pyaccesskit.enums import Engine, Transport
18
+ from pyaccesskit.errors import (
19
+ DatabaseExistsError,
20
+ DatabaseLockedError,
21
+ DatabaseNotFoundError,
22
+ SpecError,
23
+ )
24
+ from pyaccesskit.forms.collection import FormCollection
25
+ from pyaccesskit.modules import ModuleCollection
26
+ from pyaccesskit.objects import AccessObjects
27
+ from pyaccesskit.options import SessionOptions
28
+ from pyaccesskit.properties import PropertyBag
29
+ from pyaccesskit.queries import QueryCollection
30
+ from pyaccesskit.relationships import RelationshipCollection
31
+ from pyaccesskit.tables import TableCollection
32
+
33
+ __all__ = ["AccessDatabase", "RawAccess"]
34
+
35
+ MAX_PATH_LENGTH = 255
36
+ _EXTENSIONS = (".accdb", ".mdb", ".accde", ".mde")
37
+
38
+
39
+ def _resolve(path: str | os.PathLike[str]) -> Path:
40
+ resolved = Path(path).expanduser().resolve()
41
+ if resolved.suffix.lower() not in _EXTENSIONS:
42
+ raise SpecError(
43
+ f"{resolved.name!r} is not an Access database file name (expected .accdb or .mdb)"
44
+ )
45
+ if len(str(resolved)) > MAX_PATH_LENGTH:
46
+ raise SpecError(
47
+ f"database paths are limited to {MAX_PATH_LENGTH} characters by DAO: {resolved}"
48
+ )
49
+ return resolved
50
+
51
+
52
+ def _abandon(session: Session) -> None:
53
+ """Finalizer for sessions that were never closed (garbage collection or interpreter exit)."""
54
+ if session.state != "open":
55
+ return
56
+ warnings.warn(
57
+ f"PyAccessKit session for {session.target} was not closed; use 'with AccessDatabase...' or call "
58
+ "close(). Newly created databases are discarded.",
59
+ ResourceWarning,
60
+ stacklevel=2,
61
+ )
62
+ if threading.get_ident() == session.thread_id:
63
+ try:
64
+ session.close(error=RuntimeError("session abandoned"))
65
+ except BaseException:
66
+ session.terminate()
67
+ else:
68
+ session.terminate()
69
+
70
+
71
+ class RawAccess:
72
+ """Escape hatch to the underlying COM objects.
73
+
74
+ Every object returned is a revocable proxy: it stops working (and releases its COM reference) when the
75
+ session closes or switches engines. Save your own design changes; PyAccessKit quits Access without
76
+ saving objects it did not manage.
77
+ """
78
+
79
+ def __init__(self, session: Session) -> None:
80
+ self._session = session
81
+
82
+ @property
83
+ def dao(self) -> Any:
84
+ """The DAO ``Database`` object."""
85
+ return self._session.raw("dao")
86
+
87
+ @property
88
+ def dbengine(self) -> Any:
89
+ """The DAO ``DBEngine`` object."""
90
+ return self._session.raw("dbengine")
91
+
92
+ @property
93
+ def access(self) -> Any:
94
+ """The ``Access.Application`` object (switches the session to a design session if needed)."""
95
+ return self._session.raw("access")
96
+
97
+
98
+ class AccessDatabase:
99
+ """An open Microsoft Access database.
100
+
101
+ Create or open one with :meth:`create` / :meth:`open`, ideally as a context manager so that everything
102
+ (including the Access process) is cleaned up even if an exception occurs::
103
+
104
+ with AccessDatabase.create("crm.accdb") as db:
105
+ db.tables.create("Customers", columns=[...])
106
+
107
+ Attributes:
108
+ tables: The tables (:class:`~pyaccesskit.tables.TableCollection`).
109
+ relationships: The relationships.
110
+ queries: The saved queries.
111
+ forms: The forms (building forms needs Microsoft Access).
112
+ modules: The VBA modules (needs Microsoft Access).
113
+ objects: Text import/export and management of forms, reports, macros and modules.
114
+ properties: The database's DAO properties (``AppTitle``, ``StartUpForm``...).
115
+ raw: Escape hatch to the underlying COM objects.
116
+ """
117
+
118
+ def __init__(self, session: Session) -> None:
119
+ self._session = session
120
+ self.tables = TableCollection(session)
121
+ self.relationships = RelationshipCollection(session)
122
+ self.queries = QueryCollection(session)
123
+ self.forms = FormCollection(session)
124
+ self.modules = ModuleCollection(session)
125
+ self.objects = AccessObjects(session)
126
+ self.properties = PropertyBag(session, PropertyTarget.database())
127
+ self.raw = RawAccess(session)
128
+ self._finalizer = weakref.finalize(self, _abandon, session)
129
+
130
+ # ----------------------------------------------------------------------------- construction
131
+ @classmethod
132
+ def create(
133
+ cls,
134
+ path: str | os.PathLike[str],
135
+ *,
136
+ overwrite: bool = False,
137
+ atomic: bool = True,
138
+ engine: Engine | str = Engine.AUTO,
139
+ options: SessionOptions | None = None,
140
+ ) -> AccessDatabase:
141
+ """Create a new ``.accdb`` database.
142
+
143
+ Args:
144
+ path: Where to create the database.
145
+ overwrite: Replace an existing file at ``path``.
146
+ atomic: Build in a temporary sibling file and move it into place only when the session closes
147
+ without an error; on error the target is left untouched.
148
+ engine: ``"auto"`` (default), ``"dao"`` or ``"access"`` — see :class:`~pyaccesskit.enums.Engine`.
149
+ options: Advanced session options.
150
+
151
+ Raises:
152
+ DatabaseExistsError: If ``path`` exists and ``overwrite`` is false.
153
+ """
154
+ target = _resolve(path)
155
+ if target.suffix.lower() != ".accdb":
156
+ raise SpecError("PyAccessKit creates .accdb databases (Access 2007 and later format)")
157
+ if not target.parent.is_dir():
158
+ raise DatabaseNotFoundError(f"folder {target.parent} does not exist", path=target)
159
+ if target.exists() and not overwrite:
160
+ raise DatabaseExistsError(
161
+ f"{target} already exists (pass overwrite=True to replace it)", path=target
162
+ )
163
+ if atomic:
164
+ working = target.with_name(f".{target.stem}.pak-{uuid.uuid4().hex[:8]}{target.suffix}")
165
+ else:
166
+ working = target
167
+ if target.exists():
168
+ try:
169
+ target.unlink()
170
+ except PermissionError as exc:
171
+ raise DatabaseLockedError(
172
+ f"cannot overwrite {target}: it is in use", path=target
173
+ ) from exc
174
+ session = Session(
175
+ target=target,
176
+ working=working,
177
+ create=True,
178
+ readonly=False,
179
+ exclusive=True,
180
+ password=None,
181
+ engine=Engine(engine),
182
+ options=options or SessionOptions(),
183
+ overwrite=overwrite,
184
+ )
185
+ return cls(session)
186
+
187
+ @classmethod
188
+ def open(
189
+ cls,
190
+ path: str | os.PathLike[str],
191
+ *,
192
+ readonly: bool = False,
193
+ exclusive: bool | None = None,
194
+ password: str | None = None,
195
+ engine: Engine | str = Engine.AUTO,
196
+ options: SessionOptions | None = None,
197
+ ) -> AccessDatabase:
198
+ """Open an existing database.
199
+
200
+ Args:
201
+ path: The database file.
202
+ readonly: Open read-only (also allows opening a database that is open in Access elsewhere).
203
+ exclusive: Open exclusively (default: ``True`` unless ``readonly``). Design changes need it.
204
+ password: Database password, if any.
205
+ engine: ``"auto"`` (default), ``"dao"`` or ``"access"``.
206
+ options: Advanced session options.
207
+
208
+ Raises:
209
+ DatabaseNotFoundError: If the file does not exist.
210
+ DatabaseLockedError: If it is in use elsewhere.
211
+ """
212
+ target = _resolve(path)
213
+ if not target.is_file():
214
+ raise DatabaseNotFoundError(f"{target} does not exist", path=target)
215
+ session = Session(
216
+ target=target,
217
+ working=target,
218
+ create=False,
219
+ readonly=readonly,
220
+ exclusive=(not readonly) if exclusive is None else exclusive,
221
+ password=password,
222
+ engine=Engine(engine),
223
+ options=options or SessionOptions(),
224
+ )
225
+ return cls(session)
226
+
227
+ # ----------------------------------------------------------------------------------- facts
228
+ @property
229
+ def path(self) -> Path:
230
+ """The database file (for atomic creation: where it will be when the session closes)."""
231
+ return self._session.target
232
+
233
+ @property
234
+ def transport(self) -> Transport:
235
+ """How the database is currently reached (in-process DAO, Access-hosted DAO, design session)."""
236
+ return self._session.transport
237
+
238
+ @property
239
+ def format_version(self) -> str:
240
+ """The database engine format (DAO ``Database.Version``): ``"12.0"`` for ``.accdb``, ``"4.0"`` for Jet 4."""
241
+ return self._session.schema().database_info().version
242
+
243
+ @property
244
+ def readonly(self) -> bool:
245
+ """Whether the database was opened read-only."""
246
+ return self._session.readonly
247
+
248
+ @property
249
+ def is_open(self) -> bool:
250
+ """Whether the session is still open."""
251
+ return self._session.state == "open"
252
+
253
+ @property
254
+ def access_pid(self) -> int | None:
255
+ """PID of the owned ``MSACCESS.EXE`` (``None`` when no Access process is used)."""
256
+ return getattr(self._session.engine_handle, "pid", None)
257
+
258
+ # ------------------------------------------------------------------------------------- data
259
+ def execute(self, sql: str, params: Mapping[str, Any] | None = None) -> int:
260
+ """Run an action statement (INSERT/UPDATE/DELETE/DDL); returns the number of affected rows.
261
+
262
+ ``params`` binds parameters by name (``[Name]`` references in the SQL); values are never
263
+ interpolated into the SQL text.
264
+ """
265
+ self._session.check_writable("run SQL")
266
+ return self._session.schema().execute(sql, params)
267
+
268
+ def fetch_all(
269
+ self, sql: str, params: Mapping[str, Any] | None = None, *, limit: int | None = None
270
+ ) -> list[dict[str, Any]]:
271
+ """Run a SELECT and return its rows as dictionaries."""
272
+ return self._session.schema().fetch(sql, params, limit=limit).as_dicts()
273
+
274
+ # ------------------------------------------------------------------------------------ close
275
+ def close(self) -> None:
276
+ """Close the session and release everything (idempotent; only from the thread that opened it)."""
277
+ self._session.check_thread()
278
+ self._finalizer.detach()
279
+ self._session.close()
280
+
281
+ def __enter__(self) -> Self:
282
+ return self
283
+
284
+ def __exit__(
285
+ self,
286
+ exc_type: type[BaseException] | None,
287
+ exc: BaseException | None,
288
+ tb: TracebackType | None,
289
+ ) -> None:
290
+ self._session.check_thread()
291
+ self._finalizer.detach()
292
+ self._session.close(error=exc)
293
+
294
+ def __repr__(self) -> str:
295
+ state = self.transport.value if self.is_open else "closed"
296
+ return f"<AccessDatabase {str(self.path)!r} ({state})>"