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,199 @@
|
|
|
1
|
+
# pyright: basic
|
|
2
|
+
"""The COM call gateway: error translation, dialog monitoring and exact-argument invocation.
|
|
3
|
+
|
|
4
|
+
Every group of COM calls made by a backend runs inside :meth:`Com.op`, which
|
|
5
|
+
|
|
6
|
+
* tells the dialog watchdog that a call is in flight (so a modal dialog is detected, captured and
|
|
7
|
+
dismissed instead of hanging forever);
|
|
8
|
+
* translates ``pywintypes.com_error`` into :mod:`pyaccesskit.errors` exceptions with context;
|
|
9
|
+
* raises :class:`~pyaccesskit.errors.AccessDialogError` (or warns) if Access showed a dialog meanwhile.
|
|
10
|
+
|
|
11
|
+
The ``get``/``put``/``call`` helpers invoke ``IDispatch`` by DISPID with *exactly* the given arguments.
|
|
12
|
+
pywin32's dynamic wrappers cannot call indexed properties such as ``Form.Section(0)``, raise
|
|
13
|
+
``AttributeError`` for members missing from a control's type info, and pad ``Application.Run``'s 30 optional
|
|
14
|
+
arguments (spikes S6 and S8); these helpers avoid all three problems.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import time
|
|
21
|
+
import warnings
|
|
22
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
23
|
+
from contextlib import contextmanager
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Protocol
|
|
26
|
+
|
|
27
|
+
import pythoncom
|
|
28
|
+
import pywintypes
|
|
29
|
+
|
|
30
|
+
from pyaccesskit._com import constants as c
|
|
31
|
+
from pyaccesskit._com.dispatch import wrap
|
|
32
|
+
from pyaccesskit._com.errors import OpContext, translate
|
|
33
|
+
from pyaccesskit._com.variants import normalize
|
|
34
|
+
from pyaccesskit.enums import DialogPolicy, ObjectKind
|
|
35
|
+
from pyaccesskit.errors import (
|
|
36
|
+
AccessDialogError,
|
|
37
|
+
AccessDialogWarning,
|
|
38
|
+
AccessTimeoutError,
|
|
39
|
+
DaoError,
|
|
40
|
+
DialogInfo,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = ["CallMonitor", "Com", "call", "get", "invoke", "put", "retry_busy"]
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger("pyaccesskit.com")
|
|
46
|
+
|
|
47
|
+
_LCID = 0
|
|
48
|
+
_BUSY = frozenset({c.RPC_E_CALL_REJECTED, c.RPC_E_SERVERCALL_RETRYLATER})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CallMonitor(Protocol):
|
|
52
|
+
"""Implemented by the dialog watchdog of an Access process."""
|
|
53
|
+
|
|
54
|
+
policy: DialogPolicy
|
|
55
|
+
|
|
56
|
+
def begin(self, description: str) -> None:
|
|
57
|
+
"""An outermost COM operation starts."""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
def end(self) -> tuple[DialogInfo, ...]:
|
|
61
|
+
"""The operation ended; return the dialogs observed while it ran."""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def termination(self) -> tuple[str, str] | None:
|
|
66
|
+
"""``(kind, reason)`` if the monitor terminated Access (kind: timeout, dialog, interrupt)."""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --------------------------------------------------------------------------------------- invocation
|
|
71
|
+
def invoke(obj: Any, name: str, flags: int, *args: Any) -> Any:
|
|
72
|
+
"""``IDispatch::Invoke`` ``name`` on ``obj`` with exactly ``args``; wraps/normalizes the result."""
|
|
73
|
+
ole = obj._oleobj_ if hasattr(obj, "_oleobj_") else obj
|
|
74
|
+
dispid = ole.GetIDsOfNames(name)
|
|
75
|
+
result = ole.Invoke(dispid, _LCID, flags, True, *args)
|
|
76
|
+
return normalize(wrap(result))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get(obj: Any, name: str, *args: Any) -> Any:
|
|
80
|
+
"""Read a (possibly indexed) property: ``get(form, "Section", 0)``."""
|
|
81
|
+
return invoke(obj, name, pythoncom.DISPATCH_PROPERTYGET | pythoncom.DISPATCH_METHOD, *args)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def call(obj: Any, name: str, *args: Any) -> Any:
|
|
85
|
+
"""Call a method with exactly ``args`` (no padding of optional parameters)."""
|
|
86
|
+
return invoke(obj, name, pythoncom.DISPATCH_METHOD, *args)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def put(obj: Any, name: str, value: Any) -> None:
|
|
90
|
+
"""Set a property."""
|
|
91
|
+
ole = obj._oleobj_ if hasattr(obj, "_oleobj_") else obj
|
|
92
|
+
dispid = ole.GetIDsOfNames(name)
|
|
93
|
+
ole.Invoke(dispid, _LCID, pythoncom.DISPATCH_PROPERTYPUT, False, value)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def retry_busy(fn: Callable[..., Any], *args: Any, timeout: float = 30.0) -> Any:
|
|
97
|
+
"""Call ``fn`` and retry while the server rejects calls because it is busy (``RPC_E_CALL_REJECTED``).
|
|
98
|
+
|
|
99
|
+
A rejected call never started executing, so retrying is always safe.
|
|
100
|
+
"""
|
|
101
|
+
deadline = time.monotonic() + timeout
|
|
102
|
+
delay = 0.05
|
|
103
|
+
while True:
|
|
104
|
+
try:
|
|
105
|
+
return fn(*args)
|
|
106
|
+
except pywintypes.com_error as exc:
|
|
107
|
+
if exc.args[0] not in _BUSY or time.monotonic() >= deadline:
|
|
108
|
+
raise
|
|
109
|
+
logger.debug("COM server busy; retrying in %.2fs", delay)
|
|
110
|
+
time.sleep(delay)
|
|
111
|
+
delay = min(delay * 2, 1.0)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------------------------------------ gateway
|
|
115
|
+
class Com:
|
|
116
|
+
"""Per-engine gateway: wraps operations with error translation and dialog monitoring."""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
*,
|
|
121
|
+
monitor: CallMonitor | None = None,
|
|
122
|
+
dao_errors: Callable[[], Sequence[DaoError]] | None = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
self._monitor = monitor
|
|
125
|
+
self._dao_errors = dao_errors
|
|
126
|
+
self._depth = 0
|
|
127
|
+
|
|
128
|
+
def _read_dao_errors(self) -> Sequence[DaoError]:
|
|
129
|
+
if self._dao_errors is None:
|
|
130
|
+
return ()
|
|
131
|
+
try:
|
|
132
|
+
return self._dao_errors()
|
|
133
|
+
except Exception:
|
|
134
|
+
return ()
|
|
135
|
+
|
|
136
|
+
@contextmanager
|
|
137
|
+
def op(
|
|
138
|
+
self,
|
|
139
|
+
action: str,
|
|
140
|
+
*,
|
|
141
|
+
kind: ObjectKind | None = None,
|
|
142
|
+
name: str | None = None,
|
|
143
|
+
path: Path | None = None,
|
|
144
|
+
sql: str | None = None,
|
|
145
|
+
) -> Iterator[None]:
|
|
146
|
+
"""Run a group of COM calls as one operation (see module docstring)."""
|
|
147
|
+
ctx = OpContext(action, kind, name, path, sql)
|
|
148
|
+
monitor = self._monitor
|
|
149
|
+
outermost = self._depth == 0
|
|
150
|
+
self._depth += 1
|
|
151
|
+
if monitor is not None and outermost:
|
|
152
|
+
monitor.begin(action)
|
|
153
|
+
ended = False
|
|
154
|
+
|
|
155
|
+
def finish() -> tuple[DialogInfo, ...]:
|
|
156
|
+
nonlocal ended
|
|
157
|
+
if ended or monitor is None or not outermost:
|
|
158
|
+
return ()
|
|
159
|
+
ended = True
|
|
160
|
+
return monitor.end()
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
logger.debug("COM op: %s", action)
|
|
164
|
+
yield
|
|
165
|
+
except pywintypes.com_error as exc:
|
|
166
|
+
dialogs = finish()
|
|
167
|
+
error = translate(exc, ctx, self._read_dao_errors())
|
|
168
|
+
termination = monitor.termination if monitor is not None else None
|
|
169
|
+
if termination is not None and termination[0] == "timeout":
|
|
170
|
+
raise AccessTimeoutError(
|
|
171
|
+
f"cannot {action}: {termination[1]}; the owned Access process was terminated",
|
|
172
|
+
operation=action,
|
|
173
|
+
details=error.details,
|
|
174
|
+
) from error
|
|
175
|
+
if dialogs:
|
|
176
|
+
raise AccessDialogError(
|
|
177
|
+
f"cannot {action}: Microsoft Access showed a dialog",
|
|
178
|
+
dialogs=dialogs,
|
|
179
|
+
operation=action,
|
|
180
|
+
) from error
|
|
181
|
+
raise error from exc
|
|
182
|
+
except BaseException:
|
|
183
|
+
finish()
|
|
184
|
+
raise
|
|
185
|
+
finally:
|
|
186
|
+
self._depth -= 1
|
|
187
|
+
dialogs = finish()
|
|
188
|
+
if dialogs and monitor is not None:
|
|
189
|
+
if monitor.policy is DialogPolicy.FAIL:
|
|
190
|
+
raise AccessDialogError(
|
|
191
|
+
f"{action}: Microsoft Access showed a dialog that was dismissed automatically",
|
|
192
|
+
dialogs=dialogs,
|
|
193
|
+
operation=action,
|
|
194
|
+
)
|
|
195
|
+
if monitor.policy is DialogPolicy.WARN:
|
|
196
|
+
for dialog in dialogs:
|
|
197
|
+
warnings.warn(
|
|
198
|
+
f"{action}: {dialog.summary()}", AccessDialogWarning, stacklevel=3
|
|
199
|
+
)
|
pyaccesskit/_com/raw.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# pyright: basic
|
|
2
|
+
"""Revocable proxies for the ``db.raw`` escape hatch.
|
|
3
|
+
|
|
4
|
+
Raw COM objects handed to user code are wrapped so that closing (or upgrading) the session can *revoke*
|
|
5
|
+
them: every proxy — including the ones derived from it (``db.raw.dao.TableDefs(0).Fields``...) — drops its
|
|
6
|
+
COM reference and raises :class:`~pyaccesskit.errors.SessionClosedError` afterwards. User variables can
|
|
7
|
+
therefore never keep an Access process alive or be used against a closed database.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
import weakref
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from pyaccesskit.errors import SessionClosedError
|
|
18
|
+
|
|
19
|
+
__all__ = ["ProxyRegistry", "RevocableIterator", "RevocableProxy"]
|
|
20
|
+
|
|
21
|
+
_SLOTS = ("_pak_target", "_pak_registry", "_pak_label", "__weakref__")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProxyRegistry:
|
|
25
|
+
"""Tracks every proxy created for a session so they can all be revoked at once."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._proxies: weakref.WeakSet[RevocableProxy | RevocableIterator] = weakref.WeakSet()
|
|
29
|
+
self._lock = threading.Lock()
|
|
30
|
+
self.revoked = False
|
|
31
|
+
|
|
32
|
+
def track(self, proxy: RevocableProxy | RevocableIterator) -> None:
|
|
33
|
+
with self._lock:
|
|
34
|
+
self._proxies.add(proxy)
|
|
35
|
+
|
|
36
|
+
def revoke_all(self) -> int:
|
|
37
|
+
"""Revoke every live proxy; returns how many were revoked."""
|
|
38
|
+
with self._lock:
|
|
39
|
+
proxies = list(self._proxies)
|
|
40
|
+
self._proxies.clear()
|
|
41
|
+
self.revoked = True
|
|
42
|
+
for proxy in proxies:
|
|
43
|
+
object.__setattr__(
|
|
44
|
+
proxy, "_pak_target", None
|
|
45
|
+
) # iterators drop their COM enumerator too
|
|
46
|
+
return len(proxies)
|
|
47
|
+
|
|
48
|
+
def wrap(self, value: Any, label: str) -> Any:
|
|
49
|
+
"""Wrap COM objects and bound COM methods; return plain values unchanged."""
|
|
50
|
+
if value is None or isinstance(value, (str, bytes, int, float, bool, tuple, list, dict)):
|
|
51
|
+
return value
|
|
52
|
+
if hasattr(value, "_oleobj_") or callable(value):
|
|
53
|
+
proxy = RevocableProxy(value, self, label)
|
|
54
|
+
self.track(proxy)
|
|
55
|
+
return proxy
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _unwrap(value: Any) -> Any:
|
|
60
|
+
if isinstance(value, RevocableProxy):
|
|
61
|
+
return value._pak_require()
|
|
62
|
+
if isinstance(value, (list, tuple)):
|
|
63
|
+
return type(value)(_unwrap(item) for item in value)
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RevocableProxy:
|
|
68
|
+
"""A transparent, revocable stand-in for a COM object (or one of its methods)."""
|
|
69
|
+
|
|
70
|
+
__slots__ = _SLOTS
|
|
71
|
+
|
|
72
|
+
def __init__(self, target: Any, registry: ProxyRegistry, label: str) -> None:
|
|
73
|
+
object.__setattr__(self, "_pak_target", target)
|
|
74
|
+
object.__setattr__(self, "_pak_registry", registry)
|
|
75
|
+
object.__setattr__(self, "_pak_label", label)
|
|
76
|
+
|
|
77
|
+
def _pak_require(self) -> Any:
|
|
78
|
+
target = object.__getattribute__(self, "_pak_target")
|
|
79
|
+
if target is None:
|
|
80
|
+
label = object.__getattribute__(self, "_pak_label")
|
|
81
|
+
raise SessionClosedError(
|
|
82
|
+
f"raw COM object {label} was revoked because its PyAccessKit session was closed or upgraded"
|
|
83
|
+
)
|
|
84
|
+
return target
|
|
85
|
+
|
|
86
|
+
def __getattr__(self, name: str) -> Any:
|
|
87
|
+
target = self._pak_require()
|
|
88
|
+
label = object.__getattribute__(self, "_pak_label")
|
|
89
|
+
registry: ProxyRegistry = object.__getattribute__(self, "_pak_registry")
|
|
90
|
+
return registry.wrap(getattr(target, name), f"{label}.{name}")
|
|
91
|
+
|
|
92
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
93
|
+
setattr(self._pak_require(), name, _unwrap(value))
|
|
94
|
+
|
|
95
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
96
|
+
target = self._pak_require()
|
|
97
|
+
label = object.__getattribute__(self, "_pak_label")
|
|
98
|
+
registry: ProxyRegistry = object.__getattribute__(self, "_pak_registry")
|
|
99
|
+
result = target(*_unwrap(args), **{k: _unwrap(v) for k, v in kwargs.items()})
|
|
100
|
+
return registry.wrap(result, f"{label}(...)")
|
|
101
|
+
|
|
102
|
+
def __iter__(self) -> Iterator[Any]:
|
|
103
|
+
target = self._pak_require()
|
|
104
|
+
label = object.__getattribute__(self, "_pak_label")
|
|
105
|
+
registry: ProxyRegistry = object.__getattribute__(self, "_pak_registry")
|
|
106
|
+
iterator = RevocableIterator(iter(target), registry, label)
|
|
107
|
+
registry.track(iterator)
|
|
108
|
+
return iterator
|
|
109
|
+
|
|
110
|
+
def __getitem__(self, key: Any) -> Any:
|
|
111
|
+
target = self._pak_require()
|
|
112
|
+
label = object.__getattribute__(self, "_pak_label")
|
|
113
|
+
registry: ProxyRegistry = object.__getattribute__(self, "_pak_registry")
|
|
114
|
+
return registry.wrap(target[_unwrap(key)], f"{label}[{key!r}]")
|
|
115
|
+
|
|
116
|
+
def __len__(self) -> int:
|
|
117
|
+
return len(self._pak_require())
|
|
118
|
+
|
|
119
|
+
def __bool__(self) -> bool:
|
|
120
|
+
return object.__getattribute__(self, "_pak_target") is not None
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def revoked(self) -> bool:
|
|
124
|
+
"""Whether the underlying COM object has been released."""
|
|
125
|
+
return object.__getattribute__(self, "_pak_target") is None
|
|
126
|
+
|
|
127
|
+
def __repr__(self) -> str:
|
|
128
|
+
label = object.__getattribute__(self, "_pak_label")
|
|
129
|
+
state = "revoked" if self.revoked else "live"
|
|
130
|
+
return f"<pyaccesskit raw {label} ({state})>"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class RevocableIterator:
|
|
134
|
+
"""Iterates a raw COM collection; revoking it drops the enumerator, so resuming raises."""
|
|
135
|
+
|
|
136
|
+
__slots__ = ("_pak_index", *_SLOTS)
|
|
137
|
+
|
|
138
|
+
def __init__(self, target: Iterator[Any], registry: ProxyRegistry, label: str) -> None:
|
|
139
|
+
object.__setattr__(self, "_pak_target", target)
|
|
140
|
+
object.__setattr__(self, "_pak_registry", registry)
|
|
141
|
+
object.__setattr__(self, "_pak_label", label)
|
|
142
|
+
object.__setattr__(self, "_pak_index", 0)
|
|
143
|
+
|
|
144
|
+
def __iter__(self) -> RevocableIterator:
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
def __next__(self) -> Any:
|
|
148
|
+
target = object.__getattribute__(self, "_pak_target")
|
|
149
|
+
label = object.__getattribute__(self, "_pak_label")
|
|
150
|
+
if target is None:
|
|
151
|
+
raise SessionClosedError(
|
|
152
|
+
f"iteration over raw COM object {label} was revoked because its PyAccessKit session was "
|
|
153
|
+
"closed or upgraded"
|
|
154
|
+
)
|
|
155
|
+
item = next(target)
|
|
156
|
+
index = object.__getattribute__(self, "_pak_index")
|
|
157
|
+
object.__setattr__(self, "_pak_index", index + 1)
|
|
158
|
+
registry: ProxyRegistry = object.__getattribute__(self, "_pak_registry")
|
|
159
|
+
return registry.wrap(item, f"{label}[{index}]")
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def revoked(self) -> bool:
|
|
163
|
+
"""Whether the underlying enumerator has been released."""
|
|
164
|
+
return object.__getattribute__(self, "_pak_target") is None
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# pyright: basic
|
|
2
|
+
"""pywin32 availability and per-thread COM initialisation.
|
|
3
|
+
|
|
4
|
+
This package (``pyaccesskit._com``) is the only place, together with ``_win``, ``_engines`` and the COM
|
|
5
|
+
backends, that imports pywin32. It is imported lazily when a real engine is created, so
|
|
6
|
+
``import pyaccesskit`` works on any platform.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
13
|
+
|
|
14
|
+
import pythoncom
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("pyaccesskit.com")
|
|
17
|
+
|
|
18
|
+
__all__ = ["ensure_com_initialized"]
|
|
19
|
+
|
|
20
|
+
_RPC_E_CHANGED_MODE = -2147417850 # 0x80010106
|
|
21
|
+
_state = threading.local()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ensure_com_initialized() -> None:
|
|
25
|
+
"""Initialise COM (single-threaded apartment) on the calling thread, once.
|
|
26
|
+
|
|
27
|
+
``import pythoncom`` already initialises the importing thread; worker threads need this call. COM is
|
|
28
|
+
never uninitialised by PyAccessKit (other code on the thread may still hold COM objects). If the thread
|
|
29
|
+
was initialised as MTA by someone else, calls still work through COM marshalling.
|
|
30
|
+
"""
|
|
31
|
+
if getattr(_state, "initialized", False):
|
|
32
|
+
return
|
|
33
|
+
try:
|
|
34
|
+
pythoncom.CoInitializeEx(pythoncom.COINIT_APARTMENTTHREADED)
|
|
35
|
+
except pythoncom.com_error as exc:
|
|
36
|
+
if exc.args[0] != _RPC_E_CHANGED_MODE:
|
|
37
|
+
raise
|
|
38
|
+
logger.debug("thread %s is already a multithreaded COM apartment", threading.get_ident())
|
|
39
|
+
_state.initialized = True
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# pyright: basic
|
|
2
|
+
"""Conversion of values between Python and COM VARIANTs.
|
|
3
|
+
|
|
4
|
+
Access date/times are *wall-clock* values without a time zone. pywin32, however, treats a naive ``datetime``
|
|
5
|
+
as local time and converts it to UTC when it builds a ``VT_DATE`` — shifting the stored value by the UTC
|
|
6
|
+
offset (an integration test caught a 13-hour shift). :func:`to_variant` therefore passes wall-clock values
|
|
7
|
+
as UTC-tagged datetimes, which pywin32 stores unchanged; :func:`normalize` reads the wall-clock back.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import UTC, date, datetime, time
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import pywintypes
|
|
16
|
+
|
|
17
|
+
__all__ = ["normalize", "to_variant"]
|
|
18
|
+
|
|
19
|
+
_OLE_EPOCH = date(1899, 12, 30)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def to_variant(value: Any) -> Any:
|
|
23
|
+
"""Prepare a Python value for COM without time-zone shifts.
|
|
24
|
+
|
|
25
|
+
* naive ``datetime`` → the same wall-clock time, tagged UTC;
|
|
26
|
+
* aware ``datetime`` → converted to local wall-clock time first (Access has no time zones);
|
|
27
|
+
* ``date`` → midnight; ``time`` → a time on the OLE epoch day (how Access stores times of day).
|
|
28
|
+
"""
|
|
29
|
+
if isinstance(value, (bytearray, memoryview)):
|
|
30
|
+
return bytes(value)
|
|
31
|
+
if isinstance(value, datetime):
|
|
32
|
+
if value.tzinfo is not None:
|
|
33
|
+
value = value.astimezone().replace(tzinfo=None)
|
|
34
|
+
return value.replace(tzinfo=UTC)
|
|
35
|
+
if isinstance(value, date):
|
|
36
|
+
return datetime(value.year, value.month, value.day, tzinfo=UTC)
|
|
37
|
+
if isinstance(value, time):
|
|
38
|
+
return datetime(
|
|
39
|
+
_OLE_EPOCH.year,
|
|
40
|
+
_OLE_EPOCH.month,
|
|
41
|
+
_OLE_EPOCH.day,
|
|
42
|
+
value.hour,
|
|
43
|
+
value.minute,
|
|
44
|
+
value.second,
|
|
45
|
+
tzinfo=UTC,
|
|
46
|
+
)
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def normalize(value: Any) -> Any:
|
|
51
|
+
"""Convert pywin32 return values to plain Python values.
|
|
52
|
+
|
|
53
|
+
* ``pywintypes.datetime`` (VT_DATE) → naive :class:`datetime` with the stored wall-clock time;
|
|
54
|
+
* byte arrays (OLE Object / binary fields arrive as ``memoryview``) → :class:`bytes`;
|
|
55
|
+
* tuples (SAFEARRAYs) are normalised recursively;
|
|
56
|
+
* everything else (``Decimal`` for currency/decimal, ``None`` for Null/Empty...) is returned unchanged.
|
|
57
|
+
"""
|
|
58
|
+
if isinstance(value, memoryview):
|
|
59
|
+
return value.tobytes()
|
|
60
|
+
if isinstance(value, pywintypes.TimeType):
|
|
61
|
+
return datetime(
|
|
62
|
+
value.year,
|
|
63
|
+
value.month,
|
|
64
|
+
value.day,
|
|
65
|
+
value.hour,
|
|
66
|
+
value.minute,
|
|
67
|
+
value.second,
|
|
68
|
+
value.microsecond,
|
|
69
|
+
)
|
|
70
|
+
if isinstance(value, tuple):
|
|
71
|
+
return tuple(normalize(item) for item in value)
|
|
72
|
+
return value
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# pyright: basic
|
|
2
|
+
"""Engine construction (COM). Imported lazily by :mod:`pyaccesskit._session`."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pyaccesskit._session.protocols import EnginePlan
|
|
9
|
+
|
|
10
|
+
__all__ = ["open_engine"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def open_engine(plan: EnginePlan) -> Any:
|
|
14
|
+
"""Create the engine described by ``plan`` (see :class:`EnginePlan`)."""
|
|
15
|
+
if plan.kind == "dao":
|
|
16
|
+
from pyaccesskit._engines.inproc import InProcDaoEngine
|
|
17
|
+
|
|
18
|
+
return InProcDaoEngine(
|
|
19
|
+
plan.path,
|
|
20
|
+
create=plan.create,
|
|
21
|
+
readonly=plan.readonly,
|
|
22
|
+
exclusive=plan.exclusive,
|
|
23
|
+
password=plan.password,
|
|
24
|
+
native_defaults=plan.options.apply_native_defaults,
|
|
25
|
+
on_created=plan.on_created,
|
|
26
|
+
)
|
|
27
|
+
from pyaccesskit._engines.access import AccessEngine
|
|
28
|
+
from pyaccesskit._win.access_process import AccessLaunchOptions
|
|
29
|
+
|
|
30
|
+
options = plan.options
|
|
31
|
+
return AccessEngine(
|
|
32
|
+
plan.path,
|
|
33
|
+
create=plan.create,
|
|
34
|
+
readonly=plan.readonly,
|
|
35
|
+
exclusive=plan.exclusive,
|
|
36
|
+
password=plan.password,
|
|
37
|
+
design=plan.design,
|
|
38
|
+
on_created=plan.on_created,
|
|
39
|
+
options=AccessLaunchOptions(
|
|
40
|
+
progid=options.access_progid,
|
|
41
|
+
visible=options.visible,
|
|
42
|
+
macro_security=options.macro_security,
|
|
43
|
+
dialog_policy=options.dialog_policy,
|
|
44
|
+
call_timeout=options.call_timeout,
|
|
45
|
+
quit_timeout=options.quit_timeout,
|
|
46
|
+
kill_on_parent_exit=options.kill_on_parent_exit,
|
|
47
|
+
),
|
|
48
|
+
)
|