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,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
|
+
)
|