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,295 @@
1
+ """Fluent builder for :class:`FormSpec` objects.
2
+
3
+ The builder never talks to Access. It accumulates controls and produces a validated :class:`FormSpec`;
4
+ when obtained from ``db.forms.create(...)`` it also knows how to *save* itself, which builds the whole form
5
+ in one atomic step (nothing is created in Access until then)::
6
+
7
+ with db.forms.create("frmCustomers", record_source="Customers") as form:
8
+ form.textbox("CustomerName", label="Customer name")
9
+ form.checkbox("IsActive")
10
+ form.button("cmdClose", caption="Close", on_click="DoCmd.Close acForm, Me.Name")
11
+ # saved here; if the block raises, nothing is created
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import warnings
17
+ from collections.abc import Callable, Sequence
18
+ from types import TracebackType
19
+ from typing import TYPE_CHECKING, Any, Generic, Literal, Self, TypeVar
20
+
21
+ from pyaccesskit.enums import RowSourceType, Section
22
+ from pyaccesskit.errors import PyAccessKitError
23
+ from pyaccesskit.forms.controls import (
24
+ ButtonSpec,
25
+ CheckBoxSpec,
26
+ ComboBoxSpec,
27
+ ControlSpec,
28
+ LabelSpec,
29
+ TextBoxSpec,
30
+ )
31
+ from pyaccesskit.forms.spec import FormSpec
32
+ from pyaccesskit.forms.vba import Vba
33
+ from pyaccesskit.schema._base import PropertyValue, build
34
+ from pyaccesskit.units import Length
35
+
36
+ if TYPE_CHECKING:
37
+ from typing_extensions import TypedDict, Unpack
38
+
39
+ class _ControlOptions(TypedDict, total=False):
40
+ name: str | None
41
+ section: Section
42
+ at: tuple[Length, Length] | None
43
+ width: Length | None
44
+ height: Length | None
45
+ visible: bool
46
+ properties: dict[str, PropertyValue]
47
+
48
+
49
+ __all__ = ["FormBuilder"]
50
+
51
+ _R = TypeVar("_R")
52
+
53
+
54
+ def _vba(code: str | Vba | None) -> Vba | None:
55
+ if code is None or isinstance(code, Vba):
56
+ return code
57
+ return Vba(code)
58
+
59
+
60
+ class FormBuilder(Generic[_R]):
61
+ """Accumulates controls for a form and produces a :class:`FormSpec`.
62
+
63
+ Args:
64
+ name: Form name.
65
+ on_save: Called with the finished spec by :meth:`save` (set by ``db.forms.create``); its return
66
+ value is returned by :meth:`save`.
67
+ **form_options: Any other :class:`FormSpec` field (``record_source``, ``caption``,
68
+ ``default_view``...).
69
+ """
70
+
71
+ def __init__(
72
+ self, name: str, *, on_save: Callable[[FormSpec], _R] | None = None, **form_options: Any
73
+ ) -> None:
74
+ self._name = name
75
+ self._options: dict[str, Any] = dict(form_options)
76
+ self._controls: list[ControlSpec] = []
77
+ self._on_save = on_save
78
+ self._state: Literal["open", "saved", "discarded"] = "open"
79
+ # Validate the form-level options immediately so mistakes surface at the call site.
80
+ build(FormSpec, f"form {name!r}", name=name, **self._options)
81
+
82
+ # ------------------------------------------------------------------------------------ controls
83
+ def add(self, control: ControlSpec) -> Self:
84
+ """Append an already-built control spec."""
85
+ self._check_open()
86
+ self._controls.append(control)
87
+ return self
88
+
89
+ def textbox(
90
+ self,
91
+ field: str | None = None,
92
+ *,
93
+ label: str | Literal[False] | None = None,
94
+ control_source: str | None = None,
95
+ format: str | None = None,
96
+ enabled: bool = True,
97
+ locked: bool = False,
98
+ after_update: str | Vba | None = None,
99
+ **options: Unpack[_ControlOptions],
100
+ ) -> Self:
101
+ """Add a text box bound to ``field`` (or showing ``control_source="=..."``)."""
102
+ return self.add(
103
+ build(
104
+ TextBoxSpec,
105
+ f"text box {options.get('name') or field!r}",
106
+ field=field,
107
+ label=label,
108
+ control_source=control_source,
109
+ format=format,
110
+ enabled=enabled,
111
+ locked=locked,
112
+ after_update=_vba(after_update),
113
+ **options,
114
+ )
115
+ )
116
+
117
+ def checkbox(
118
+ self,
119
+ field: str | None = None,
120
+ *,
121
+ label: str | Literal[False] | None = None,
122
+ enabled: bool = True,
123
+ locked: bool = False,
124
+ after_update: str | Vba | None = None,
125
+ **options: Unpack[_ControlOptions],
126
+ ) -> Self:
127
+ """Add a check box bound to ``field``."""
128
+ return self.add(
129
+ build(
130
+ CheckBoxSpec,
131
+ f"check box {options.get('name') or field!r}",
132
+ field=field,
133
+ label=label,
134
+ enabled=enabled,
135
+ locked=locked,
136
+ after_update=_vba(after_update),
137
+ **options,
138
+ )
139
+ )
140
+
141
+ def combobox(
142
+ self,
143
+ field: str | None = None,
144
+ *,
145
+ row_source: str,
146
+ row_source_type: RowSourceType = RowSourceType.TABLE_QUERY,
147
+ bound_column: int = 1,
148
+ column_count: int = 1,
149
+ column_widths: Sequence[Length] | None = None,
150
+ limit_to_list: bool = True,
151
+ label: str | Literal[False] | None = None,
152
+ enabled: bool = True,
153
+ locked: bool = False,
154
+ after_update: str | Vba | None = None,
155
+ **options: Unpack[_ControlOptions],
156
+ ) -> Self:
157
+ """Add a combo box bound to ``field`` with rows from ``row_source``."""
158
+ return self.add(
159
+ build(
160
+ ComboBoxSpec,
161
+ f"combo box {options.get('name') or field!r}",
162
+ field=field,
163
+ row_source=row_source,
164
+ row_source_type=row_source_type,
165
+ bound_column=bound_column,
166
+ column_count=column_count,
167
+ column_widths=column_widths,
168
+ limit_to_list=limit_to_list,
169
+ label=label,
170
+ enabled=enabled,
171
+ locked=locked,
172
+ after_update=_vba(after_update),
173
+ **options,
174
+ )
175
+ )
176
+
177
+ def label(self, caption: str, **options: Unpack[_ControlOptions]) -> Self:
178
+ """Add a free-standing label."""
179
+ return self.add(build(LabelSpec, f"label {caption!r}", caption=caption, **options))
180
+
181
+ def button(
182
+ self,
183
+ name: str,
184
+ *,
185
+ caption: str,
186
+ on_click: str | Vba | None = None,
187
+ section: Section = Section.DETAIL,
188
+ at: tuple[Length, Length] | None = None,
189
+ width: Length | None = None,
190
+ height: Length | None = None,
191
+ visible: bool = True,
192
+ properties: dict[str, PropertyValue] | None = None,
193
+ ) -> Self:
194
+ """Add a command button; ``on_click`` is the VBA body of its Click event procedure."""
195
+ return self.add(
196
+ build(
197
+ ButtonSpec,
198
+ f"button {name!r}",
199
+ name=name,
200
+ caption=caption,
201
+ on_click=_vba(on_click),
202
+ section=section,
203
+ at=at,
204
+ width=width,
205
+ height=height,
206
+ visible=visible,
207
+ properties=properties or {},
208
+ )
209
+ )
210
+
211
+ # ---------------------------------------------------------------------------- form-level code
212
+ def on_load(self, code: str | Vba) -> Self:
213
+ """Set the VBA body of the form's Load event."""
214
+ self._check_open()
215
+ self._options["on_load"] = _vba(code)
216
+ return self
217
+
218
+ def on_current(self, code: str | Vba) -> Self:
219
+ """Set the VBA body of the form's Current event."""
220
+ self._check_open()
221
+ self._options["on_current"] = _vba(code)
222
+ return self
223
+
224
+ def module_code(self, code: str) -> Self:
225
+ """Append extra VBA (helper procedures, module-level declarations) to the form's module."""
226
+ self._check_open()
227
+ existing = self._options.get("module_code") or ""
228
+ self._options["module_code"] = f"{existing}\n{code}" if existing else code
229
+ return self
230
+
231
+ # ------------------------------------------------------------------------------------ results
232
+ def to_spec(self) -> FormSpec:
233
+ """Validate and return the finished :class:`FormSpec`."""
234
+ return build(
235
+ FormSpec,
236
+ f"form {self._name!r}",
237
+ name=self._name,
238
+ controls=tuple(self._controls),
239
+ **self._options,
240
+ )
241
+
242
+ def save(self) -> _R:
243
+ """Build the form in Access (atomically) and return the result of ``on_save``.
244
+
245
+ Raises:
246
+ PyAccessKitError: If the builder has no save target or was already saved/discarded.
247
+ """
248
+ self._check_open()
249
+ if self._on_save is None:
250
+ raise PyAccessKitError(
251
+ "this FormBuilder is standalone; use to_spec() or db.forms.create(...)"
252
+ )
253
+ spec = self.to_spec()
254
+ result = self._on_save(spec)
255
+ self._state = "saved"
256
+ return result
257
+
258
+ def discard(self) -> None:
259
+ """Abandon the builder without creating anything."""
260
+ self._state = "discarded"
261
+
262
+ @property
263
+ def is_saved(self) -> bool:
264
+ """Whether :meth:`save` completed."""
265
+ return self._state == "saved"
266
+
267
+ def _check_open(self) -> None:
268
+ if self._state != "open":
269
+ raise PyAccessKitError(f"form builder {self._name!r} was already {self._state}")
270
+
271
+ # ------------------------------------------------------------------------------ context manager
272
+ def __enter__(self) -> Self:
273
+ return self
274
+
275
+ def __exit__(
276
+ self,
277
+ exc_type: type[BaseException] | None,
278
+ exc: BaseException | None,
279
+ tb: TracebackType | None,
280
+ ) -> None:
281
+ if exc_type is not None:
282
+ self.discard()
283
+ elif self._state == "open":
284
+ self.save()
285
+
286
+ def __del__(self) -> None:
287
+ if getattr(self, "_state", None) == "open" and getattr(self, "_on_save", None) is not None:
288
+ warnings.warn(
289
+ f"form {self._name!r} was never saved; call .save() or use 'with db.forms.create(...)'",
290
+ ResourceWarning,
291
+ stacklevel=2,
292
+ )
293
+
294
+ def __repr__(self) -> str:
295
+ return f"<FormBuilder {self._name!r} controls={len(self._controls)} state={self._state}>"
@@ -0,0 +1,117 @@
1
+ """Forms: the ``db.forms`` collection and :class:`Form` handles (building forms needs Microsoft Access)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from pyaccesskit._backends.protocols import ControlInfo
9
+ from pyaccesskit._ops import design as design_ops
10
+ from pyaccesskit.enums import ObjectKind
11
+ from pyaccesskit.errors import ObjectNotFoundError
12
+ from pyaccesskit.forms.builder import FormBuilder
13
+ from pyaccesskit.forms.layout import ResolvedForm
14
+ from pyaccesskit.forms.spec import FormSpec
15
+
16
+ if TYPE_CHECKING:
17
+ from pyaccesskit._session.session import Session
18
+
19
+ __all__ = ["Form", "FormCollection"]
20
+
21
+
22
+ class Form:
23
+ """A saved form (a live, name-based handle)."""
24
+
25
+ def __init__(self, session: Session, name: str) -> None:
26
+ self._session = session
27
+ self._name = name
28
+
29
+ @property
30
+ def name(self) -> str:
31
+ """The form name."""
32
+ return self._name
33
+
34
+ def controls(self) -> list[ControlInfo]:
35
+ """The form's controls as saved (opens the form hidden in Design view)."""
36
+ return self._session.design().form_controls(self._name)
37
+
38
+ def check_opens(self) -> None:
39
+ """Open the form in Form view (hidden) and close it; raises if Access reports a problem."""
40
+ self._session.design().check_form_opens(self._name)
41
+
42
+ def export_text(self) -> str:
43
+ """The form in Access's ``SaveAsText`` format."""
44
+ return design_ops.export_object(self._session.design(), ObjectKind.FORM, self._name)
45
+
46
+ def rename(self, new_name: str) -> None:
47
+ """Rename the form."""
48
+ self._session.check_writable(f"rename form {self._name!r}")
49
+ self._session.design().rename_object(ObjectKind.FORM, self._name, new_name)
50
+ self._name = new_name
51
+
52
+ def drop(self) -> None:
53
+ """Delete the form."""
54
+ self._session.check_writable(f"drop form {self._name!r}")
55
+ self._session.design().delete_object(ObjectKind.FORM, self._name)
56
+
57
+ def __repr__(self) -> str:
58
+ return f"<Form {self._name!r}>"
59
+
60
+
61
+ class FormCollection:
62
+ """``db.forms``."""
63
+
64
+ def __init__(self, session: Session) -> None:
65
+ self._session = session
66
+
67
+ def names(self) -> list[str]:
68
+ """Form names (read through DAO; does not need Microsoft Access)."""
69
+ return self._session.schema().list_documents(ObjectKind.FORM)
70
+
71
+ def __iter__(self) -> Iterator[Form]:
72
+ return iter([Form(self._session, name) for name in self.names()])
73
+
74
+ def __len__(self) -> int:
75
+ return len(self.names())
76
+
77
+ def __contains__(self, name: object) -> bool:
78
+ return isinstance(name, str) and any(n.casefold() == name.casefold() for n in self.names())
79
+
80
+ def __getitem__(self, name: str) -> Form:
81
+ for actual in self.names():
82
+ if actual.casefold() == name.casefold():
83
+ return Form(self._session, actual)
84
+ raise ObjectNotFoundError(f"form {name!r} does not exist", kind=ObjectKind.FORM, name=name)
85
+
86
+ def create(self, name: str, *, replace: bool = False, **form_options: Any) -> FormBuilder[Form]:
87
+ """Start building a form; nothing is created in Access until the builder is saved.
88
+
89
+ Example::
90
+
91
+ with db.forms.create("frmCustomers", record_source="Customers") as form:
92
+ form.textbox("CustomerName")
93
+ # built atomically here
94
+
95
+ Args:
96
+ name: Form name.
97
+ replace: Rebuild the form if it already exists (atomically swapped in).
98
+ **form_options: Any :class:`~pyaccesskit.forms.FormSpec` field (``record_source``, ``caption``,
99
+ ``default_view``...).
100
+ """
101
+ return FormBuilder(
102
+ name, on_save=lambda spec: self.build(spec, replace=replace), **form_options
103
+ )
104
+
105
+ def build(self, spec: FormSpec, *, replace: bool = False) -> Form:
106
+ """Build a form from a finished :class:`FormSpec` (atomically)."""
107
+ self._session.check_writable(f"build form {spec.name!r}")
108
+ # design() may switch engines (closing in-process DAO), so resolve the schema backend afterwards.
109
+ design = self._session.design()
110
+ resolved: ResolvedForm = design_ops.build_form(
111
+ self._session.schema(), design, spec, replace=replace
112
+ )
113
+ return Form(self._session, resolved.spec.name)
114
+
115
+ def drop(self, name: str) -> None:
116
+ """Delete a form."""
117
+ self[name].drop()
@@ -0,0 +1,157 @@
1
+ """Specifications of form controls.
2
+
3
+ Each control kind is its own spec class; together they form the discriminated union :data:`ControlSpec`
4
+ (``kind`` is the discriminator). Positions and sizes are :class:`~pyaccesskit.units.Length` values and are
5
+ optional: the layout engine places controls that have no explicit ``at=``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Annotated, Literal, Self
11
+
12
+ from pydantic import Field, field_validator, model_validator
13
+
14
+ from pyaccesskit.enums import ControlKind, RowSourceType, Section
15
+ from pyaccesskit.forms.vba import Vba
16
+ from pyaccesskit.schema._base import Items, PropertyValue, SpecModel
17
+ from pyaccesskit.schema.names import check_name
18
+ from pyaccesskit.units import Length
19
+
20
+ __all__ = [
21
+ "ButtonSpec",
22
+ "CheckBoxSpec",
23
+ "ComboBoxSpec",
24
+ "ControlBase",
25
+ "ControlSpec",
26
+ "LabelSpec",
27
+ "TextBoxSpec",
28
+ ]
29
+
30
+
31
+ class ControlBase(SpecModel):
32
+ """Options shared by all controls.
33
+
34
+ Attributes:
35
+ name: Control name. Bound controls default to their field name.
36
+ section: Form section holding the control.
37
+ at: Explicit ``(left, top)`` position; ``None`` lets the layout engine place it.
38
+ width: Explicit width (defaults depend on the control kind).
39
+ height: Explicit height.
40
+ visible: Whether the control is visible in Form view.
41
+ properties: Other Access control properties to set verbatim (escape hatch).
42
+ """
43
+
44
+ name: str | None = None
45
+ section: Section = Section.DETAIL
46
+ at: tuple[Length, Length] | None = None
47
+ width: Length | None = None
48
+ height: Length | None = None
49
+ visible: bool = True
50
+ properties: dict[str, PropertyValue] = Field(default_factory=dict)
51
+
52
+ @field_validator("name")
53
+ @classmethod
54
+ def _check_name(cls, value: str | None) -> str | None:
55
+ return None if value is None else check_name(value, what="control name")
56
+
57
+ @property
58
+ def control_kind(self) -> ControlKind:
59
+ """The kind of control."""
60
+ return ControlKind(getattr(self, "kind")) # noqa: B009 - defined by each subclass
61
+
62
+ @property
63
+ def bound_field(self) -> str | None:
64
+ """The bound field name, if the control is bound to a field."""
65
+ return getattr(self, "field", None)
66
+
67
+ @property
68
+ def attached_label(self) -> str | None:
69
+ """The attached label caption, or ``None`` if the control has no attached label."""
70
+ return None
71
+
72
+
73
+ class _BoundControl(ControlBase):
74
+ field: str | None = None
75
+ label: str | Literal[False] | None = None
76
+ """Attached label: ``None`` = the field name, ``False`` = no label, or explicit text."""
77
+ enabled: bool = True
78
+ locked: bool = False
79
+ after_update: Vba | None = None
80
+
81
+ @field_validator("field")
82
+ @classmethod
83
+ def _check_field(cls, value: str | None) -> str | None:
84
+ return None if value is None else check_name(value, what="field name")
85
+
86
+ @property
87
+ def attached_label(self) -> str | None:
88
+ """Label caption (defaults to the bound field or control name)."""
89
+ if self.label is False:
90
+ return None
91
+ if self.label is not None:
92
+ return self.label
93
+ return self.field or self.name
94
+
95
+
96
+ class LabelSpec(ControlBase):
97
+ """A free-standing label."""
98
+
99
+ kind: Literal["label"] = "label"
100
+ caption: str
101
+
102
+
103
+ class TextBoxSpec(_BoundControl):
104
+ """A text box bound to ``field`` or showing a calculated ``control_source`` (``"=[Qty]*[Price]"``)."""
105
+
106
+ kind: Literal["textbox"] = "textbox"
107
+ control_source: str | None = None
108
+ format: str | None = None
109
+
110
+ @model_validator(mode="after")
111
+ def _check_source(self) -> Self:
112
+ if self.field is not None and self.control_source is not None:
113
+ raise ValueError("give either field= or control_source=, not both")
114
+ if self.control_source is not None and not self.control_source.startswith("="):
115
+ raise ValueError("control_source expressions start with '=' (e.g. '=[Qty]*[Price]')")
116
+ return self
117
+
118
+
119
+ class CheckBoxSpec(_BoundControl):
120
+ """A check box (typically bound to a Yes/No field)."""
121
+
122
+ kind: Literal["checkbox"] = "checkbox"
123
+
124
+
125
+ class ComboBoxSpec(_BoundControl):
126
+ """A combo box whose rows come from a table/query/SQL statement or a value list."""
127
+
128
+ kind: Literal["combobox"] = "combobox"
129
+ row_source: str
130
+ row_source_type: RowSourceType = RowSourceType.TABLE_QUERY
131
+ bound_column: int = Field(default=1, ge=0)
132
+ column_count: int = Field(default=1, ge=1, le=255)
133
+ column_widths: Items[Length] | None = None
134
+ limit_to_list: bool = True
135
+
136
+ @model_validator(mode="after")
137
+ def _check_columns(self) -> Self:
138
+ if self.bound_column > self.column_count:
139
+ raise ValueError("bound_column cannot exceed column_count")
140
+ if self.column_widths is not None and len(self.column_widths) > self.column_count:
141
+ raise ValueError("more column_widths than column_count")
142
+ return self
143
+
144
+
145
+ class ButtonSpec(ControlBase):
146
+ """A command button, optionally running VBA when clicked."""
147
+
148
+ kind: Literal["button"] = "button"
149
+ caption: str
150
+ on_click: Vba | None = None
151
+
152
+
153
+ ControlSpec = Annotated[
154
+ LabelSpec | TextBoxSpec | CheckBoxSpec | ComboBoxSpec | ButtonSpec,
155
+ Field(discriminator="kind"),
156
+ ]
157
+ """Any control spec (a Pydantic discriminated union on ``kind``)."""