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
|
+
"""Form layout engine: turns a :class:`FormSpec` into exact control geometry.
|
|
2
|
+
|
|
3
|
+
The engine is pure Python — it never talks to Access — so layouts can be unit-tested and previewed. Two
|
|
4
|
+
automatic layouts mirror Access's own control layouts:
|
|
5
|
+
|
|
6
|
+
* **stacked** (single/split forms): one control per row, attached label on the left;
|
|
7
|
+
* **tabular** (continuous/datasheet forms): labels in the form header, one row of controls in the detail.
|
|
8
|
+
|
|
9
|
+
Controls with an explicit ``at=`` keep their position; their attached label goes to their left.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from pyaccesskit.enums import FormView, LayoutKind, Section
|
|
17
|
+
from pyaccesskit.errors import SpecError
|
|
18
|
+
from pyaccesskit.forms.controls import ButtonSpec, CheckBoxSpec, ControlSpec, LabelSpec
|
|
19
|
+
from pyaccesskit.forms.spec import FormSpec, label_name_for
|
|
20
|
+
from pyaccesskit.forms.vba import EventBinding
|
|
21
|
+
from pyaccesskit.units import Length, cm, inch
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"DEFAULT_METRICS",
|
|
25
|
+
"MAX_FORM_EXTENT",
|
|
26
|
+
"LayoutMetrics",
|
|
27
|
+
"Rect",
|
|
28
|
+
"ResolvedControl",
|
|
29
|
+
"ResolvedForm",
|
|
30
|
+
"ResolvedLabel",
|
|
31
|
+
"layout_form",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
MAX_FORM_EXTENT = inch(22)
|
|
35
|
+
"""Access limit for form width and for each section's height."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class LayoutMetrics:
|
|
40
|
+
"""Spacing and default sizes used by the automatic layouts."""
|
|
41
|
+
|
|
42
|
+
margin: Length = field(default_factory=lambda: cm(0.5))
|
|
43
|
+
label_width: Length = field(default_factory=lambda: cm(3.5))
|
|
44
|
+
label_gap: Length = field(default_factory=lambda: cm(0.25))
|
|
45
|
+
control_width: Length = field(default_factory=lambda: cm(6))
|
|
46
|
+
row_height: Length = field(default_factory=lambda: cm(0.6))
|
|
47
|
+
row_gap: Length = field(default_factory=lambda: cm(0.2))
|
|
48
|
+
checkbox_size: Length = field(default_factory=lambda: cm(0.45))
|
|
49
|
+
button_width: Length = field(default_factory=lambda: cm(3))
|
|
50
|
+
button_height: Length = field(default_factory=lambda: cm(0.8))
|
|
51
|
+
column_gap: Length = field(default_factory=lambda: cm(0.25))
|
|
52
|
+
tabular_column_width: Length = field(default_factory=lambda: cm(3.5))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
DEFAULT_METRICS = LayoutMetrics()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Rect:
|
|
60
|
+
"""A rectangle in twips-backed lengths."""
|
|
61
|
+
|
|
62
|
+
left: Length
|
|
63
|
+
top: Length
|
|
64
|
+
width: Length
|
|
65
|
+
height: Length
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def right(self) -> Length:
|
|
69
|
+
"""Right edge."""
|
|
70
|
+
return self.left + self.width
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def bottom(self) -> Length:
|
|
74
|
+
"""Bottom edge."""
|
|
75
|
+
return self.top + self.height
|
|
76
|
+
|
|
77
|
+
def overlaps(self, other: Rect) -> bool:
|
|
78
|
+
"""Whether the two rectangles share any area."""
|
|
79
|
+
return (
|
|
80
|
+
self.left < other.right
|
|
81
|
+
and other.left < self.right
|
|
82
|
+
and self.top < other.bottom
|
|
83
|
+
and other.top < self.bottom
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class ResolvedLabel:
|
|
89
|
+
"""An attached label with its final geometry."""
|
|
90
|
+
|
|
91
|
+
name: str
|
|
92
|
+
caption: str
|
|
93
|
+
section: Section
|
|
94
|
+
rect: Rect
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class ResolvedControl:
|
|
99
|
+
"""A control with its final name, section and geometry."""
|
|
100
|
+
|
|
101
|
+
spec: ControlSpec
|
|
102
|
+
name: str
|
|
103
|
+
section: Section
|
|
104
|
+
rect: Rect
|
|
105
|
+
label: ResolvedLabel | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class ResolvedForm:
|
|
110
|
+
"""Everything the Access materializer needs to build the form."""
|
|
111
|
+
|
|
112
|
+
spec: FormSpec
|
|
113
|
+
width: Length
|
|
114
|
+
detail_height: Length
|
|
115
|
+
header_height: Length | None
|
|
116
|
+
"""``None`` means the form has no header/footer sections."""
|
|
117
|
+
footer_height: Length | None
|
|
118
|
+
controls: tuple[ResolvedControl, ...]
|
|
119
|
+
events: tuple[EventBinding, ...]
|
|
120
|
+
module_text: str | None
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def has_header(self) -> bool:
|
|
124
|
+
"""Whether the form needs header/footer sections."""
|
|
125
|
+
return self.header_height is not None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _default_size(
|
|
129
|
+
control: ControlSpec, m: LayoutMetrics, *, tabular: bool
|
|
130
|
+
) -> tuple[Length, Length]:
|
|
131
|
+
if isinstance(control, CheckBoxSpec):
|
|
132
|
+
width, height = m.checkbox_size, m.checkbox_size
|
|
133
|
+
elif isinstance(control, ButtonSpec):
|
|
134
|
+
width, height = m.button_width, m.button_height
|
|
135
|
+
elif isinstance(control, LabelSpec):
|
|
136
|
+
width = m.tabular_column_width if tabular else m.label_width + m.label_gap + m.control_width
|
|
137
|
+
height = m.row_height
|
|
138
|
+
else:
|
|
139
|
+
width, height = (m.tabular_column_width if tabular else m.control_width), m.row_height
|
|
140
|
+
return control.width or width, control.height or height
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _effective_layout(spec: FormSpec) -> LayoutKind:
|
|
144
|
+
if spec.layout is not LayoutKind.AUTO:
|
|
145
|
+
return spec.layout
|
|
146
|
+
if spec.default_view in (FormView.CONTINUOUS, FormView.DATASHEET):
|
|
147
|
+
return LayoutKind.TABULAR
|
|
148
|
+
return LayoutKind.STACKED
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def layout_form(spec: FormSpec, metrics: LayoutMetrics = DEFAULT_METRICS) -> ResolvedForm:
|
|
152
|
+
"""Compute the geometry of every control of ``spec``.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
SpecError: If a control cannot be placed or the form exceeds Access's size limits.
|
|
156
|
+
"""
|
|
157
|
+
m = metrics
|
|
158
|
+
kind = _effective_layout(spec)
|
|
159
|
+
tabular = kind is LayoutKind.TABULAR
|
|
160
|
+
controls = spec.resolved_controls()
|
|
161
|
+
cursors: dict[Section, Length] = dict.fromkeys(Section, m.margin)
|
|
162
|
+
tab_x = m.margin
|
|
163
|
+
placed: list[ResolvedControl] = []
|
|
164
|
+
label_x = m.margin
|
|
165
|
+
control_x = m.margin + m.label_width + m.label_gap
|
|
166
|
+
|
|
167
|
+
for control in controls:
|
|
168
|
+
name = control.name or ""
|
|
169
|
+
width, height = _default_size(control, m, tabular=tabular)
|
|
170
|
+
caption = control.attached_label
|
|
171
|
+
section = control.section
|
|
172
|
+
|
|
173
|
+
if control.at is not None:
|
|
174
|
+
left, top = control.at
|
|
175
|
+
rect = Rect(left, top, width, height)
|
|
176
|
+
label = None
|
|
177
|
+
if caption is not None:
|
|
178
|
+
label_left = left - m.label_gap - m.label_width
|
|
179
|
+
if label_left.twips < 0:
|
|
180
|
+
raise SpecError(
|
|
181
|
+
f"no room for the label of {name!r} left of its position; move it right, "
|
|
182
|
+
"pass label=False, or add a free-standing label"
|
|
183
|
+
)
|
|
184
|
+
label = ResolvedLabel(
|
|
185
|
+
label_name_for(name),
|
|
186
|
+
caption,
|
|
187
|
+
section,
|
|
188
|
+
Rect(label_left, top, m.label_width, m.row_height),
|
|
189
|
+
)
|
|
190
|
+
placed.append(ResolvedControl(control, name, section, rect, label))
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
if kind is LayoutKind.NONE:
|
|
194
|
+
raise SpecError(f"layout='none' requires at=(left, top) for control {name!r}")
|
|
195
|
+
|
|
196
|
+
if tabular and section is Section.DETAIL:
|
|
197
|
+
# A column is as wide as the wider of its header label and its control; narrow controls
|
|
198
|
+
# (check boxes) are centred in their column.
|
|
199
|
+
column_width = (
|
|
200
|
+
width if isinstance(control, ButtonSpec) else max(width, m.tabular_column_width)
|
|
201
|
+
)
|
|
202
|
+
header_label = None
|
|
203
|
+
if caption is not None or isinstance(control, LabelSpec):
|
|
204
|
+
label_rect = Rect(tab_x, m.margin, column_width, m.row_height)
|
|
205
|
+
if isinstance(control, LabelSpec):
|
|
206
|
+
placed.append(ResolvedControl(control, name, Section.HEADER, label_rect))
|
|
207
|
+
tab_x = tab_x + column_width + m.column_gap
|
|
208
|
+
continue
|
|
209
|
+
header_label = ResolvedLabel(
|
|
210
|
+
label_name_for(name), caption or name, Section.HEADER, label_rect
|
|
211
|
+
)
|
|
212
|
+
left = tab_x + Length((column_width.twips - width.twips) // 2)
|
|
213
|
+
placed.append(
|
|
214
|
+
ResolvedControl(
|
|
215
|
+
control, name, section, Rect(left, m.row_gap, width, height), header_label
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
tab_x = tab_x + column_width + m.column_gap
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
# stacked (or tabular header/footer controls, which stack within their section)
|
|
222
|
+
top = cursors[section]
|
|
223
|
+
row_height = max(height, m.row_height)
|
|
224
|
+
if isinstance(control, LabelSpec):
|
|
225
|
+
rect = Rect(label_x, top, width, height)
|
|
226
|
+
placed.append(ResolvedControl(control, name, section, rect))
|
|
227
|
+
else:
|
|
228
|
+
offset = (
|
|
229
|
+
Length((row_height.twips - height.twips) // 2)
|
|
230
|
+
if isinstance(control, CheckBoxSpec)
|
|
231
|
+
else Length(0)
|
|
232
|
+
)
|
|
233
|
+
rect = Rect(control_x, top + offset, width, height)
|
|
234
|
+
label = None
|
|
235
|
+
if caption is not None:
|
|
236
|
+
label = ResolvedLabel(
|
|
237
|
+
label_name_for(name),
|
|
238
|
+
caption,
|
|
239
|
+
section,
|
|
240
|
+
Rect(label_x, top, m.label_width, m.row_height),
|
|
241
|
+
)
|
|
242
|
+
placed.append(ResolvedControl(control, name, section, rect, label))
|
|
243
|
+
cursors[section] = top + row_height + m.row_gap
|
|
244
|
+
|
|
245
|
+
def extent(section: Section) -> Length | None:
|
|
246
|
+
rects = [c.rect for c in placed if c.section is section]
|
|
247
|
+
rects += [
|
|
248
|
+
c.label.rect for c in placed if c.label is not None and c.label.section is section
|
|
249
|
+
]
|
|
250
|
+
if not rects:
|
|
251
|
+
return None
|
|
252
|
+
return max(r.bottom for r in rects) + (
|
|
253
|
+
m.row_gap if tabular and section is Section.DETAIL else m.margin
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
all_rects = [c.rect for c in placed] + [c.label.rect for c in placed if c.label is not None]
|
|
257
|
+
width = max([r.right for r in all_rects], default=Length(0)) + m.margin
|
|
258
|
+
if spec.width is not None and spec.width > width:
|
|
259
|
+
width = spec.width
|
|
260
|
+
|
|
261
|
+
detail = extent(Section.DETAIL) or m.margin * 2
|
|
262
|
+
header_needed = any(
|
|
263
|
+
c.section is not Section.DETAIL
|
|
264
|
+
or (c.label is not None and c.label.section is not Section.DETAIL)
|
|
265
|
+
for c in placed
|
|
266
|
+
)
|
|
267
|
+
if spec.header is False and header_needed:
|
|
268
|
+
raise SpecError(
|
|
269
|
+
f"form {spec.name!r} has header=False, but its layout puts labels in the form header "
|
|
270
|
+
"(tabular layouts label their columns there); allow the header, pass label=False on the "
|
|
271
|
+
"controls, or use layout='stacked'"
|
|
272
|
+
)
|
|
273
|
+
has_header = spec.header if spec.header is not None else header_needed
|
|
274
|
+
header_height: Length | None = None
|
|
275
|
+
footer_height: Length | None = None
|
|
276
|
+
if has_header:
|
|
277
|
+
header_height = extent(Section.HEADER) or Length(0)
|
|
278
|
+
footer_height = extent(Section.FOOTER) or Length(0)
|
|
279
|
+
|
|
280
|
+
for label, value in (
|
|
281
|
+
("form width", width),
|
|
282
|
+
("detail height", detail),
|
|
283
|
+
("header height", header_height),
|
|
284
|
+
("footer height", footer_height),
|
|
285
|
+
):
|
|
286
|
+
if value is not None and value > MAX_FORM_EXTENT:
|
|
287
|
+
raise SpecError(
|
|
288
|
+
f"{label} {value.format('cm')} exceeds Access's limit of {MAX_FORM_EXTENT.format('cm')}"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
return ResolvedForm(
|
|
292
|
+
spec=spec,
|
|
293
|
+
width=width,
|
|
294
|
+
detail_height=detail,
|
|
295
|
+
header_height=header_height,
|
|
296
|
+
footer_height=footer_height,
|
|
297
|
+
controls=tuple(placed),
|
|
298
|
+
events=spec.event_bindings(),
|
|
299
|
+
module_text=spec.module_text(),
|
|
300
|
+
)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Form specifications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
from pydantic import Field, field_validator, model_validator
|
|
9
|
+
|
|
10
|
+
from pyaccesskit.enums import FormView, LayoutKind, ScrollBars, Section
|
|
11
|
+
from pyaccesskit.forms.controls import ButtonSpec, ControlSpec, LabelSpec
|
|
12
|
+
from pyaccesskit.forms.vba import EventBinding, Vba, build_module, is_vba_identifier
|
|
13
|
+
from pyaccesskit.schema._base import Items, PropertyValue, SpecModel
|
|
14
|
+
from pyaccesskit.schema.names import check_name
|
|
15
|
+
from pyaccesskit.units import Length
|
|
16
|
+
|
|
17
|
+
__all__ = ["FormSpec", "label_name_for"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_LABEL_SUFFIX = "_Label"
|
|
21
|
+
_MAX_NAME = 64
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def label_name_for(control_name: str) -> str:
|
|
25
|
+
"""The name of a control's attached label (``CustomerName`` → ``CustomerName_Label``).
|
|
26
|
+
|
|
27
|
+
Access names are limited to 64 characters. For long control names the name is shortened and a hash of
|
|
28
|
+
the full control name keeps it unique and stable (collisions are still checked by :class:`FormSpec`).
|
|
29
|
+
"""
|
|
30
|
+
name = f"{control_name}{_LABEL_SUFFIX}"
|
|
31
|
+
if len(name) <= _MAX_NAME:
|
|
32
|
+
return name
|
|
33
|
+
digest = hashlib.sha1(control_name.encode("utf-8"), usedforsecurity=False).hexdigest()[:8]
|
|
34
|
+
keep = _MAX_NAME - len(_LABEL_SUFFIX) - len(digest) - 1
|
|
35
|
+
return f"{control_name[:keep]}_{digest}{_LABEL_SUFFIX}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FormSpec(SpecModel):
|
|
39
|
+
"""A form built by PyAccessKit.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
name: Form name.
|
|
43
|
+
record_source: Table, query or SQL the form is bound to (``None`` for an unbound form).
|
|
44
|
+
caption: Window caption.
|
|
45
|
+
default_view: Single, continuous, datasheet or split form.
|
|
46
|
+
layout: How controls without ``at=`` are placed (``AUTO`` picks stacked or tabular).
|
|
47
|
+
header: Show the form header/footer sections; ``None`` = automatically when needed.
|
|
48
|
+
width: Minimum form width (grown to fit the controls).
|
|
49
|
+
controls: Controls in tab order.
|
|
50
|
+
on_load: VBA run by the form's Load event.
|
|
51
|
+
on_current: VBA run by the form's Current event.
|
|
52
|
+
module_code: Extra VBA appended to the form's module (helper procedures...).
|
|
53
|
+
option_explicit: Put ``Option Explicit`` at the top of the generated module.
|
|
54
|
+
properties: Other Access form properties to set verbatim (escape hatch).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
record_source: str | None = None
|
|
59
|
+
caption: str | None = None
|
|
60
|
+
default_view: FormView = FormView.SINGLE
|
|
61
|
+
layout: LayoutKind = LayoutKind.AUTO
|
|
62
|
+
header: bool | None = None
|
|
63
|
+
width: Length | None = None
|
|
64
|
+
allow_additions: bool = True
|
|
65
|
+
allow_edits: bool = True
|
|
66
|
+
allow_deletions: bool = True
|
|
67
|
+
data_entry: bool = False
|
|
68
|
+
navigation_buttons: bool = True
|
|
69
|
+
record_selectors: bool = True
|
|
70
|
+
dividing_lines: bool = False
|
|
71
|
+
scroll_bars: ScrollBars = ScrollBars.BOTH
|
|
72
|
+
auto_center: bool = True
|
|
73
|
+
pop_up: bool = False
|
|
74
|
+
modal: bool = False
|
|
75
|
+
controls: Items[ControlSpec] = ()
|
|
76
|
+
on_load: Vba | None = None
|
|
77
|
+
on_current: Vba | None = None
|
|
78
|
+
module_code: str | None = None
|
|
79
|
+
option_explicit: bool = True
|
|
80
|
+
properties: dict[str, PropertyValue] = Field(default_factory=dict)
|
|
81
|
+
|
|
82
|
+
@field_validator("name")
|
|
83
|
+
@classmethod
|
|
84
|
+
def _check_name(cls, value: str) -> str:
|
|
85
|
+
return check_name(value, what="form name")
|
|
86
|
+
|
|
87
|
+
@model_validator(mode="after")
|
|
88
|
+
def _check_form(self) -> Self:
|
|
89
|
+
seen: dict[str, str] = {}
|
|
90
|
+
|
|
91
|
+
def claim(name: str, what: str) -> None:
|
|
92
|
+
key = name.casefold()
|
|
93
|
+
if key in seen:
|
|
94
|
+
raise ValueError(f"{what} name {name!r} is already used by {seen[key]}")
|
|
95
|
+
seen[key] = what
|
|
96
|
+
|
|
97
|
+
for index, control in enumerate(self.resolved_controls()):
|
|
98
|
+
name = control.name or ""
|
|
99
|
+
claim(name, f"control #{index + 1}")
|
|
100
|
+
if control.attached_label is not None:
|
|
101
|
+
claim(label_name_for(name), f"the label of {name!r}")
|
|
102
|
+
uses_sections = any(c.section is not Section.DETAIL for c in self.controls)
|
|
103
|
+
if uses_sections and self.header is False:
|
|
104
|
+
raise ValueError("controls are placed in the header/footer but header=False")
|
|
105
|
+
for binding in self.event_bindings():
|
|
106
|
+
if binding.object_name != "Form" and not is_vba_identifier(binding.object_name):
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"control {binding.object_name!r} has an event procedure, so its name must be a plain "
|
|
109
|
+
"VBA identifier (letters, digits, underscores)"
|
|
110
|
+
)
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
# --------------------------------------------------------------------------------- derived data
|
|
114
|
+
def resolved_controls(self) -> tuple[ControlSpec, ...]:
|
|
115
|
+
"""Controls with default names filled in (bound field name, else ``<Kind><n>``)."""
|
|
116
|
+
resolved: list[ControlSpec] = []
|
|
117
|
+
counters: dict[str, int] = {}
|
|
118
|
+
for control in self.controls:
|
|
119
|
+
if control.name is not None:
|
|
120
|
+
resolved.append(control)
|
|
121
|
+
continue
|
|
122
|
+
field = control.bound_field
|
|
123
|
+
if field is not None:
|
|
124
|
+
name = field
|
|
125
|
+
elif isinstance(control, ButtonSpec):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
"buttons need an explicit name (it is used by their event procedures)"
|
|
128
|
+
)
|
|
129
|
+
else:
|
|
130
|
+
prefix = (
|
|
131
|
+
"Label"
|
|
132
|
+
if isinstance(control, LabelSpec)
|
|
133
|
+
else control.control_kind.value.capitalize()
|
|
134
|
+
)
|
|
135
|
+
counters[prefix] = counters.get(prefix, 0) + 1
|
|
136
|
+
name = f"{prefix}{counters[prefix]}"
|
|
137
|
+
resolved.append(control.model_copy(update={"name": name}))
|
|
138
|
+
return tuple(resolved)
|
|
139
|
+
|
|
140
|
+
def event_bindings(self) -> tuple[EventBinding, ...]:
|
|
141
|
+
"""Every event procedure the form needs, form events first."""
|
|
142
|
+
bindings: list[EventBinding] = []
|
|
143
|
+
if self.on_load is not None:
|
|
144
|
+
bindings.append(EventBinding("Form", "OnLoad", "Load", self.on_load))
|
|
145
|
+
if self.on_current is not None:
|
|
146
|
+
bindings.append(EventBinding("Form", "OnCurrent", "Current", self.on_current))
|
|
147
|
+
for control in self.resolved_controls():
|
|
148
|
+
name = control.name or ""
|
|
149
|
+
if isinstance(control, ButtonSpec):
|
|
150
|
+
if control.on_click is not None:
|
|
151
|
+
bindings.append(EventBinding(name, "OnClick", "Click", control.on_click))
|
|
152
|
+
elif not isinstance(control, LabelSpec) and control.after_update is not None:
|
|
153
|
+
bindings.append(
|
|
154
|
+
EventBinding(name, "AfterUpdate", "AfterUpdate", control.after_update)
|
|
155
|
+
)
|
|
156
|
+
return tuple(bindings)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def has_module(self) -> bool:
|
|
160
|
+
"""Whether the form needs a class module (event procedures or extra code)."""
|
|
161
|
+
return bool(self.event_bindings()) or bool(self.module_code and self.module_code.strip())
|
|
162
|
+
|
|
163
|
+
def module_text(self) -> str | None:
|
|
164
|
+
"""The complete VBA text of the form's module, or ``None`` if it needs none."""
|
|
165
|
+
if not self.has_module:
|
|
166
|
+
return None
|
|
167
|
+
return build_module(
|
|
168
|
+
self.event_bindings(), self.module_code, option_explicit=self.option_explicit
|
|
169
|
+
)
|
pyaccesskit/forms/vba.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# pyright: reportUnnecessaryIsInstance=false
|
|
2
|
+
# (dataclass __post_init__ and render_literal validate untyped input at runtime)
|
|
3
|
+
"""VBA snippets for event procedures and helpers that assemble form modules."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import textwrap
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from pyaccesskit.errors import SpecError
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"VBA_IDENTIFIER",
|
|
16
|
+
"EventBinding",
|
|
17
|
+
"Vba",
|
|
18
|
+
"build_module",
|
|
19
|
+
"event_procedure",
|
|
20
|
+
"is_vba_identifier",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
VBA_IDENTIFIER = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,254}$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_vba_identifier(name: str) -> bool:
|
|
27
|
+
"""Whether ``name`` can be used as-is in a VBA procedure name (``cmdSave_Click``)."""
|
|
28
|
+
return bool(VBA_IDENTIFIER.match(name))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Vba:
|
|
33
|
+
"""The body of a VBA event procedure, e.g. ``Vba("DoCmd.Close acForm, Me.Name")``.
|
|
34
|
+
|
|
35
|
+
Indentation is normalized; write the body only (no ``Sub``/``End Sub``).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
code: str
|
|
39
|
+
|
|
40
|
+
def __post_init__(self) -> None:
|
|
41
|
+
if not isinstance(self.code, str) or not self.code.strip():
|
|
42
|
+
raise SpecError("Vba requires non-empty code")
|
|
43
|
+
if re.search(r"^\s*(Private\s+|Public\s+)?Sub\s", self.code, re.IGNORECASE | re.MULTILINE):
|
|
44
|
+
raise SpecError("Vba holds a procedure *body*; do not include 'Sub ... End Sub'")
|
|
45
|
+
|
|
46
|
+
def lines(self) -> list[str]:
|
|
47
|
+
"""The body lines, dedented, without trailing blank lines."""
|
|
48
|
+
text = textwrap.dedent(self.code.replace("\r\n", "\n").replace("\r", "\n")).strip("\n")
|
|
49
|
+
return [line.rstrip() for line in text.split("\n")]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class EventBinding:
|
|
54
|
+
"""An event wired to an ``[Event Procedure]``.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
object_name: ``"Form"`` or a control name.
|
|
58
|
+
property_name: The Access event property, e.g. ``"OnClick"``.
|
|
59
|
+
event: The VBA event name, e.g. ``"Click"``.
|
|
60
|
+
body: The procedure body.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
object_name: str
|
|
64
|
+
property_name: str
|
|
65
|
+
event: str
|
|
66
|
+
body: Vba
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def procedure_name(self) -> str:
|
|
70
|
+
"""The VBA procedure name, e.g. ``cmdClose_Click``."""
|
|
71
|
+
return f"{self.object_name}_{self.event}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def event_procedure(binding: EventBinding) -> str:
|
|
75
|
+
"""Render ``Private Sub <object>_<event>() ... End Sub`` (CRLF line endings)."""
|
|
76
|
+
body = "\r\n".join(f" {line}" if line else "" for line in binding.body.lines())
|
|
77
|
+
return f"Private Sub {binding.procedure_name}()\r\n{body}\r\nEnd Sub\r\n"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_module(
|
|
81
|
+
bindings: Sequence[EventBinding], extra_code: str | None = None, *, option_explicit: bool = True
|
|
82
|
+
) -> str:
|
|
83
|
+
"""Assemble a complete class-module text for a form: options, event procedures, extra code."""
|
|
84
|
+
header = ["Option Compare Database"]
|
|
85
|
+
if option_explicit:
|
|
86
|
+
header.append("Option Explicit")
|
|
87
|
+
declarations, procedures = split_declarations(extra_code or "")
|
|
88
|
+
parts = ["\r\n".join(header) + "\r\n"]
|
|
89
|
+
# VBA only accepts module-level declarations before the first procedure.
|
|
90
|
+
if declarations:
|
|
91
|
+
parts.append(_crlf(declarations))
|
|
92
|
+
parts.extend(event_procedure(binding) for binding in bindings)
|
|
93
|
+
if procedures:
|
|
94
|
+
parts.append(_crlf(procedures))
|
|
95
|
+
return "\r\n".join(parts)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_PROCEDURE_START = re.compile(
|
|
99
|
+
r"^\s*(?:(?:Public|Private|Friend)\s+)?(?:Static\s+)?(?:Sub|Function|Property\s+(?:Get|Let|Set))\s",
|
|
100
|
+
re.IGNORECASE,
|
|
101
|
+
)
|
|
102
|
+
_DIRECTIVE_IF = re.compile(r"^\s*#If\b", re.IGNORECASE)
|
|
103
|
+
_DIRECTIVE_END_IF = re.compile(r"^\s*#End\s*If\b", re.IGNORECASE)
|
|
104
|
+
_OPTION_LINE = re.compile(r"^\s*Option\s+(?:Compare|Explicit)\b", re.IGNORECASE)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def split_declarations(code: str) -> tuple[str, str]:
|
|
108
|
+
"""Split VBA into its declarations section and its procedures (``Option`` lines are dropped).
|
|
109
|
+
|
|
110
|
+
Everything before the first ``Sub``/``Function``/``Property`` is the declarations section; comments
|
|
111
|
+
directly above that procedure stay with it. A ``#If ... #End If`` block containing that procedure is
|
|
112
|
+
never split: it moves to the procedures as a whole.
|
|
113
|
+
"""
|
|
114
|
+
lines = [
|
|
115
|
+
line
|
|
116
|
+
for line in code.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
117
|
+
if not _OPTION_LINE.match(line)
|
|
118
|
+
]
|
|
119
|
+
start = len(lines)
|
|
120
|
+
depth, block_start = 0, 0
|
|
121
|
+
for index, line in enumerate(lines):
|
|
122
|
+
if _DIRECTIVE_IF.match(line):
|
|
123
|
+
if depth == 0:
|
|
124
|
+
block_start = index
|
|
125
|
+
depth += 1
|
|
126
|
+
elif _DIRECTIVE_END_IF.match(line):
|
|
127
|
+
depth = max(depth - 1, 0)
|
|
128
|
+
elif _PROCEDURE_START.match(line):
|
|
129
|
+
# Never split inside #If ... #End If: the whole conditional block goes with the procedures.
|
|
130
|
+
start = block_start if depth else index
|
|
131
|
+
break
|
|
132
|
+
while start > 0 and lines[start - 1].lstrip().startswith("'"):
|
|
133
|
+
start -= 1
|
|
134
|
+
return "\n".join(lines[:start]).strip("\n"), "\n".join(lines[start:]).strip("\n")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _crlf(code: str) -> str:
|
|
138
|
+
return code.replace("\n", "\r\n") + "\r\n"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Maintenance helpers: cleaning up orphaned Access processes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from pyaccesskit import _ledger
|
|
8
|
+
from pyaccesskit._ledger import OwnedProcess, ReapResult
|
|
9
|
+
|
|
10
|
+
__all__ = ["OwnedProcess", "ReapResult", "owned_processes", "reap_orphans"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def owned_processes() -> list[OwnedProcess]:
|
|
14
|
+
"""Access processes currently recorded in PyAccessKit's ownership ledger (from any Python process)."""
|
|
15
|
+
return [entry for _path, entry in _ledger.load_all()]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def reap_orphans(*, dry_run: bool = False) -> list[ReapResult]:
|
|
19
|
+
"""Terminate Access processes that PyAccessKit started but whose Python owner has died.
|
|
20
|
+
|
|
21
|
+
Only processes recorded in the ownership ledger are considered, and each one is terminated only if its
|
|
22
|
+
owner is gone *and* its PID, creation time and image (``MSACCESS.EXE``) still match. Access instances
|
|
23
|
+
started by the user or by other tools are never touched.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
dry_run: Report what would be terminated without terminating anything.
|
|
27
|
+
"""
|
|
28
|
+
if sys.platform != "win32":
|
|
29
|
+
return [] # Access only runs on Windows: nothing can have been started here
|
|
30
|
+
from pyaccesskit._win.inspector import Win32Inspector
|
|
31
|
+
|
|
32
|
+
return _ledger.reap_orphans(Win32Inspector(), dry_run=dry_run)
|