pyaccesskit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. pyaccesskit/AGENT_GUIDE.md +455 -0
  2. pyaccesskit/__init__.py +167 -0
  3. pyaccesskit/__main__.py +6 -0
  4. pyaccesskit/_backends/__init__.py +0 -0
  5. pyaccesskit/_backends/access/__init__.py +1 -0
  6. pyaccesskit/_backends/access/design.py +415 -0
  7. pyaccesskit/_backends/dao/__init__.py +1 -0
  8. pyaccesskit/_backends/dao/profile.py +40 -0
  9. pyaccesskit/_backends/dao/schema.py +805 -0
  10. pyaccesskit/_backends/dao/typemap.py +390 -0
  11. pyaccesskit/_backends/fake/__init__.py +3 -0
  12. pyaccesskit/_backends/fake/backend.py +680 -0
  13. pyaccesskit/_backends/protocols.py +339 -0
  14. pyaccesskit/_com/__init__.py +1 -0
  15. pyaccesskit/_com/constants.py +394 -0
  16. pyaccesskit/_com/dispatch.py +50 -0
  17. pyaccesskit/_com/errors.py +184 -0
  18. pyaccesskit/_com/gateway.py +199 -0
  19. pyaccesskit/_com/raw.py +164 -0
  20. pyaccesskit/_com/runtime.py +39 -0
  21. pyaccesskit/_com/variants.py +72 -0
  22. pyaccesskit/_engines/__init__.py +48 -0
  23. pyaccesskit/_engines/access.py +300 -0
  24. pyaccesskit/_engines/inproc.py +148 -0
  25. pyaccesskit/_engines/probe.py +231 -0
  26. pyaccesskit/_ledger.py +158 -0
  27. pyaccesskit/_ops/__init__.py +0 -0
  28. pyaccesskit/_ops/design.py +127 -0
  29. pyaccesskit/_ops/schema.py +471 -0
  30. pyaccesskit/_session/__init__.py +1 -0
  31. pyaccesskit/_session/protocols.py +78 -0
  32. pyaccesskit/_session/session.py +354 -0
  33. pyaccesskit/_text/__init__.py +0 -0
  34. pyaccesskit/_text/codec.py +114 -0
  35. pyaccesskit/_version.py +3 -0
  36. pyaccesskit/_win/__init__.py +1 -0
  37. pyaccesskit/_win/access_process.py +348 -0
  38. pyaccesskit/_win/console.py +56 -0
  39. pyaccesskit/_win/inspector.py +53 -0
  40. pyaccesskit/_win/job.py +65 -0
  41. pyaccesskit/_win/processes.py +159 -0
  42. pyaccesskit/_win/watchdog.py +253 -0
  43. pyaccesskit/cli/__init__.py +10 -0
  44. pyaccesskit/cli/_output.py +101 -0
  45. pyaccesskit/cli/agent.py +99 -0
  46. pyaccesskit/cli/app.py +54 -0
  47. pyaccesskit/cli/cleanup.py +56 -0
  48. pyaccesskit/cli/doctor.py +101 -0
  49. pyaccesskit/cli/inspection.py +223 -0
  50. pyaccesskit/database.py +296 -0
  51. pyaccesskit/diagnostics.py +319 -0
  52. pyaccesskit/enums.py +258 -0
  53. pyaccesskit/errors.py +407 -0
  54. pyaccesskit/forms/__init__.py +45 -0
  55. pyaccesskit/forms/builder.py +295 -0
  56. pyaccesskit/forms/collection.py +117 -0
  57. pyaccesskit/forms/controls.py +157 -0
  58. pyaccesskit/forms/layout.py +300 -0
  59. pyaccesskit/forms/spec.py +169 -0
  60. pyaccesskit/forms/vba.py +138 -0
  61. pyaccesskit/maintenance.py +32 -0
  62. pyaccesskit/modules.py +101 -0
  63. pyaccesskit/objects.py +81 -0
  64. pyaccesskit/options.py +40 -0
  65. pyaccesskit/properties.py +74 -0
  66. pyaccesskit/py.typed +0 -0
  67. pyaccesskit/queries.py +190 -0
  68. pyaccesskit/relationships.py +143 -0
  69. pyaccesskit/schema/__init__.py +73 -0
  70. pyaccesskit/schema/_base.py +55 -0
  71. pyaccesskit/schema/_reserved_words.py +55 -0
  72. pyaccesskit/schema/columns.py +609 -0
  73. pyaccesskit/schema/compat.py +57 -0
  74. pyaccesskit/schema/expressions.py +162 -0
  75. pyaccesskit/schema/indexes.py +114 -0
  76. pyaccesskit/schema/names.py +122 -0
  77. pyaccesskit/schema/queries.py +192 -0
  78. pyaccesskit/schema/relationships.py +132 -0
  79. pyaccesskit/schema/tables.py +178 -0
  80. pyaccesskit/tables.py +333 -0
  81. pyaccesskit/units.py +301 -0
  82. pyaccesskit-0.1.0.dist-info/METADATA +201 -0
  83. pyaccesskit-0.1.0.dist-info/RECORD +86 -0
  84. pyaccesskit-0.1.0.dist-info/WHEEL +4 -0
  85. pyaccesskit-0.1.0.dist-info/entry_points.txt +2 -0
  86. pyaccesskit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,300 @@
1
+ """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
+ )
@@ -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)