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,300 @@
1
+ # pyright: basic
2
+ """Engine: Microsoft Access automation (works with any Python/Office bitness combination).
3
+
4
+ Schema work uses **Access-hosted DAO** (``app.DBEngine.OpenDatabase``): the database is *not* opened in the
5
+ Access UI, so AutoExec macros and startup forms never run. The first design feature (forms, modules, text
6
+ import/export, Decimal DDL) upgrades the engine to a **design session** (``OpenCurrentDatabase``).
7
+ New databases are created with ``NewCurrentDatabase`` and start in design mode (native Access defaults).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import os
14
+ from collections.abc import Callable
15
+ from pathlib import Path
16
+ from typing import Any, Literal
17
+
18
+ import pywintypes
19
+
20
+ from pyaccesskit._backends.access.design import AccessDesignBackend
21
+ from pyaccesskit._backends.dao.schema import DaoSchemaBackend
22
+ from pyaccesskit._backends.protocols import DesignBackend, SchemaBackend
23
+ from pyaccesskit._com import constants as c
24
+ from pyaccesskit._com.errors import OpContext, details_from, translate
25
+ from pyaccesskit._com.gateway import call, get, put
26
+ from pyaccesskit._win.access_process import AccessLaunchOptions, AccessProcess
27
+ from pyaccesskit.enums import Transport
28
+ from pyaccesskit.errors import AccessRuntimeOnlyError, CapabilityError, DatabaseLockedError
29
+
30
+ __all__ = ["AccessEngine"]
31
+
32
+ _STARTUP_FORM = "StartUpForm"
33
+ _PROPERTY_NOT_FOUND = 3270
34
+ _DB_TEXT = int(c.DataTypeEnum.dbText)
35
+
36
+
37
+ def _same_path(left: str, right: Path) -> bool:
38
+ return os.path.normcase(str(Path(left).resolve())) == os.path.normcase(str(right.resolve()))
39
+
40
+
41
+ class AccessEngine:
42
+ """Owns one Access process and the database opened in it."""
43
+
44
+ def __init__(
45
+ self,
46
+ path: Path,
47
+ *,
48
+ create: bool,
49
+ readonly: bool,
50
+ exclusive: bool,
51
+ password: str | None,
52
+ design: bool,
53
+ options: AccessLaunchOptions,
54
+ on_created: Callable[[], None] | None = None,
55
+ ) -> None:
56
+ self._path = path
57
+ self._readonly = readonly
58
+ self._exclusive = exclusive
59
+ self._password = password
60
+ self._mode: Literal["hosted", "design"] = "hosted"
61
+ self._db: Any = None
62
+ self._design: AccessDesignBackend | None = None
63
+ self._on_created = on_created
64
+ self._process = AccessProcess.launch(options, database=str(path))
65
+ try:
66
+ if create:
67
+ self._new_current()
68
+ elif design and not readonly:
69
+ self._open_current()
70
+ else:
71
+ self._open_hosted()
72
+ except BaseException:
73
+ self._process.shutdown()
74
+ raise
75
+ self._schema = DaoSchemaBackend(
76
+ com=self._process.com,
77
+ database=lambda: self._db,
78
+ path=path,
79
+ transport=lambda: self.transport,
80
+ run_ddl=self._run_ddl,
81
+ )
82
+
83
+ @property
84
+ def _app(self) -> Any:
85
+ return self._process.app
86
+
87
+ @property
88
+ def transport(self) -> Transport:
89
+ return Transport.ACCESS_DESIGN if self._mode == "design" else Transport.ACCESS_HOSTED
90
+
91
+ @property
92
+ def supports_design(self) -> bool:
93
+ return not self._readonly and not self._process.is_runtime
94
+
95
+ @property
96
+ def pid(self) -> int | None:
97
+ return self._process.pid
98
+
99
+ # ------------------------------------------------------------------------------ open / create
100
+ def _connect(self) -> str:
101
+ return f";PWD={self._password}" if self._password else ""
102
+
103
+ def _open_hosted(self) -> None:
104
+ with self._process.com.op(f"open database {self._path}", path=self._path):
105
+ dbengine = get(self._app, "DBEngine")
106
+ self._db = call(
107
+ dbengine,
108
+ "OpenDatabase",
109
+ str(self._path),
110
+ self._exclusive,
111
+ self._readonly,
112
+ self._connect(),
113
+ )
114
+ self._mode = "hosted"
115
+
116
+ def _new_current(self) -> None:
117
+ with self._process.com.op(f"create database {self._path}", path=self._path):
118
+ call(
119
+ self._app,
120
+ "NewCurrentDatabase",
121
+ str(self._path),
122
+ int(c.AcNewDatabaseFormat.acNewDatabaseFormatAccess2007),
123
+ )
124
+ self._verify_current() # confirms Access created *this* file and it is now its current database
125
+ if self._on_created is not None:
126
+ self._on_created()
127
+
128
+ def _open_current(self) -> None:
129
+ """Open the database for design without running its startup form (ADR 0002).
130
+
131
+ ``macro_security`` stops VBA and ``AutoExec``, but Access still opens the ``StartUpForm``; with code
132
+ disabled that form can raise dialogs. The property is set aside through DAO (which runs nothing)
133
+ and restored as soon as the database is open.
134
+ """
135
+ startup_form = self._suspend_startup_form()
136
+ try:
137
+ with self._process.com.op(f"open database {self._path} in Access", path=self._path):
138
+ call(
139
+ self._app,
140
+ "OpenCurrentDatabase",
141
+ str(self._path),
142
+ self._exclusive,
143
+ self._password or "",
144
+ )
145
+ self._verify_current()
146
+ except BaseException as exc:
147
+ if startup_form is not None:
148
+ try:
149
+ self._restore_startup_form(startup_form)
150
+ except Exception as restore_error: # reported on the original error
151
+ exc.add_note(
152
+ f"PyAccessKit could not restore StartUpForm={startup_form!r} ({restore_error}); "
153
+ "set it again in Access (File > Options > Current Database > Display Form)"
154
+ )
155
+ raise
156
+ if startup_form is not None:
157
+ self._restore_startup_form(startup_form)
158
+
159
+ def _startup_property(self, db: Any) -> Any:
160
+ try:
161
+ return get(get(db, "Properties"), "Item", _STARTUP_FORM)
162
+ except pywintypes.com_error as exc:
163
+ if details_from(exc).number == _PROPERTY_NOT_FOUND:
164
+ return None
165
+ raise
166
+
167
+ def _suspend_startup_form(self) -> str | None:
168
+ with self._process.com.op(f"read startup settings of {self._path}", path=self._path):
169
+ db = call(
170
+ get(self._app, "DBEngine"),
171
+ "OpenDatabase",
172
+ str(self._path),
173
+ self._exclusive,
174
+ False,
175
+ self._connect(),
176
+ )
177
+ try:
178
+ prop = self._startup_property(db)
179
+ value = str(get(prop, "Value") or "") if prop is not None else ""
180
+ if value:
181
+ call(get(db, "Properties"), "Delete", _STARTUP_FORM)
182
+ del prop
183
+ finally:
184
+ call(db, "Close")
185
+ return value or None
186
+
187
+ def _restore_startup_form(self, value: str) -> None:
188
+ with self._process.com.op(f"restore startup settings of {self._path}", path=self._path):
189
+ reopened = self._db is None # opening failed: restore through DAO instead of CurrentDb
190
+ db = self._db or call(
191
+ get(self._app, "DBEngine"),
192
+ "OpenDatabase",
193
+ str(self._path),
194
+ self._exclusive,
195
+ False,
196
+ self._connect(),
197
+ )
198
+ try:
199
+ prop = self._startup_property(db)
200
+ if prop is not None:
201
+ put(prop, "Value", value)
202
+ else:
203
+ new = call(db, "CreateProperty", _STARTUP_FORM, _DB_TEXT, value)
204
+ call(get(db, "Properties"), "Append", new)
205
+ finally:
206
+ if reopened:
207
+ call(db, "Close")
208
+
209
+ def _verify_current(self) -> None:
210
+ """``OpenCurrentDatabase`` can fail *silently* (ADR 0001, S9b): check, and explain failures."""
211
+ with self._process.com.op(f"open database {self._path} in Access", path=self._path):
212
+ full_name = str(get(get(self._app, "CurrentProject"), "FullName") or "")
213
+ db = call(self._app, "CurrentDb") if full_name else None
214
+ if full_name and db is not None and _same_path(full_name, self._path):
215
+ self._db = db
216
+ self._mode = "design"
217
+ self._process.database_opened()
218
+ return
219
+ # Ask DAO why Access refused: it reports locked / unrecognized format / missing precisely.
220
+ try:
221
+ probe = call(
222
+ get(self._app, "DBEngine"),
223
+ "OpenDatabase",
224
+ str(self._path),
225
+ False,
226
+ True,
227
+ self._connect(),
228
+ )
229
+ except pywintypes.com_error as exc:
230
+ raise translate(
231
+ exc, OpContext(f"open database {self._path} in Access", path=self._path)
232
+ ) from exc
233
+ with contextlib.suppress(pywintypes.com_error):
234
+ call(probe, "Close")
235
+ raise DatabaseLockedError(
236
+ f"Microsoft Access could not open {self._path} as its current database (it may be open "
237
+ "exclusively in another Access instance)",
238
+ path=self._path,
239
+ )
240
+
241
+ def upgrade(self) -> None:
242
+ """Switch from Access-hosted DAO to a design session (``OpenCurrentDatabase``)."""
243
+ if self._mode == "design":
244
+ return
245
+ if self._readonly:
246
+ raise CapabilityError(
247
+ "read-only sessions cannot use design features (forms, modules, DDL)"
248
+ )
249
+ if self._process.is_runtime:
250
+ raise AccessRuntimeOnlyError(
251
+ "only the Access Runtime is installed; design features need full Microsoft Access",
252
+ diagnosis="Install Microsoft Access (not the Runtime) to build forms, reports and modules.",
253
+ )
254
+ with self._process.com.op(f"release database {self._path}", path=self._path):
255
+ call(self._db, "Close")
256
+ self._db = None
257
+ self._open_current()
258
+
259
+ def _run_ddl(self, sql: str) -> None:
260
+ self.upgrade()
261
+ with self._process.com.op("run DDL", sql=sql, path=self._path):
262
+ connection = get(get(self._app, "CurrentProject"), "Connection")
263
+ call(connection, "Execute", sql)
264
+
265
+ # ------------------------------------------------------------------------------------ engine
266
+ def schema(self) -> SchemaBackend:
267
+ return self._schema
268
+
269
+ def design(self) -> DesignBackend:
270
+ self.upgrade()
271
+ if self._design is None:
272
+ self._design = AccessDesignBackend(
273
+ com=lambda: self._process.com, app=lambda: self._process.app
274
+ )
275
+ return self._design
276
+
277
+ def raw(self, which: str) -> Any:
278
+ if which == "dao":
279
+ return self._db
280
+ if which == "dbengine":
281
+ return get(self._app, "DBEngine")
282
+ self.upgrade()
283
+ return self._app
284
+
285
+ def close(self) -> list[BaseException]:
286
+ errors: list[BaseException] = []
287
+ if self._design is not None:
288
+ self._design.cleanup()
289
+ self._design = None
290
+ if self._db is not None and self._mode == "hosted":
291
+ try:
292
+ call(self._db, "Close")
293
+ except pywintypes.com_error as exc:
294
+ errors.append(exc)
295
+ self._db = None
296
+ errors.extend(self._process.shutdown())
297
+ return errors
298
+
299
+ def terminate(self) -> None:
300
+ self._process.terminate("the PyAccessKit session was abandoned")
@@ -0,0 +1,148 @@
1
+ # pyright: basic
2
+ """Engine: DAO loaded into the Python process (fastest; needs Python bitness = Office/ACE bitness)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import contextlib
7
+ from collections.abc import Callable
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import pywintypes
12
+
13
+ from pyaccesskit._backends.dao.profile import apply_native_defaults
14
+ from pyaccesskit._backends.dao.schema import DaoSchemaBackend
15
+ from pyaccesskit._backends.protocols import DesignBackend, SchemaBackend
16
+ from pyaccesskit._com import constants as c
17
+ from pyaccesskit._com.dispatch import create_inproc
18
+ from pyaccesskit._com.gateway import Com
19
+ from pyaccesskit._engines.probe import DAO_PROGID, inproc_dao
20
+ from pyaccesskit._win.access_process import read_dao_errors
21
+ from pyaccesskit.enums import Transport
22
+ from pyaccesskit.errors import CapabilityError, DaoNotAvailableError
23
+
24
+ __all__ = ["InProcDaoEngine"]
25
+
26
+ _ACE_PROVIDERS = ("Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0")
27
+
28
+
29
+ class InProcDaoEngine:
30
+ """Owns an in-process ``DBEngine`` and one open ``Database``."""
31
+
32
+ transport = Transport.DAO_INPROC
33
+ supports_design = False
34
+
35
+ def __init__(
36
+ self,
37
+ path: Path,
38
+ *,
39
+ create: bool,
40
+ readonly: bool,
41
+ exclusive: bool,
42
+ password: str | None,
43
+ native_defaults: bool = True,
44
+ on_created: Callable[[], None] | None = None,
45
+ ) -> None:
46
+ self._path = path
47
+ self._readonly = readonly
48
+ self._exclusive = exclusive
49
+ self._password = password
50
+ try:
51
+ self._dbengine: Any = create_inproc(DAO_PROGID)
52
+ except pywintypes.com_error as exc:
53
+ raise DaoNotAvailableError(
54
+ "in-process DAO is not available in this Python process",
55
+ diagnosis=inproc_dao().reason or str(exc),
56
+ ) from exc
57
+ self._com = Com(dao_errors=lambda: read_dao_errors(self._dbengine))
58
+ self._db: Any = None
59
+ verb = "create" if create else "open"
60
+ with self._com.op(f"{verb} database {path}", path=path):
61
+ if create:
62
+ locale = c.dbLangGeneral + (f";pwd={password}" if password else "")
63
+ # CreateDatabase fails if the file exists, so a returned database is ours.
64
+ self._db = self._dbengine.CreateDatabase(
65
+ str(path), locale, int(c.DatabaseTypeEnum.dbVersion120)
66
+ )
67
+ if on_created is not None:
68
+ on_created()
69
+ if native_defaults:
70
+ try:
71
+ apply_native_defaults(self._db)
72
+ except BaseException:
73
+ self.close() # release the file so the session can delete it
74
+ raise
75
+ else:
76
+ self._db = self._open()
77
+ self._schema = DaoSchemaBackend(
78
+ com=self._com,
79
+ database=lambda: self._db,
80
+ path=path,
81
+ transport=lambda: Transport.DAO_INPROC,
82
+ run_ddl=self._run_ddl,
83
+ )
84
+
85
+ def _open(self) -> Any:
86
+ connect = f";PWD={self._password}" if self._password else ""
87
+ return self._dbengine.OpenDatabase(
88
+ str(self._path), self._exclusive, self._readonly, connect
89
+ )
90
+
91
+ def _run_ddl(self, sql: str) -> None:
92
+ """Run ANSI-92 DDL (e.g. DECIMAL(p,s)) with ADO; DAO must release its exclusive handle meanwhile."""
93
+ with self._com.op("run DDL", sql=sql, path=self._path):
94
+ self._db.Close()
95
+ self._db = None
96
+ try:
97
+ last_error: Exception | None = None
98
+ for provider in _ACE_PROVIDERS:
99
+ connection = create_inproc("ADODB.Connection")
100
+ extra = (
101
+ f"Jet OLEDB:Database Password={self._password};" if self._password else ""
102
+ )
103
+ try:
104
+ connection.Open(f"Provider={provider};Data Source={self._path};{extra}")
105
+ except pywintypes.com_error as exc:
106
+ last_error = exc
107
+ continue
108
+ try:
109
+ connection.Execute(sql)
110
+ finally:
111
+ with contextlib.suppress(pywintypes.com_error):
112
+ connection.Close()
113
+ return
114
+ assert last_error is not None
115
+ raise last_error
116
+ finally:
117
+ self._db = self._open()
118
+
119
+ # ------------------------------------------------------------------------------------ engine
120
+ def schema(self) -> SchemaBackend:
121
+ return self._schema
122
+
123
+ def design(self) -> DesignBackend:
124
+ raise CapabilityError(
125
+ "forms, reports, modules and text import/export need Microsoft Access; open the database with "
126
+ "engine='access' (or engine='auto') instead of engine='dao'"
127
+ )
128
+
129
+ def raw(self, which: str) -> Any:
130
+ if which == "dao":
131
+ return self._db
132
+ if which == "dbengine":
133
+ return self._dbengine
134
+ raise CapabilityError("this session uses in-process DAO; there is no Access.Application")
135
+
136
+ def close(self) -> list[BaseException]:
137
+ errors: list[BaseException] = []
138
+ if self._db is not None:
139
+ try:
140
+ self._db.Close()
141
+ except pywintypes.com_error as exc:
142
+ errors.append(exc)
143
+ self._db = None
144
+ self._dbengine = None
145
+ return errors
146
+
147
+ def terminate(self) -> None:
148
+ """Nothing to terminate: in-process DAO has no separate process."""
@@ -0,0 +1,231 @@
1
+ # pyright: basic
2
+ """Environment probing: which engines can this Python process use, and why not?
3
+
4
+ Registry checks use the standard library (``winreg``); the in-process DAO check actually loads
5
+ ``DAO.DBEngine.120`` (cheap, no process is started). Results are cached per process.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ import os
12
+ import struct
13
+ import winreg
14
+ from dataclasses import dataclass
15
+
16
+ import pywintypes
17
+
18
+ from pyaccesskit._com.dispatch import create_inproc
19
+
20
+ __all__ = [
21
+ "ACE_OLEDB_PROGIDS",
22
+ "DAO_PROGID",
23
+ "AccessFacts",
24
+ "ClickToRunFacts",
25
+ "ProbeResult",
26
+ "access_facts",
27
+ "access_registered",
28
+ "click_to_run",
29
+ "dao_registration",
30
+ "file_version",
31
+ "inproc_dao",
32
+ "inproc_registration",
33
+ "pe_bits",
34
+ "python_bits",
35
+ ]
36
+
37
+ DAO_PROGID = "DAO.DBEngine.120"
38
+ ACE_OLEDB_PROGIDS = ("Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0")
39
+ _APP_PATHS = r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\MSACCESS.EXE"
40
+ _C2R_CONFIGURATION = r"SOFTWARE\Microsoft\Office\ClickToRun\Configuration"
41
+ _PE_MACHINE_BITS = {0x014C: 32, 0x8664: 64, 0xAA64: 64}
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class ProbeResult:
46
+ """Whether something is usable, with a human explanation when it is not."""
47
+
48
+ available: bool
49
+ reason: str = ""
50
+
51
+
52
+ def python_bits() -> int:
53
+ """Bitness of this Python interpreter (32 or 64)."""
54
+ return struct.calcsize("P") * 8
55
+
56
+
57
+ def _clsid(progid: str) -> str | None:
58
+ try:
59
+ with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, rf"{progid}\CLSID") as key:
60
+ return str(winreg.QueryValueEx(key, "")[0])
61
+ except OSError:
62
+ return None
63
+
64
+
65
+ def _inproc_server(clsid: str, view: int) -> str | None:
66
+ try:
67
+ with winreg.OpenKey(
68
+ winreg.HKEY_CLASSES_ROOT, rf"CLSID\{clsid}\InprocServer32", 0, winreg.KEY_READ | view
69
+ ) as key:
70
+ value = str(winreg.QueryValueEx(key, "")[0])
71
+ return value or None
72
+ except OSError:
73
+ return None
74
+
75
+
76
+ def inproc_registration(progid: str) -> dict[int, str | None]:
77
+ """In-process server path registered for ``progid``, for 32- and 64-bit processes."""
78
+ clsid = _clsid(progid)
79
+ if clsid is None:
80
+ return {32: None, 64: None}
81
+ return {
82
+ 32: _inproc_server(clsid, winreg.KEY_WOW64_32KEY),
83
+ 64: _inproc_server(clsid, winreg.KEY_WOW64_64KEY),
84
+ }
85
+
86
+
87
+ def dao_registration() -> dict[int, str | None]:
88
+ """Native in-process DAO server path registered for 32- and 64-bit processes."""
89
+ return inproc_registration(DAO_PROGID)
90
+
91
+
92
+ def access_registered(progid: str = "Access.Application") -> bool:
93
+ """Whether ``Access.Application`` (or the given ProgID) is registered."""
94
+ return _clsid(progid) is not None
95
+
96
+
97
+ @functools.cache
98
+ def inproc_dao() -> ProbeResult:
99
+ """Whether this Python process can load DAO in-process (cached)."""
100
+ try:
101
+ engine = create_inproc(DAO_PROGID)
102
+ except pywintypes.com_error:
103
+ registered = dao_registration()
104
+ bits = python_bits()
105
+ other = 64 if bits == 32 else 32
106
+ if registered[bits]:
107
+ reason = f"DAO is registered for {bits}-bit processes but could not be loaded"
108
+ elif registered[other]:
109
+ reason = (
110
+ f"DAO (Access database engine) is installed for {other}-bit programs only, but this Python is "
111
+ f"{bits}-bit. Use a {other}-bit Python for in-process DAO, or engine='access'."
112
+ )
113
+ else:
114
+ reason = "the Access database engine (DAO.DBEngine.120) is not installed"
115
+ return ProbeResult(False, reason)
116
+ del engine
117
+ return ProbeResult(True)
118
+
119
+
120
+ # ------------------------------------------------------------------------------ installation facts
121
+ @dataclass(frozen=True)
122
+ class AccessFacts:
123
+ """What the registry says about Microsoft Access (nothing is started)."""
124
+
125
+ progid: str
126
+ registered: bool
127
+ current_version: str | None
128
+ executable: str | None
129
+ version: str | None
130
+ bits: int | None
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class ClickToRunFacts:
135
+ """Microsoft 365 / Click-to-Run installation details."""
136
+
137
+ platform: str | None
138
+ version: str | None
139
+ products: tuple[str, ...]
140
+
141
+
142
+ def _read_value(root: int, path: str, name: str, view: int) -> str | None:
143
+ try:
144
+ with winreg.OpenKey(root, path, 0, winreg.KEY_READ | view) as key:
145
+ value = winreg.QueryValueEx(key, name)[0]
146
+ except OSError:
147
+ return None
148
+ return str(value) if value not in (None, "") else None
149
+
150
+
151
+ def _executable_from_command(command: str) -> str | None:
152
+ command = command.strip()
153
+ if command.startswith('"'):
154
+ end = command.find('"', 1)
155
+ return command[1:end] if end > 1 else None
156
+ lowered = command.lower()
157
+ end = lowered.find(".exe")
158
+ return command[: end + 4] if end >= 0 else command or None
159
+
160
+
161
+ def _local_server(clsid: str) -> str | None:
162
+ for view in (winreg.KEY_WOW64_64KEY, winreg.KEY_WOW64_32KEY):
163
+ command = _read_value(winreg.HKEY_CLASSES_ROOT, rf"CLSID\{clsid}\LocalServer32", "", view)
164
+ if command:
165
+ return _executable_from_command(command)
166
+ return None
167
+
168
+
169
+ def pe_bits(path: str) -> int | None:
170
+ """Bitness of a Windows executable, read from its PE header (``None`` if unreadable)."""
171
+ try:
172
+ with open(path, "rb") as handle: # noqa: PTH123 - plain binary read of a header
173
+ header = handle.read(64)
174
+ if header[:2] != b"MZ" or len(header) < 64:
175
+ return None
176
+ handle.seek(int.from_bytes(header[0x3C:0x40], "little"))
177
+ signature = handle.read(6)
178
+ except OSError:
179
+ return None
180
+ if signature[:4] != b"PE\0\0":
181
+ return None
182
+ return _PE_MACHINE_BITS.get(int.from_bytes(signature[4:6], "little"))
183
+
184
+
185
+ def file_version(path: str) -> str | None:
186
+ """The file version resource of an executable (e.g. ``16.0.19127.20264``)."""
187
+ try:
188
+ import win32api
189
+
190
+ info = win32api.GetFileVersionInfo(path, "\\")
191
+ except Exception:
192
+ return None
193
+ ms, ls = int(info["FileVersionMS"]), int(info["FileVersionLS"])
194
+ return f"{ms >> 16}.{ms & 0xFFFF}.{ls >> 16}.{ls & 0xFFFF}"
195
+
196
+
197
+ def access_facts(progid: str = "Access.Application") -> AccessFacts:
198
+ """Locate the Access executable registered for automation (registry only)."""
199
+ clsid = _clsid(progid)
200
+ current = _read_value(winreg.HKEY_CLASSES_ROOT, rf"{progid}\CurVer", "", winreg.KEY_WOW64_64KEY)
201
+ executable = _local_server(clsid) if clsid else None
202
+ if executable is None:
203
+ for view in (winreg.KEY_WOW64_64KEY, winreg.KEY_WOW64_32KEY):
204
+ executable = _read_value(winreg.HKEY_LOCAL_MACHINE, _APP_PATHS, "", view)
205
+ if executable:
206
+ break
207
+ if executable and not os.path.isfile(executable): # noqa: PTH113 - registry path strings
208
+ executable = None
209
+ return AccessFacts(
210
+ progid=progid,
211
+ registered=clsid is not None,
212
+ current_version=current,
213
+ executable=executable,
214
+ version=file_version(executable) if executable else None,
215
+ bits=pe_bits(executable) if executable else None,
216
+ )
217
+
218
+
219
+ def click_to_run() -> ClickToRunFacts | None:
220
+ """Click-to-Run configuration, or ``None`` for MSI installations / no Office."""
221
+ view = winreg.KEY_WOW64_64KEY
222
+ platform = _read_value(winreg.HKEY_LOCAL_MACHINE, _C2R_CONFIGURATION, "Platform", view)
223
+ version = _read_value(winreg.HKEY_LOCAL_MACHINE, _C2R_CONFIGURATION, "VersionToReport", view)
224
+ products = _read_value(winreg.HKEY_LOCAL_MACHINE, _C2R_CONFIGURATION, "ProductReleaseIds", view)
225
+ if platform is None and version is None and products is None:
226
+ return None
227
+ return ClickToRunFacts(
228
+ platform=platform,
229
+ version=version,
230
+ products=tuple(p for p in (products or "").split(",") if p),
231
+ )