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,609 @@
|
|
|
1
|
+
"""Column (field) specifications and the :class:`Column` factory.
|
|
2
|
+
|
|
3
|
+
Each Access data type has its own immutable spec class (``TextColumn``, ``NumberColumn``...) so that only
|
|
4
|
+
options meaningful for that type are accepted. The classes form a discriminated union on ``type``
|
|
5
|
+
(:data:`ColumnSpec`), which is also how columns appear in JSON/YAML::
|
|
6
|
+
|
|
7
|
+
{"type": "text", "name": "Email", "length": 255}
|
|
8
|
+
|
|
9
|
+
Most code uses the :class:`Column` factory, which mirrors the Access table designer::
|
|
10
|
+
|
|
11
|
+
Column.autonumber("CustomerID", primary_key=True)
|
|
12
|
+
Column.text("CustomerName", length=200, required=True)
|
|
13
|
+
Column.number("Quantity", size=NumberSize.INTEGER)
|
|
14
|
+
Column.currency("UnitPrice", default=0)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from datetime import date, datetime, time
|
|
21
|
+
from decimal import Decimal, InvalidOperation
|
|
22
|
+
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, Self, TypedDict, cast
|
|
23
|
+
|
|
24
|
+
from pydantic import Field, field_validator, model_validator
|
|
25
|
+
|
|
26
|
+
from pyaccesskit.enums import DataType, NumberSize
|
|
27
|
+
from pyaccesskit.schema._base import PropertyValue, SpecModel, build
|
|
28
|
+
from pyaccesskit.schema.expressions import DefaultValue, Expr, parse_literal
|
|
29
|
+
from pyaccesskit.schema.names import check_name
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from typing_extensions import Unpack
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"COLUMN_CLASSES",
|
|
36
|
+
"AutoNumberColumn",
|
|
37
|
+
"Column",
|
|
38
|
+
"ColumnBase",
|
|
39
|
+
"ColumnOptions",
|
|
40
|
+
"ColumnSpec",
|
|
41
|
+
"CurrencyColumn",
|
|
42
|
+
"DateTimeColumn",
|
|
43
|
+
"DecimalColumn",
|
|
44
|
+
"HyperlinkColumn",
|
|
45
|
+
"LongTextColumn",
|
|
46
|
+
"NumberColumn",
|
|
47
|
+
"OleObjectColumn",
|
|
48
|
+
"OleObjectOptions",
|
|
49
|
+
"TextColumn",
|
|
50
|
+
"UnsupportedColumn",
|
|
51
|
+
"YesNoColumn",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------------------------ default coercion
|
|
56
|
+
# Strings (e.g. from YAML/JSON) are accepted as *literals* of the column's type only. Anything that is not
|
|
57
|
+
# a literal must be an explicit Expr(...) so that a typo never silently becomes an expression.
|
|
58
|
+
def _literal(value: Any, expected: str) -> Any:
|
|
59
|
+
if not isinstance(value, str):
|
|
60
|
+
return value
|
|
61
|
+
parsed = parse_literal(value)
|
|
62
|
+
if isinstance(parsed, Expr) or parsed is None:
|
|
63
|
+
raise ValueError(f"default {value!r} is not {expected}; wrap expressions in Expr(...)")
|
|
64
|
+
return parsed
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _as_int(value: Any) -> int | Expr:
|
|
68
|
+
value = _literal(value, "an integer")
|
|
69
|
+
if isinstance(value, Expr):
|
|
70
|
+
return value
|
|
71
|
+
if isinstance(value, bool):
|
|
72
|
+
raise ValueError("booleans are not valid defaults for integer columns (use 0 or 1)")
|
|
73
|
+
if isinstance(value, int):
|
|
74
|
+
return value
|
|
75
|
+
if isinstance(value, (float, Decimal)) and value == int(value):
|
|
76
|
+
return int(value)
|
|
77
|
+
raise ValueError(f"default {value!r} is not an integer; wrap expressions in Expr(...)")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _as_float(value: Any) -> float | Expr:
|
|
81
|
+
value = _literal(value, "a number")
|
|
82
|
+
if isinstance(value, Expr):
|
|
83
|
+
return value
|
|
84
|
+
if isinstance(value, bool) or not isinstance(value, (int, float, Decimal)):
|
|
85
|
+
raise ValueError(f"default {value!r} is not a number; wrap expressions in Expr(...)")
|
|
86
|
+
return float(value)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _as_decimal(value: Any) -> Decimal | Expr:
|
|
90
|
+
value = _literal(value, "a number")
|
|
91
|
+
if isinstance(value, Expr):
|
|
92
|
+
return value
|
|
93
|
+
if isinstance(value, bool) or not isinstance(value, (int, float, Decimal)):
|
|
94
|
+
raise ValueError(f"default {value!r} is not a number; wrap expressions in Expr(...)")
|
|
95
|
+
try:
|
|
96
|
+
return value if isinstance(value, Decimal) else Decimal(str(value))
|
|
97
|
+
except InvalidOperation as exc: # pragma: no cover - finite floats always convert
|
|
98
|
+
raise ValueError(f"default {value!r} is not a valid decimal") from exc
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _as_bool(value: Any) -> bool | Expr:
|
|
102
|
+
value = _literal(value, "a Yes/No value (True/False)")
|
|
103
|
+
if isinstance(value, (bool, Expr)):
|
|
104
|
+
return value
|
|
105
|
+
if isinstance(value, int) and value in (0, 1, -1):
|
|
106
|
+
return value != 0
|
|
107
|
+
raise ValueError(f"default {value!r} is not a Yes/No value; use True/False")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _as_datetime(value: Any) -> datetime | date | time | Expr:
|
|
111
|
+
if isinstance(value, str):
|
|
112
|
+
text = value.strip()
|
|
113
|
+
try:
|
|
114
|
+
parsed = datetime.fromisoformat(text)
|
|
115
|
+
except ValueError:
|
|
116
|
+
try:
|
|
117
|
+
value = time.fromisoformat(text)
|
|
118
|
+
except ValueError:
|
|
119
|
+
value = _literal(text, "a date/time (use an ISO date, #date#, or Expr('Now()'))")
|
|
120
|
+
else:
|
|
121
|
+
value = parsed.date() if len(text) <= 10 else parsed
|
|
122
|
+
if isinstance(value, (datetime, date, time, Expr)):
|
|
123
|
+
return value
|
|
124
|
+
raise ValueError(f"default {value!r} is not a date/time; use datetime/date or Expr('Now()')")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------------------------------ base class
|
|
128
|
+
class ColumnBase(SpecModel):
|
|
129
|
+
"""Options shared by every column type.
|
|
130
|
+
|
|
131
|
+
Attributes:
|
|
132
|
+
name: Field name (≤64 characters; see Access naming rules).
|
|
133
|
+
required: Disallow Null values (*Required* property).
|
|
134
|
+
default: Default value: a Python literal (rendered as an Access literal) or an ``Expr``.
|
|
135
|
+
validation_rule: *Validation Rule* expression, e.g. ``">0"``.
|
|
136
|
+
validation_text: Message shown when the validation rule fails.
|
|
137
|
+
description: *Description* shown in the table designer.
|
|
138
|
+
caption: *Caption* used as the default label text on forms and datasheet headers.
|
|
139
|
+
format: *Format* property, e.g. ``"Short Date"`` or ``"Currency"``.
|
|
140
|
+
primary_key: Shorthand: include this column in the table's primary key.
|
|
141
|
+
unique: Shorthand: create a unique single-column index named after the column.
|
|
142
|
+
indexed: Shorthand: create a non-unique single-column index named after the column.
|
|
143
|
+
properties: Other Access/DAO field properties to set verbatim (escape hatch).
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
data_type: ClassVar[DataType]
|
|
147
|
+
supports_default: ClassVar[bool] = True
|
|
148
|
+
indexable: ClassVar[bool] = True
|
|
149
|
+
|
|
150
|
+
name: str
|
|
151
|
+
required: bool = False
|
|
152
|
+
default: DefaultValue | None = None
|
|
153
|
+
validation_rule: str | None = None
|
|
154
|
+
validation_text: str | None = None
|
|
155
|
+
description: str | None = None
|
|
156
|
+
caption: str | None = None
|
|
157
|
+
format: str | None = None
|
|
158
|
+
primary_key: bool = False
|
|
159
|
+
unique: bool = False
|
|
160
|
+
indexed: bool = False
|
|
161
|
+
properties: dict[str, PropertyValue] = Field(default_factory=dict)
|
|
162
|
+
|
|
163
|
+
@field_validator("name")
|
|
164
|
+
@classmethod
|
|
165
|
+
def _check_name(cls, value: str) -> str:
|
|
166
|
+
return check_name(value, what="column name")
|
|
167
|
+
|
|
168
|
+
@model_validator(mode="before")
|
|
169
|
+
@classmethod
|
|
170
|
+
def _coerce_default_input(cls, data: Any) -> Any:
|
|
171
|
+
if not isinstance(data, Mapping):
|
|
172
|
+
return data
|
|
173
|
+
fields = cast("Mapping[str, Any]", data)
|
|
174
|
+
value = fields.get("default")
|
|
175
|
+
if value is None or isinstance(value, Expr):
|
|
176
|
+
return fields
|
|
177
|
+
if isinstance(value, Mapping):
|
|
178
|
+
payload = cast("Mapping[str, Any]", value)
|
|
179
|
+
if set(payload) == {"expr"}: # the JSON form of an Expr
|
|
180
|
+
return {**fields, "default": Expr(str(payload["expr"]))}
|
|
181
|
+
return {**fields, "default": cls._coerce_default(value, fields)}
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def _coerce_default(cls, value: Any, data: Mapping[str, Any]) -> Any:
|
|
185
|
+
"""Convert a user-supplied default to this column type's canonical Python type."""
|
|
186
|
+
return value
|
|
187
|
+
|
|
188
|
+
@model_validator(mode="after")
|
|
189
|
+
def _check_common(self) -> Self:
|
|
190
|
+
if self.default is not None and not self.supports_default:
|
|
191
|
+
raise ValueError(f"{self.data_type.value} columns cannot have a default value")
|
|
192
|
+
if (self.primary_key or self.unique or self.indexed) and not self.indexable:
|
|
193
|
+
raise ValueError(f"{self.data_type.value} columns cannot be indexed")
|
|
194
|
+
if self.validation_text is not None and self.validation_rule is None:
|
|
195
|
+
raise ValueError("validation_text requires a validation_rule")
|
|
196
|
+
return self
|
|
197
|
+
|
|
198
|
+
def normalized(self) -> Self:
|
|
199
|
+
"""Canonical form: the index shorthands are cleared (they live in ``TableSpec.indexes``)."""
|
|
200
|
+
if not (self.primary_key or self.unique or self.indexed):
|
|
201
|
+
return self
|
|
202
|
+
return self.model_copy(update={"primary_key": False, "unique": False, "indexed": False})
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------------------------------ text types
|
|
206
|
+
class TextColumn(ColumnBase):
|
|
207
|
+
"""Short Text: up to 255 characters."""
|
|
208
|
+
|
|
209
|
+
data_type: ClassVar[DataType] = DataType.TEXT
|
|
210
|
+
type: Literal["text"] = "text"
|
|
211
|
+
length: int = Field(default=255, ge=1, le=255)
|
|
212
|
+
allow_zero_length: bool = False
|
|
213
|
+
unicode_compression: bool = True
|
|
214
|
+
input_mask: str | None = None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class LongTextColumn(ColumnBase):
|
|
218
|
+
"""Long Text (Memo): up to ~1 GB; optionally rich text and append-only."""
|
|
219
|
+
|
|
220
|
+
data_type: ClassVar[DataType] = DataType.LONG_TEXT
|
|
221
|
+
type: Literal["long_text"] = "long_text"
|
|
222
|
+
rich_text: bool = False
|
|
223
|
+
append_only: bool = False
|
|
224
|
+
allow_zero_length: bool = False
|
|
225
|
+
unicode_compression: bool = True
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class HyperlinkColumn(ColumnBase):
|
|
229
|
+
"""Hyperlink: a Long Text field flagged as a hyperlink."""
|
|
230
|
+
|
|
231
|
+
data_type: ClassVar[DataType] = DataType.HYPERLINK
|
|
232
|
+
type: Literal["hyperlink"] = "hyperlink"
|
|
233
|
+
allow_zero_length: bool = False
|
|
234
|
+
unicode_compression: bool = True
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------------------- number types
|
|
238
|
+
class NumberColumn(ColumnBase):
|
|
239
|
+
"""Number with a *Field Size* of Byte, Integer (16-bit), Long Integer, Single, Double or Replication ID."""
|
|
240
|
+
|
|
241
|
+
data_type: ClassVar[DataType] = DataType.NUMBER
|
|
242
|
+
type: Literal["number"] = "number"
|
|
243
|
+
size: NumberSize = NumberSize.LONG_INTEGER
|
|
244
|
+
decimal_places: int | None = Field(default=None, ge=0, le=15)
|
|
245
|
+
"""``None`` means *Auto*."""
|
|
246
|
+
input_mask: str | None = None
|
|
247
|
+
|
|
248
|
+
@classmethod
|
|
249
|
+
def _coerce_default(cls, value: Any, data: Mapping[str, Any]) -> Any:
|
|
250
|
+
size = NumberSize(data.get("size", NumberSize.LONG_INTEGER))
|
|
251
|
+
if size in (NumberSize.SINGLE, NumberSize.DOUBLE):
|
|
252
|
+
return _as_float(value)
|
|
253
|
+
if size is NumberSize.REPLICATION_ID:
|
|
254
|
+
raise ValueError("Replication ID columns only accept Expr(...) defaults")
|
|
255
|
+
return _as_int(value)
|
|
256
|
+
|
|
257
|
+
@model_validator(mode="after")
|
|
258
|
+
def _check_replication_default(self) -> Self:
|
|
259
|
+
# Access defines an AutoNumber (Replication ID) as exactly this; one spelling keeps specs canonical.
|
|
260
|
+
if (
|
|
261
|
+
self.size is NumberSize.REPLICATION_ID
|
|
262
|
+
and isinstance(self.default, Expr)
|
|
263
|
+
and self.default.expr.replace(" ", "").casefold() == "genguid()"
|
|
264
|
+
):
|
|
265
|
+
raise ValueError(
|
|
266
|
+
"a Replication ID number defaulting to GenGUID() is an AutoNumber; "
|
|
267
|
+
"use Column.autonumber(name, replication_id=True)"
|
|
268
|
+
)
|
|
269
|
+
return self
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class DecimalColumn(ColumnBase):
|
|
273
|
+
"""Number with *Field Size* = Decimal (exact, with precision and scale)."""
|
|
274
|
+
|
|
275
|
+
data_type: ClassVar[DataType] = DataType.DECIMAL
|
|
276
|
+
type: Literal["decimal"] = "decimal"
|
|
277
|
+
precision: int = Field(default=18, ge=1, le=28)
|
|
278
|
+
scale: int = Field(default=0, ge=0, le=28)
|
|
279
|
+
decimal_places: int | None = Field(default=None, ge=0, le=15)
|
|
280
|
+
input_mask: str | None = None
|
|
281
|
+
|
|
282
|
+
@classmethod
|
|
283
|
+
def _coerce_default(cls, value: Any, data: Mapping[str, Any]) -> Any:
|
|
284
|
+
return _as_decimal(value)
|
|
285
|
+
|
|
286
|
+
@model_validator(mode="after")
|
|
287
|
+
def _check_scale(self) -> Self:
|
|
288
|
+
if self.scale > self.precision:
|
|
289
|
+
raise ValueError(f"scale ({self.scale}) cannot exceed precision ({self.precision})")
|
|
290
|
+
return self
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class CurrencyColumn(ColumnBase):
|
|
294
|
+
"""Currency: fixed-point, 4 decimal places, no rounding surprises."""
|
|
295
|
+
|
|
296
|
+
data_type: ClassVar[DataType] = DataType.CURRENCY
|
|
297
|
+
type: Literal["currency"] = "currency"
|
|
298
|
+
decimal_places: int | None = Field(default=None, ge=0, le=15)
|
|
299
|
+
input_mask: str | None = None
|
|
300
|
+
|
|
301
|
+
@classmethod
|
|
302
|
+
def _coerce_default(cls, value: Any, data: Mapping[str, Any]) -> Any:
|
|
303
|
+
return _as_decimal(value)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class AutoNumberColumn(ColumnBase):
|
|
307
|
+
"""AutoNumber: an incrementing Long Integer, or a Replication ID (GUID).
|
|
308
|
+
|
|
309
|
+
*Random* AutoNumbers are not offered: DAO silently ignores the setting, so PyAccessKit cannot create
|
|
310
|
+
them reliably (see ADR 0002).
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
data_type: ClassVar[DataType] = DataType.AUTONUMBER
|
|
314
|
+
supports_default: ClassVar[bool] = False
|
|
315
|
+
type: Literal["autonumber"] = "autonumber"
|
|
316
|
+
replication_id: bool = False
|
|
317
|
+
|
|
318
|
+
@model_validator(mode="after")
|
|
319
|
+
def _check_autonumber(self) -> Self:
|
|
320
|
+
if self.required:
|
|
321
|
+
raise ValueError("AutoNumber columns are always populated; 'required' does not apply")
|
|
322
|
+
return self
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ------------------------------------------------------------------------------------------ other types
|
|
326
|
+
class DateTimeColumn(ColumnBase):
|
|
327
|
+
"""Date/Time."""
|
|
328
|
+
|
|
329
|
+
data_type: ClassVar[DataType] = DataType.DATE_TIME
|
|
330
|
+
type: Literal["date_time"] = "date_time"
|
|
331
|
+
input_mask: str | None = None
|
|
332
|
+
|
|
333
|
+
@classmethod
|
|
334
|
+
def _coerce_default(cls, value: Any, data: Mapping[str, Any]) -> Any:
|
|
335
|
+
return _as_datetime(value)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class YesNoColumn(ColumnBase):
|
|
339
|
+
"""Yes/No (Boolean). Shown as a check box in datasheets and bound forms."""
|
|
340
|
+
|
|
341
|
+
data_type: ClassVar[DataType] = DataType.YES_NO
|
|
342
|
+
type: Literal["yes_no"] = "yes_no"
|
|
343
|
+
|
|
344
|
+
@classmethod
|
|
345
|
+
def _coerce_default(cls, value: Any, data: Mapping[str, Any]) -> Any:
|
|
346
|
+
return _as_bool(value)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class OleObjectColumn(ColumnBase):
|
|
350
|
+
"""OLE Object (long binary data)."""
|
|
351
|
+
|
|
352
|
+
data_type: ClassVar[DataType] = DataType.OLE_OBJECT
|
|
353
|
+
supports_default: ClassVar[bool] = False
|
|
354
|
+
indexable: ClassVar[bool] = False
|
|
355
|
+
type: Literal["ole_object"] = "ole_object"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class UnsupportedColumn(ColumnBase):
|
|
359
|
+
"""A column PyAccessKit can read but not create yet (Attachment, Calculated, Large Number...).
|
|
360
|
+
|
|
361
|
+
It appears when introspecting existing databases so that ``to_spec()`` never fails; creating a table
|
|
362
|
+
with one raises :class:`~pyaccesskit.errors.SpecError`.
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
data_type: ClassVar[DataType] = DataType.UNKNOWN
|
|
366
|
+
type: Literal["unsupported"] = "unsupported"
|
|
367
|
+
dao_type: int
|
|
368
|
+
detail: str = ""
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
ColumnSpec = Annotated[
|
|
372
|
+
TextColumn
|
|
373
|
+
| LongTextColumn
|
|
374
|
+
| NumberColumn
|
|
375
|
+
| DecimalColumn
|
|
376
|
+
| CurrencyColumn
|
|
377
|
+
| AutoNumberColumn
|
|
378
|
+
| DateTimeColumn
|
|
379
|
+
| YesNoColumn
|
|
380
|
+
| HyperlinkColumn
|
|
381
|
+
| OleObjectColumn
|
|
382
|
+
| UnsupportedColumn,
|
|
383
|
+
Field(discriminator="type"),
|
|
384
|
+
]
|
|
385
|
+
"""Any column spec (a Pydantic discriminated union on ``type``)."""
|
|
386
|
+
|
|
387
|
+
COLUMN_CLASSES: tuple[type[ColumnBase], ...] = (
|
|
388
|
+
TextColumn,
|
|
389
|
+
LongTextColumn,
|
|
390
|
+
NumberColumn,
|
|
391
|
+
DecimalColumn,
|
|
392
|
+
CurrencyColumn,
|
|
393
|
+
AutoNumberColumn,
|
|
394
|
+
DateTimeColumn,
|
|
395
|
+
YesNoColumn,
|
|
396
|
+
HyperlinkColumn,
|
|
397
|
+
OleObjectColumn,
|
|
398
|
+
UnsupportedColumn,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# ---------------------------------------------------------------------------------------------- factory
|
|
403
|
+
class ColumnOptions(TypedDict, total=False):
|
|
404
|
+
"""Keyword options accepted by every :class:`Column` constructor (see :class:`ColumnBase`)."""
|
|
405
|
+
|
|
406
|
+
required: bool
|
|
407
|
+
default: DefaultValue | None
|
|
408
|
+
validation_rule: str | None
|
|
409
|
+
validation_text: str | None
|
|
410
|
+
description: str | None
|
|
411
|
+
caption: str | None
|
|
412
|
+
format: str | None
|
|
413
|
+
primary_key: bool
|
|
414
|
+
unique: bool
|
|
415
|
+
indexed: bool
|
|
416
|
+
properties: dict[str, PropertyValue]
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class AutoNumberOptions(TypedDict, total=False):
|
|
420
|
+
"""Keyword options accepted by :meth:`Column.autonumber` (no default/required)."""
|
|
421
|
+
|
|
422
|
+
validation_rule: str | None
|
|
423
|
+
validation_text: str | None
|
|
424
|
+
description: str | None
|
|
425
|
+
caption: str | None
|
|
426
|
+
format: str | None
|
|
427
|
+
primary_key: bool
|
|
428
|
+
unique: bool
|
|
429
|
+
indexed: bool
|
|
430
|
+
properties: dict[str, PropertyValue]
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class OleObjectOptions(AutoNumberOptions, total=False):
|
|
434
|
+
"""Keyword options accepted by :meth:`Column.ole_object` (no default value)."""
|
|
435
|
+
|
|
436
|
+
required: bool
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class Column:
|
|
440
|
+
"""Factory for column specs, mirroring the data types of the Access table designer.
|
|
441
|
+
|
|
442
|
+
Every constructor raises :class:`~pyaccesskit.errors.SpecError` for invalid options.
|
|
443
|
+
"""
|
|
444
|
+
|
|
445
|
+
def __init__(self) -> None:
|
|
446
|
+
raise TypeError(
|
|
447
|
+
"Column is a factory namespace; call Column.text(...), Column.number(...), etc."
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
@staticmethod
|
|
451
|
+
def text(
|
|
452
|
+
name: str,
|
|
453
|
+
*,
|
|
454
|
+
length: int = 255,
|
|
455
|
+
allow_zero_length: bool = False,
|
|
456
|
+
unicode_compression: bool = True,
|
|
457
|
+
input_mask: str | None = None,
|
|
458
|
+
**options: Unpack[ColumnOptions],
|
|
459
|
+
) -> TextColumn:
|
|
460
|
+
"""Short Text (``length`` 1-255, default 255)."""
|
|
461
|
+
return build(
|
|
462
|
+
TextColumn,
|
|
463
|
+
f"text column {name!r}",
|
|
464
|
+
name=name,
|
|
465
|
+
length=length,
|
|
466
|
+
allow_zero_length=allow_zero_length,
|
|
467
|
+
unicode_compression=unicode_compression,
|
|
468
|
+
input_mask=input_mask,
|
|
469
|
+
**options,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
@staticmethod
|
|
473
|
+
def long_text(
|
|
474
|
+
name: str,
|
|
475
|
+
*,
|
|
476
|
+
rich_text: bool = False,
|
|
477
|
+
append_only: bool = False,
|
|
478
|
+
allow_zero_length: bool = False,
|
|
479
|
+
unicode_compression: bool = True,
|
|
480
|
+
**options: Unpack[ColumnOptions],
|
|
481
|
+
) -> LongTextColumn:
|
|
482
|
+
"""Long Text (Memo)."""
|
|
483
|
+
return build(
|
|
484
|
+
LongTextColumn,
|
|
485
|
+
f"long text column {name!r}",
|
|
486
|
+
name=name,
|
|
487
|
+
rich_text=rich_text,
|
|
488
|
+
append_only=append_only,
|
|
489
|
+
allow_zero_length=allow_zero_length,
|
|
490
|
+
unicode_compression=unicode_compression,
|
|
491
|
+
**options,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
@staticmethod
|
|
495
|
+
def number(
|
|
496
|
+
name: str,
|
|
497
|
+
*,
|
|
498
|
+
size: NumberSize | str = NumberSize.LONG_INTEGER,
|
|
499
|
+
decimal_places: int | None = None,
|
|
500
|
+
input_mask: str | None = None,
|
|
501
|
+
**options: Unpack[ColumnOptions],
|
|
502
|
+
) -> NumberColumn:
|
|
503
|
+
"""Number. ``size`` defaults to Long Integer (32-bit), exactly like Access."""
|
|
504
|
+
return build(
|
|
505
|
+
NumberColumn,
|
|
506
|
+
f"number column {name!r}",
|
|
507
|
+
name=name,
|
|
508
|
+
size=size,
|
|
509
|
+
decimal_places=decimal_places,
|
|
510
|
+
input_mask=input_mask,
|
|
511
|
+
**options,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
@staticmethod
|
|
515
|
+
def decimal(
|
|
516
|
+
name: str,
|
|
517
|
+
*,
|
|
518
|
+
precision: int = 18,
|
|
519
|
+
scale: int = 0,
|
|
520
|
+
decimal_places: int | None = None,
|
|
521
|
+
input_mask: str | None = None,
|
|
522
|
+
**options: Unpack[ColumnOptions],
|
|
523
|
+
) -> DecimalColumn:
|
|
524
|
+
"""Number with Field Size = Decimal(``precision``, ``scale``)."""
|
|
525
|
+
return build(
|
|
526
|
+
DecimalColumn,
|
|
527
|
+
f"decimal column {name!r}",
|
|
528
|
+
name=name,
|
|
529
|
+
precision=precision,
|
|
530
|
+
scale=scale,
|
|
531
|
+
decimal_places=decimal_places,
|
|
532
|
+
input_mask=input_mask,
|
|
533
|
+
**options,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
@staticmethod
|
|
537
|
+
def currency(
|
|
538
|
+
name: str,
|
|
539
|
+
*,
|
|
540
|
+
decimal_places: int | None = None,
|
|
541
|
+
input_mask: str | None = None,
|
|
542
|
+
**options: Unpack[ColumnOptions],
|
|
543
|
+
) -> CurrencyColumn:
|
|
544
|
+
"""Currency."""
|
|
545
|
+
return build(
|
|
546
|
+
CurrencyColumn,
|
|
547
|
+
f"currency column {name!r}",
|
|
548
|
+
name=name,
|
|
549
|
+
decimal_places=decimal_places,
|
|
550
|
+
input_mask=input_mask,
|
|
551
|
+
**options,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
@staticmethod
|
|
555
|
+
def autonumber(
|
|
556
|
+
name: str,
|
|
557
|
+
*,
|
|
558
|
+
replication_id: bool = False,
|
|
559
|
+
**options: Unpack[AutoNumberOptions],
|
|
560
|
+
) -> AutoNumberColumn:
|
|
561
|
+
"""AutoNumber (Long Integer by default; ``replication_id=True`` for a GUID)."""
|
|
562
|
+
return build(
|
|
563
|
+
AutoNumberColumn,
|
|
564
|
+
f"autonumber column {name!r}",
|
|
565
|
+
name=name,
|
|
566
|
+
replication_id=replication_id,
|
|
567
|
+
**options,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
@staticmethod
|
|
571
|
+
def date_time(
|
|
572
|
+
name: str, *, input_mask: str | None = None, **options: Unpack[ColumnOptions]
|
|
573
|
+
) -> DateTimeColumn:
|
|
574
|
+
"""Date/Time."""
|
|
575
|
+
return build(
|
|
576
|
+
DateTimeColumn,
|
|
577
|
+
f"date/time column {name!r}",
|
|
578
|
+
name=name,
|
|
579
|
+
input_mask=input_mask,
|
|
580
|
+
**options,
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
@staticmethod
|
|
584
|
+
def yes_no(name: str, **options: Unpack[ColumnOptions]) -> YesNoColumn:
|
|
585
|
+
"""Yes/No (Boolean)."""
|
|
586
|
+
return build(YesNoColumn, f"yes/no column {name!r}", name=name, **options)
|
|
587
|
+
|
|
588
|
+
@staticmethod
|
|
589
|
+
def hyperlink(
|
|
590
|
+
name: str,
|
|
591
|
+
*,
|
|
592
|
+
allow_zero_length: bool = False,
|
|
593
|
+
unicode_compression: bool = True,
|
|
594
|
+
**options: Unpack[ColumnOptions],
|
|
595
|
+
) -> HyperlinkColumn:
|
|
596
|
+
"""Hyperlink."""
|
|
597
|
+
return build(
|
|
598
|
+
HyperlinkColumn,
|
|
599
|
+
f"hyperlink column {name!r}",
|
|
600
|
+
name=name,
|
|
601
|
+
allow_zero_length=allow_zero_length,
|
|
602
|
+
unicode_compression=unicode_compression,
|
|
603
|
+
**options,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
@staticmethod
|
|
607
|
+
def ole_object(name: str, **options: Unpack[OleObjectOptions]) -> OleObjectColumn:
|
|
608
|
+
"""OLE Object (long binary)."""
|
|
609
|
+
return build(OleObjectColumn, f"OLE object column {name!r}", name=name, **options)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Storage-type rules shared by pre-validation and the in-memory backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pyaccesskit.enums import NumberSize
|
|
6
|
+
from pyaccesskit.schema.columns import (
|
|
7
|
+
AutoNumberColumn,
|
|
8
|
+
ColumnBase,
|
|
9
|
+
CurrencyColumn,
|
|
10
|
+
DateTimeColumn,
|
|
11
|
+
DecimalColumn,
|
|
12
|
+
HyperlinkColumn,
|
|
13
|
+
LongTextColumn,
|
|
14
|
+
NumberColumn,
|
|
15
|
+
OleObjectColumn,
|
|
16
|
+
TextColumn,
|
|
17
|
+
YesNoColumn,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = ["relationship_compatible", "storage_type"]
|
|
21
|
+
|
|
22
|
+
_NUMBER_STORAGE = {
|
|
23
|
+
NumberSize.BYTE: "byte",
|
|
24
|
+
NumberSize.INTEGER: "integer",
|
|
25
|
+
NumberSize.LONG_INTEGER: "long",
|
|
26
|
+
NumberSize.SINGLE: "single",
|
|
27
|
+
NumberSize.DOUBLE: "double",
|
|
28
|
+
NumberSize.REPLICATION_ID: "guid",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def storage_type(column: ColumnBase) -> str:
|
|
33
|
+
"""The engine-level storage type of a column (AutoNumber stores a Long Integer, etc.)."""
|
|
34
|
+
if isinstance(column, AutoNumberColumn):
|
|
35
|
+
return "guid" if column.replication_id else "long"
|
|
36
|
+
if isinstance(column, NumberColumn):
|
|
37
|
+
return _NUMBER_STORAGE[column.size]
|
|
38
|
+
simple: tuple[tuple[type[ColumnBase], str], ...] = (
|
|
39
|
+
(TextColumn, "text"),
|
|
40
|
+
(LongTextColumn, "memo"),
|
|
41
|
+
(HyperlinkColumn, "memo"),
|
|
42
|
+
(CurrencyColumn, "currency"),
|
|
43
|
+
(DecimalColumn, "decimal"),
|
|
44
|
+
(DateTimeColumn, "datetime"),
|
|
45
|
+
(YesNoColumn, "boolean"),
|
|
46
|
+
(OleObjectColumn, "binary"),
|
|
47
|
+
)
|
|
48
|
+
for cls, kind in simple:
|
|
49
|
+
if isinstance(column, cls):
|
|
50
|
+
return kind
|
|
51
|
+
return "unknown" # pragma: no cover - every column class is listed above
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def relationship_compatible(primary: ColumnBase, foreign: ColumnBase) -> bool:
|
|
55
|
+
"""Whether two columns can be paired in a relationship (same storage type, indexable)."""
|
|
56
|
+
kind = storage_type(primary)
|
|
57
|
+
return kind == storage_type(foreign) and kind not in ("memo", "binary", "unknown")
|