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,354 @@
|
|
|
1
|
+
"""The Session: sole owner of an engine, its state machine and every cleanup guarantee (COM-free).
|
|
2
|
+
|
|
3
|
+
* **Engine selection** — ``auto`` prefers in-process DAO, else Access; the first design feature lazily
|
|
4
|
+
switches an in-process DAO session to Access (name-based handles survive; raw proxies are revoked).
|
|
5
|
+
* **Thread affinity** — COM objects belong to the apartment that created them; a session refuses calls
|
|
6
|
+
from other threads (:class:`~pyaccesskit.errors.WrongThreadError`).
|
|
7
|
+
* **Atomic creation** — ``create()`` builds a sibling temp file and moves it into place only when the
|
|
8
|
+
session closes without error; otherwise the temp file is deleted and the target is untouched.
|
|
9
|
+
* **Cleanup** — failures while closing never mask the exception that caused the close (they are attached
|
|
10
|
+
as notes); on a normal close they are raised together as :class:`~pyaccesskit.errors.CleanupError`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Literal
|
|
24
|
+
|
|
25
|
+
from pyaccesskit._backends.protocols import DesignBackend, SchemaBackend
|
|
26
|
+
from pyaccesskit._com.raw import ProxyRegistry
|
|
27
|
+
from pyaccesskit._session.protocols import EngineHandle, EnginePlan, EnvironmentProbe, RawKind
|
|
28
|
+
from pyaccesskit.enums import Engine, Transport
|
|
29
|
+
from pyaccesskit.errors import (
|
|
30
|
+
AccessNotInstalledError,
|
|
31
|
+
CapabilityError,
|
|
32
|
+
CleanupError,
|
|
33
|
+
DaoNotAvailableError,
|
|
34
|
+
DatabaseExistsError,
|
|
35
|
+
DatabaseLockedError,
|
|
36
|
+
EngineUnavailableError,
|
|
37
|
+
ReadOnlyError,
|
|
38
|
+
SessionClosedError,
|
|
39
|
+
WrongThreadError,
|
|
40
|
+
)
|
|
41
|
+
from pyaccesskit.options import SessionOptions
|
|
42
|
+
|
|
43
|
+
__all__ = ["DefaultProbe", "EngineFactory", "Session"]
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger("pyaccesskit.session")
|
|
46
|
+
|
|
47
|
+
EngineFactory = Callable[[EnginePlan], EngineHandle]
|
|
48
|
+
|
|
49
|
+
_ACCESS_ADVICE = (
|
|
50
|
+
"Install Microsoft Access (Microsoft 365, 2016 or later) for full functionality, or the Microsoft 365 Access "
|
|
51
|
+
"Runtime together with a Python of the same bitness for DAO-only work. Run 'pyaccesskit doctor' for details."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DefaultProbe:
|
|
56
|
+
"""Probes the real machine (imports the COM layer lazily)."""
|
|
57
|
+
|
|
58
|
+
def inproc_dao(self) -> tuple[bool, str]:
|
|
59
|
+
from pyaccesskit._engines.probe import inproc_dao
|
|
60
|
+
|
|
61
|
+
result = inproc_dao()
|
|
62
|
+
return result.available, result.reason
|
|
63
|
+
|
|
64
|
+
def access(self, progid: str) -> bool:
|
|
65
|
+
from pyaccesskit._engines.probe import (
|
|
66
|
+
access_registered,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return access_registered(progid)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def default_factory(plan: EnginePlan) -> EngineHandle:
|
|
73
|
+
"""Open a real engine (imports the COM layer lazily)."""
|
|
74
|
+
from pyaccesskit._engines import open_engine
|
|
75
|
+
|
|
76
|
+
engine: EngineHandle = open_engine(plan)
|
|
77
|
+
return engine
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Session:
|
|
81
|
+
"""Owns one engine for one database file."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
*,
|
|
86
|
+
target: Path,
|
|
87
|
+
working: Path,
|
|
88
|
+
create: bool,
|
|
89
|
+
readonly: bool,
|
|
90
|
+
exclusive: bool,
|
|
91
|
+
password: str | None,
|
|
92
|
+
engine: Engine,
|
|
93
|
+
options: SessionOptions,
|
|
94
|
+
factory: EngineFactory | None = None,
|
|
95
|
+
probe: EnvironmentProbe | None = None,
|
|
96
|
+
overwrite: bool = False,
|
|
97
|
+
) -> None:
|
|
98
|
+
self.target = target
|
|
99
|
+
self.overwrite = overwrite
|
|
100
|
+
self.working = working
|
|
101
|
+
self.created = create
|
|
102
|
+
self.readonly = readonly
|
|
103
|
+
self.exclusive = exclusive
|
|
104
|
+
self.password = password
|
|
105
|
+
self.engine_choice = engine
|
|
106
|
+
self.options = options
|
|
107
|
+
self.thread_id = threading.get_ident()
|
|
108
|
+
self.state: Literal["opening", "open", "closing", "closed"] = "opening"
|
|
109
|
+
self._factory = factory or default_factory
|
|
110
|
+
self._probe = probe or DefaultProbe()
|
|
111
|
+
self._raw = ProxyRegistry()
|
|
112
|
+
self._owns_working = False # set only when our engine created the file
|
|
113
|
+
self._kind = self._select()
|
|
114
|
+
try:
|
|
115
|
+
self._engine: EngineHandle = self._factory(
|
|
116
|
+
self._plan(self._kind, create=create, design=False)
|
|
117
|
+
)
|
|
118
|
+
except BaseException:
|
|
119
|
+
# No AccessDatabase (and so no finalizer) exists yet. Delete the file only if our engine created
|
|
120
|
+
# it: with atomic=False another process may have created the target meanwhile.
|
|
121
|
+
self.state = "closed"
|
|
122
|
+
if self._owns_working:
|
|
123
|
+
with contextlib.suppress(Exception):
|
|
124
|
+
self._discard()
|
|
125
|
+
raise
|
|
126
|
+
self.state = "open"
|
|
127
|
+
logger.info("opened %s via %s", target, self._engine.transport.value)
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------------------ engine choice
|
|
130
|
+
def _plan(self, kind: Literal["dao", "access"], *, create: bool, design: bool) -> EnginePlan:
|
|
131
|
+
return EnginePlan(
|
|
132
|
+
kind=kind,
|
|
133
|
+
path=self.working,
|
|
134
|
+
create=create,
|
|
135
|
+
readonly=self.readonly,
|
|
136
|
+
exclusive=self.exclusive,
|
|
137
|
+
password=self.password,
|
|
138
|
+
design=design,
|
|
139
|
+
options=self.options,
|
|
140
|
+
on_created=self._mark_created if create else None,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _mark_created(self) -> None:
|
|
144
|
+
self._owns_working = True
|
|
145
|
+
|
|
146
|
+
def _select(self) -> Literal["dao", "access"]:
|
|
147
|
+
progid = self.options.access_progid
|
|
148
|
+
if self.engine_choice is Engine.DAO:
|
|
149
|
+
available, reason = self._probe.inproc_dao()
|
|
150
|
+
if not available:
|
|
151
|
+
raise DaoNotAvailableError("in-process DAO is not available", diagnosis=reason)
|
|
152
|
+
return "dao"
|
|
153
|
+
if self.engine_choice is Engine.ACCESS:
|
|
154
|
+
if not self._probe.access(progid):
|
|
155
|
+
raise AccessNotInstalledError(
|
|
156
|
+
"Microsoft Access is not installed", diagnosis=_ACCESS_ADVICE
|
|
157
|
+
)
|
|
158
|
+
return "access"
|
|
159
|
+
available, reason = self._probe.inproc_dao()
|
|
160
|
+
if available:
|
|
161
|
+
return "dao"
|
|
162
|
+
if self._probe.access(progid):
|
|
163
|
+
return "access"
|
|
164
|
+
raise EngineUnavailableError(
|
|
165
|
+
"neither in-process DAO nor Microsoft Access is available",
|
|
166
|
+
diagnosis=f"In-process DAO: {reason}\nMicrosoft Access: not installed.\n{_ACCESS_ADVICE}",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------------------------------------ checks
|
|
170
|
+
def check(self) -> None:
|
|
171
|
+
"""Raise if the session is closed or used from the wrong thread."""
|
|
172
|
+
if self.state != "open":
|
|
173
|
+
raise SessionClosedError(f"the session for {self.target} is closed")
|
|
174
|
+
if threading.get_ident() != self.thread_id:
|
|
175
|
+
raise WrongThreadError(
|
|
176
|
+
"a PyAccessKit session can only be used from the thread that opened it (COM objects are "
|
|
177
|
+
"bound to their apartment); open a separate session per thread or process"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def check_thread(self) -> None:
|
|
181
|
+
"""Raise :class:`WrongThreadError` if an unclosed session is used from another thread."""
|
|
182
|
+
if self.state != "closed" and threading.get_ident() != self.thread_id:
|
|
183
|
+
raise WrongThreadError(
|
|
184
|
+
"a PyAccessKit session can only be closed from the thread that opened it (COM objects are "
|
|
185
|
+
"bound to their apartment); close it there, or call terminate() for an emergency stop"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def check_writable(self, action: str) -> None:
|
|
189
|
+
"""Raise :class:`ReadOnlyError` for read-only sessions."""
|
|
190
|
+
self.check()
|
|
191
|
+
if self.readonly:
|
|
192
|
+
raise ReadOnlyError(
|
|
193
|
+
f"cannot {action}: {self.target.name} was opened with readonly=True"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def transport(self) -> Transport:
|
|
198
|
+
"""How the database is currently reached."""
|
|
199
|
+
return self._engine.transport
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def engine_handle(self) -> EngineHandle:
|
|
203
|
+
"""The current engine (for diagnostics and tests)."""
|
|
204
|
+
return self._engine
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------------- backends
|
|
207
|
+
def schema(self) -> SchemaBackend:
|
|
208
|
+
"""The schema backend of the current engine."""
|
|
209
|
+
self.check()
|
|
210
|
+
return self._engine.schema()
|
|
211
|
+
|
|
212
|
+
def design(self) -> DesignBackend:
|
|
213
|
+
"""The design backend, switching to Microsoft Access first if needed."""
|
|
214
|
+
self.check()
|
|
215
|
+
self._ensure_access()
|
|
216
|
+
return self._engine.design()
|
|
217
|
+
|
|
218
|
+
def _ensure_access(self) -> None:
|
|
219
|
+
if self._kind == "access":
|
|
220
|
+
return
|
|
221
|
+
if self.engine_choice is Engine.DAO:
|
|
222
|
+
raise CapabilityError(
|
|
223
|
+
"forms, reports, modules and text import/export need Microsoft Access; this session was "
|
|
224
|
+
"opened with engine='dao'"
|
|
225
|
+
)
|
|
226
|
+
if self.readonly:
|
|
227
|
+
raise CapabilityError("read-only sessions cannot use design features")
|
|
228
|
+
if not self._probe.access(self.options.access_progid):
|
|
229
|
+
raise AccessNotInstalledError(
|
|
230
|
+
"this feature needs Microsoft Access, which is not installed",
|
|
231
|
+
diagnosis=_ACCESS_ADVICE,
|
|
232
|
+
)
|
|
233
|
+
logger.info("switching %s from in-process DAO to Microsoft Access", self.target)
|
|
234
|
+
self._raw.revoke_all()
|
|
235
|
+
self._raw = ProxyRegistry()
|
|
236
|
+
errors = self._engine.close()
|
|
237
|
+
if errors:
|
|
238
|
+
raise CleanupError(
|
|
239
|
+
"could not release in-process DAO before switching to Access", errors=tuple(errors)
|
|
240
|
+
)
|
|
241
|
+
self._engine = self._factory(self._plan("access", create=False, design=True))
|
|
242
|
+
self._kind = "access"
|
|
243
|
+
|
|
244
|
+
def raw(self, which: RawKind) -> Any:
|
|
245
|
+
"""A revocable proxy for a raw COM object (escape hatch)."""
|
|
246
|
+
self.check()
|
|
247
|
+
if which == "access":
|
|
248
|
+
self._ensure_access()
|
|
249
|
+
return self._raw.wrap(self._engine.raw(which), f"db.raw.{which}")
|
|
250
|
+
|
|
251
|
+
# ------------------------------------------------------------------------------------- close
|
|
252
|
+
@property
|
|
253
|
+
def atomic(self) -> bool:
|
|
254
|
+
"""Whether the database is being built in a temp file."""
|
|
255
|
+
return self.working != self.target
|
|
256
|
+
|
|
257
|
+
def close(self, error: BaseException | None = None) -> None:
|
|
258
|
+
"""Close the session. With ``error`` set, a newly created database is discarded."""
|
|
259
|
+
if self.state in ("closing", "closed"):
|
|
260
|
+
return
|
|
261
|
+
self.check_thread()
|
|
262
|
+
self.state = "closing"
|
|
263
|
+
cleanup: list[BaseException] = []
|
|
264
|
+
interrupt: BaseException | None = None
|
|
265
|
+
try:
|
|
266
|
+
self._raw.revoke_all()
|
|
267
|
+
cleanup.extend(self._engine.close())
|
|
268
|
+
except (KeyboardInterrupt, SystemExit) as exc:
|
|
269
|
+
interrupt = exc
|
|
270
|
+
with contextlib.suppress(Exception):
|
|
271
|
+
self._engine.terminate()
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
cleanup.append(exc)
|
|
274
|
+
finally:
|
|
275
|
+
try:
|
|
276
|
+
if self.atomic:
|
|
277
|
+
if error is None and interrupt is None:
|
|
278
|
+
self._commit()
|
|
279
|
+
else:
|
|
280
|
+
self._discard()
|
|
281
|
+
except Exception as exc:
|
|
282
|
+
cleanup.append(exc)
|
|
283
|
+
self.state = "closed"
|
|
284
|
+
logger.info("closed %s", self.target)
|
|
285
|
+
if interrupt is not None:
|
|
286
|
+
raise interrupt
|
|
287
|
+
if cleanup:
|
|
288
|
+
if error is not None:
|
|
289
|
+
for problem in cleanup:
|
|
290
|
+
error.add_note(
|
|
291
|
+
f"PyAccessKit cleanup also failed: {type(problem).__name__}: {problem}"
|
|
292
|
+
)
|
|
293
|
+
else:
|
|
294
|
+
raise CleanupError(
|
|
295
|
+
f"closing {self.target.name} did not complete cleanly", errors=tuple(cleanup)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def terminate(self) -> None:
|
|
299
|
+
"""Emergency stop (safe from any thread): terminate an owned Access process, discard temp files."""
|
|
300
|
+
if self.state == "closed":
|
|
301
|
+
return
|
|
302
|
+
self.state = "closed"
|
|
303
|
+
with contextlib.suppress(Exception):
|
|
304
|
+
self._engine.terminate()
|
|
305
|
+
if self.atomic:
|
|
306
|
+
with contextlib.suppress(Exception):
|
|
307
|
+
self._discard()
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _retry(action: Callable[[], object], attempts: int = 40, delay: float = 0.25) -> None:
|
|
311
|
+
for attempt in range(attempts):
|
|
312
|
+
try:
|
|
313
|
+
action()
|
|
314
|
+
return
|
|
315
|
+
except PermissionError:
|
|
316
|
+
if attempt == attempts - 1:
|
|
317
|
+
raise
|
|
318
|
+
time.sleep(delay)
|
|
319
|
+
|
|
320
|
+
def _commit(self) -> None:
|
|
321
|
+
try:
|
|
322
|
+
if self.overwrite:
|
|
323
|
+
self._retry(lambda: self.working.replace(self.target))
|
|
324
|
+
else:
|
|
325
|
+
self._retry(self._move_without_replacing)
|
|
326
|
+
except FileExistsError as exc:
|
|
327
|
+
raise DatabaseExistsError(
|
|
328
|
+
f"{self.target} was created by someone else while this session was building it; the new "
|
|
329
|
+
f"database was left at {self.working}",
|
|
330
|
+
path=self.target,
|
|
331
|
+
) from exc
|
|
332
|
+
except OSError as exc:
|
|
333
|
+
raise DatabaseLockedError(
|
|
334
|
+
f"could not move the new database into place at {self.target} ({exc}); it was left at {self.working}",
|
|
335
|
+
path=self.target,
|
|
336
|
+
) from exc
|
|
337
|
+
self._remove_lock_file(self.working)
|
|
338
|
+
|
|
339
|
+
def _move_without_replacing(self) -> None:
|
|
340
|
+
if sys.platform == "win32":
|
|
341
|
+
self.working.rename(self.target) # MoveFileEx without REPLACE_EXISTING: fails if taken
|
|
342
|
+
else:
|
|
343
|
+
os.link(self.working, self.target) # atomic "create if absent" on POSIX
|
|
344
|
+
self.working.unlink()
|
|
345
|
+
|
|
346
|
+
def _discard(self) -> None:
|
|
347
|
+
self._retry(lambda: self.working.unlink(missing_ok=True))
|
|
348
|
+
self._remove_lock_file(self.working)
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def _remove_lock_file(path: Path) -> None:
|
|
352
|
+
lock_suffix = ".ldb" if path.suffix.lower() in (".mdb", ".mde") else ".laccdb"
|
|
353
|
+
with contextlib.suppress(OSError):
|
|
354
|
+
path.with_suffix(lock_suffix).unlink(missing_ok=True)
|
|
File without changes
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Encodings used by Access's ``SaveAsText``/``LoadFromText`` (verified in ADR 0001, spike S7).
|
|
2
|
+
|
|
3
|
+
* Forms, reports, queries and macros are exported as **UTF-16LE with a BOM** and must be imported the same
|
|
4
|
+
way (UTF-8 input silently loses non-ASCII text).
|
|
5
|
+
* VBA modules are read and written in the **ANSI code page** (e.g. cp1252); a UTF-8 BOM would become literal
|
|
6
|
+
```` characters in the code.
|
|
7
|
+
* Class modules are recognised by four leading ``Attribute`` lines (Access's own export format); a VB6-style
|
|
8
|
+
``VERSION 1.0 CLASS`` header produces an *empty standard* module instead.
|
|
9
|
+
|
|
10
|
+
This module converts between those native byte formats and ordinary Python ``str`` (LF line endings),
|
|
11
|
+
which is how PyAccessKit stores text on disk (UTF-8, git-friendly).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import codecs
|
|
17
|
+
import locale
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
from pyaccesskit.enums import ModuleKind, ObjectKind
|
|
21
|
+
from pyaccesskit.errors import SpecError
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CLASS_MODULE_HEADER",
|
|
25
|
+
"ansi_encoding",
|
|
26
|
+
"decode_export",
|
|
27
|
+
"encode_import",
|
|
28
|
+
"module_import_text",
|
|
29
|
+
"split_module_export",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
CLASS_MODULE_HEADER = (
|
|
33
|
+
"Attribute VB_GlobalNameSpace = False",
|
|
34
|
+
"Attribute VB_Creatable = False",
|
|
35
|
+
"Attribute VB_PredeclaredId = False",
|
|
36
|
+
"Attribute VB_Exposed = False",
|
|
37
|
+
)
|
|
38
|
+
"""The lines Access writes at the top of an exported class module (and uses to recognise one)."""
|
|
39
|
+
|
|
40
|
+
_UNICODE_KINDS = frozenset({ObjectKind.FORM, ObjectKind.REPORT, ObjectKind.QUERY, ObjectKind.MACRO})
|
|
41
|
+
_VB6_HEADER = re.compile(
|
|
42
|
+
r"\AVERSION\s+1\.0\s+CLASS\s*\nBEGIN\s*\n.*?\nEND\s*\n", re.IGNORECASE | re.DOTALL
|
|
43
|
+
)
|
|
44
|
+
_ATTRIBUTE_LINE = re.compile(r"^Attribute\s+VB_\w+\s*=.*$", re.IGNORECASE)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ansi_encoding() -> str:
|
|
48
|
+
"""The Windows ANSI code page Access uses for module text (e.g. ``cp1252``)."""
|
|
49
|
+
return locale.getencoding()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _to_lf(text: str) -> str:
|
|
53
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def decode_export(kind: ObjectKind, raw: bytes) -> str:
|
|
57
|
+
"""Decode bytes written by ``SaveAsText`` into text with LF line endings."""
|
|
58
|
+
if raw.startswith(codecs.BOM_UTF16_LE):
|
|
59
|
+
text = raw.decode("utf-16")
|
|
60
|
+
elif raw.startswith(codecs.BOM_UTF8):
|
|
61
|
+
text = raw.decode("utf-8-sig")
|
|
62
|
+
elif kind in _UNICODE_KINDS:
|
|
63
|
+
text = raw.decode("utf-16-le")
|
|
64
|
+
else:
|
|
65
|
+
text = raw.decode(ansi_encoding())
|
|
66
|
+
return _to_lf(text)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def encode_import(kind: ObjectKind, text: str) -> bytes:
|
|
70
|
+
"""Encode text for ``LoadFromText`` (CRLF line endings, UTF-16LE+BOM or ANSI depending on ``kind``).
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
SpecError: If module text contains characters the ANSI code page cannot represent.
|
|
74
|
+
"""
|
|
75
|
+
crlf = _to_lf(text).replace("\n", "\r\n")
|
|
76
|
+
if kind in _UNICODE_KINDS:
|
|
77
|
+
return codecs.BOM_UTF16_LE + crlf.encode("utf-16-le")
|
|
78
|
+
encoding = ansi_encoding()
|
|
79
|
+
try:
|
|
80
|
+
return crlf.encode(encoding)
|
|
81
|
+
except UnicodeEncodeError as exc:
|
|
82
|
+
bad = crlf[exc.start : exc.end]
|
|
83
|
+
raise SpecError(
|
|
84
|
+
f"VBA module text contains {bad!r}, which the ANSI code page {encoding} cannot represent; "
|
|
85
|
+
"VBA source is stored in the system code page (use ChrW(...) for other characters)"
|
|
86
|
+
) from exc
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def split_module_export(text: str) -> tuple[ModuleKind, str]:
|
|
90
|
+
"""Split exported module text into its kind and the code (class header removed)."""
|
|
91
|
+
lines = _to_lf(text).split("\n")
|
|
92
|
+
header = [line.strip() for line in lines[: len(CLASS_MODULE_HEADER)]]
|
|
93
|
+
if [h.casefold() for h in header] == [h.casefold() for h in CLASS_MODULE_HEADER]:
|
|
94
|
+
return ModuleKind.CLASS, "\n".join(lines[len(CLASS_MODULE_HEADER) :])
|
|
95
|
+
return ModuleKind.STANDARD, "\n".join(lines)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def module_import_text(code: str, kind: ModuleKind) -> str:
|
|
99
|
+
"""Prepare module code for ``LoadFromText``.
|
|
100
|
+
|
|
101
|
+
* converts VB6 ``.cls`` files (``VERSION 1.0 CLASS`` header) and strips ``Attribute`` lines;
|
|
102
|
+
* adds ``Option Compare Database`` if missing (as Access does for new modules);
|
|
103
|
+
* prefixes class modules with :data:`CLASS_MODULE_HEADER`.
|
|
104
|
+
"""
|
|
105
|
+
text = _to_lf(code).lstrip("")
|
|
106
|
+
text = _VB6_HEADER.sub("", text)
|
|
107
|
+
body = [line for line in text.split("\n") if not _ATTRIBUTE_LINE.match(line.strip())]
|
|
108
|
+
while body and not body[0].strip():
|
|
109
|
+
body.pop(0)
|
|
110
|
+
if not any(line.strip().casefold().startswith("option compare") for line in body):
|
|
111
|
+
body.insert(0, "Option Compare Database")
|
|
112
|
+
lines = list(CLASS_MODULE_HEADER) + body if kind is ModuleKind.CLASS else body
|
|
113
|
+
result = "\n".join(lines)
|
|
114
|
+
return result if result.endswith("\n") else result + "\n"
|
pyaccesskit/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Win32 process management (pywin32). Imported lazily by the engines."""
|