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,471 @@
1
+ """Schema operations: semantic pre-validation + linting, then one backend call.
2
+
3
+ Every public schema mutation goes through here, so users get the same precise errors regardless of the
4
+ backend (in-process DAO, Access-hosted DAO, or the in-memory fake) — and most mistakes are reported
5
+ before Access is touched at all.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import uuid
12
+ from collections.abc import Iterable
13
+
14
+ from pyaccesskit._backends.protocols import SchemaBackend, TableInfo
15
+ from pyaccesskit.enums import DataType, ObjectKind
16
+ from pyaccesskit.errors import (
17
+ ObjectExistsError,
18
+ ObjectNotFoundError,
19
+ RelationshipError,
20
+ SchemaError,
21
+ SpecError,
22
+ )
23
+ from pyaccesskit.schema import (
24
+ ColumnBase,
25
+ ColumnSpec,
26
+ IndexField,
27
+ IndexSpec,
28
+ QuerySpec,
29
+ RelationshipSpec,
30
+ TableSpec,
31
+ UnsupportedColumn,
32
+ sql_equivalent,
33
+ )
34
+ from pyaccesskit.schema.compat import relationship_compatible, storage_type
35
+ from pyaccesskit.schema.names import check_name, warn_name
36
+ from pyaccesskit.schema.tables import MAX_INDEXES
37
+
38
+ __all__ = [
39
+ "add_column",
40
+ "create_index",
41
+ "create_or_replace_query",
42
+ "create_relationship",
43
+ "create_table",
44
+ "drop_column",
45
+ "drop_index",
46
+ "drop_query",
47
+ "drop_relationship",
48
+ "drop_table",
49
+ "find_query",
50
+ "find_table",
51
+ "rename_column",
52
+ "rename_query",
53
+ "rename_table",
54
+ ]
55
+
56
+
57
+ def _key(name: str) -> str:
58
+ return name.casefold()
59
+
60
+
61
+ # ------------------------------------------------------------------------------------- lookups
62
+ def find_table(schema: SchemaBackend, name: str) -> TableInfo | None:
63
+ """The table called ``name`` (case-insensitive), or ``None``."""
64
+ key = _key(name)
65
+ return next((t for t in schema.list_tables() if _key(t.name) == key), None)
66
+
67
+
68
+ def require_table(schema: SchemaBackend, name: str) -> TableInfo:
69
+ """Like :func:`find_table` but raises :class:`ObjectNotFoundError`."""
70
+ info = find_table(schema, name)
71
+ if info is None:
72
+ raise ObjectNotFoundError(
73
+ f"table {name!r} does not exist", kind=ObjectKind.TABLE, name=name
74
+ )
75
+ return info
76
+
77
+
78
+ def find_query(schema: SchemaBackend, name: str) -> str | None:
79
+ """The actual name of the query called ``name`` (case-insensitive), or ``None``."""
80
+ key = _key(name)
81
+ return next((q.name for q in schema.list_queries() if _key(q.name) == key), None)
82
+
83
+
84
+ def ensure_name_free(schema: SchemaBackend, name: str, *, ignore: str | None = None) -> None:
85
+ """Raise :class:`ObjectExistsError` if a table or query already uses ``name``.
86
+
87
+ Tables and queries share one namespace in Access. ``ignore`` skips an object being renamed.
88
+ """
89
+ key = _key(name)
90
+ if ignore is not None and _key(ignore) == key:
91
+ return
92
+ table = find_table(schema, name)
93
+ if table is not None:
94
+ raise ObjectExistsError(
95
+ f"a table named {table.name!r} already exists", kind=ObjectKind.TABLE, name=name
96
+ )
97
+ query = find_query(schema, name)
98
+ if query is not None:
99
+ raise ObjectExistsError(
100
+ f"a query named {query!r} already exists (tables and queries share one namespace)",
101
+ kind=ObjectKind.QUERY,
102
+ name=name,
103
+ )
104
+
105
+
106
+ def _column(spec: TableSpec, name: str) -> ColumnBase:
107
+ try:
108
+ return spec.column(name)
109
+ except KeyError:
110
+ raise ObjectNotFoundError(
111
+ f"table {spec.name!r} has no column {name!r} (columns: {', '.join(spec.column_names)})",
112
+ kind=ObjectKind.FIELD,
113
+ name=name,
114
+ ) from None
115
+
116
+
117
+ def _lint_table(spec: TableSpec) -> None:
118
+ warn_name(spec.name, what="table name", stacklevel=5)
119
+ for column in spec.columns:
120
+ warn_name(column.name, what=f"column name in table {spec.name!r}", stacklevel=5)
121
+
122
+
123
+ # -------------------------------------------------------------------------------------- tables
124
+ def _check_creatable(column: ColumnBase, table: str) -> None:
125
+ if isinstance(column, UnsupportedColumn):
126
+ raise SpecError(
127
+ f"cannot create column {column.name!r} of table {table!r}: {column.detail or 'this type'} "
128
+ "columns can be read but not created by PyAccessKit yet"
129
+ )
130
+
131
+
132
+ def create_table(schema: SchemaBackend, spec: TableSpec, *, lint: bool = True) -> TableSpec:
133
+ """Create a table; returns the normalized spec that was created."""
134
+ for column in spec.columns:
135
+ _check_creatable(column, spec.name)
136
+ normalized = spec.normalized()
137
+ ensure_name_free(schema, normalized.name)
138
+ if lint:
139
+ _lint_table(normalized)
140
+ schema.create_table(normalized)
141
+ return normalized
142
+
143
+
144
+ def drop_table(schema: SchemaBackend, name: str, *, drop_relationships: bool = False) -> None:
145
+ """Delete a table (optionally deleting the relationships it takes part in first)."""
146
+ info = require_table(schema, name)
147
+ related = [
148
+ rel
149
+ for rel in schema.list_relationships()
150
+ if _key(rel.primary_table) == _key(info.name) or _key(rel.foreign_table) == _key(info.name)
151
+ ]
152
+ if related and not drop_relationships:
153
+ names = ", ".join(repr(rel.effective_name) for rel in related)
154
+ raise SchemaError(
155
+ f"table {info.name!r} is part of relationship(s) {names}; delete them first or pass "
156
+ "drop_relationships=True"
157
+ )
158
+ for rel in related:
159
+ schema.drop_relationship(rel.effective_name)
160
+ schema.drop_table(info.name)
161
+
162
+
163
+ def rename_table(schema: SchemaBackend, old: str, new: str) -> str:
164
+ """Rename a table; returns the new name."""
165
+ info = require_table(schema, old)
166
+ check_name(new, what="table name")
167
+ ensure_name_free(schema, new, ignore=info.name)
168
+ warn_name(new, what="table name", stacklevel=4)
169
+ schema.rename_table(info.name, new)
170
+ return new
171
+
172
+
173
+ def _index_budget(schema: SchemaBackend, table: str, extra: int) -> None:
174
+ used = schema.count_indexes(table)
175
+ if used + extra > MAX_INDEXES:
176
+ raise SchemaError(
177
+ f"table {table!r} would need {used + extra} indexes; Access allows {MAX_INDEXES} per table "
178
+ "(including hidden indexes of enforced relationships)"
179
+ )
180
+
181
+
182
+ def add_column(schema: SchemaBackend, table: str, column: ColumnSpec, *, lint: bool = True) -> None:
183
+ """Append a column, creating any index requested with ``unique=``/``indexed=``/``primary_key=``."""
184
+ info = require_table(schema, table)
185
+ _check_creatable(column, info.name)
186
+ spec = schema.read_table(info.name)
187
+ if any(_key(c.name) == _key(column.name) for c in spec.columns):
188
+ raise ObjectExistsError(
189
+ f"table {spec.name!r} already has a column {column.name!r}",
190
+ kind=ObjectKind.FIELD,
191
+ name=column.name,
192
+ )
193
+ if column.data_type is DataType.AUTONUMBER and any(
194
+ c.data_type is DataType.AUTONUMBER for c in spec.columns
195
+ ):
196
+ raise SchemaError(f"table {spec.name!r} already has an AutoNumber column")
197
+ new_indexes: list[IndexSpec] = []
198
+ if column.primary_key:
199
+ if spec.primary_index is not None:
200
+ raise SchemaError(f"table {spec.name!r} already has a primary key")
201
+ new_indexes.append(IndexSpec.primary_key(column.name))
202
+ elif column.unique or column.indexed:
203
+ new_indexes.append(
204
+ IndexSpec(
205
+ name=column.name, fields=(IndexField(name=column.name),), unique=column.unique
206
+ )
207
+ )
208
+ for index in new_indexes: # check before mutating: a clash would otherwise strand the column
209
+ if any(_key(i.name) == _key(index.name) for i in spec.indexes):
210
+ raise ObjectExistsError(
211
+ f"table {spec.name!r} already has an index named {index.name!r}; add the column "
212
+ "without the shorthand and create the index with an explicit name",
213
+ kind=ObjectKind.INDEX,
214
+ name=index.name,
215
+ )
216
+ if new_indexes:
217
+ _index_budget(schema, spec.name, len(new_indexes))
218
+ if lint:
219
+ warn_name(column.name, what=f"column name in table {spec.name!r}", stacklevel=4)
220
+ schema.add_column(spec.name, column.normalized())
221
+ created: list[str] = []
222
+ try:
223
+ for index in new_indexes:
224
+ schema.create_index(spec.name, index)
225
+ created.append(index.name)
226
+ except BaseException:
227
+ with contextlib.suppress(Exception):
228
+ for name in reversed(created):
229
+ schema.drop_index(spec.name, name)
230
+ schema.drop_column(spec.name, column.name)
231
+ raise
232
+
233
+
234
+ def drop_column(schema: SchemaBackend, table: str, column: str) -> None:
235
+ """Delete a column (it must not be part of an index or relationship)."""
236
+ info = require_table(schema, table)
237
+ spec = schema.read_table(info.name)
238
+ target = _column(spec, column)
239
+ indexes = [
240
+ i.name for i in spec.indexes if any(_key(f) == _key(target.name) for f in i.field_names)
241
+ ]
242
+ if indexes:
243
+ raise SchemaError(f"column {target.name!r} is part of index(es) {indexes}; drop them first")
244
+ for rel in schema.list_relationships():
245
+ sides: list[str] = []
246
+ if _key(rel.primary_table) == _key(spec.name):
247
+ sides.extend(rel.primary_columns)
248
+ if _key(rel.foreign_table) == _key(spec.name):
249
+ sides.extend(rel.foreign_columns)
250
+ if any(_key(c) == _key(target.name) for c in sides):
251
+ raise SchemaError(
252
+ f"column {target.name!r} is part of relationship {rel.effective_name!r}; drop it first"
253
+ )
254
+ if len(spec.columns) == 1:
255
+ raise SchemaError(f"cannot drop the last column of table {spec.name!r}")
256
+ schema.drop_column(spec.name, target.name)
257
+
258
+
259
+ def rename_column(schema: SchemaBackend, table: str, old: str, new: str) -> None:
260
+ """Rename a column."""
261
+ info = require_table(schema, table)
262
+ spec = schema.read_table(info.name)
263
+ target = _column(spec, old)
264
+ check_name(new, what="column name")
265
+ if _key(old) != _key(new) and any(_key(c.name) == _key(new) for c in spec.columns):
266
+ raise ObjectExistsError(
267
+ f"table {spec.name!r} already has a column {new!r}", kind=ObjectKind.FIELD, name=new
268
+ )
269
+ warn_name(new, what=f"column name in table {spec.name!r}", stacklevel=4)
270
+ schema.rename_column(spec.name, target.name, new)
271
+
272
+
273
+ def create_index(schema: SchemaBackend, table: str, index: IndexSpec) -> None:
274
+ """Create an index after checking columns, name and the 32-index budget."""
275
+ info = require_table(schema, table)
276
+ spec = schema.read_table(info.name)
277
+ for field_name in index.field_names:
278
+ column = _column(spec, field_name)
279
+ if not column.indexable:
280
+ raise SpecError(f"{column.data_type.value} column {column.name!r} cannot be indexed")
281
+ if index.primary and spec.primary_index is not None:
282
+ raise SchemaError(
283
+ f"table {spec.name!r} already has a primary key ({spec.primary_index.name!r})"
284
+ )
285
+ if any(_key(i.name) == _key(index.name) for i in spec.indexes):
286
+ raise ObjectExistsError(
287
+ f"table {spec.name!r} already has an index named {index.name!r}",
288
+ kind=ObjectKind.INDEX,
289
+ name=index.name,
290
+ )
291
+ _index_budget(schema, spec.name, 1)
292
+ schema.create_index(spec.name, index)
293
+
294
+
295
+ def drop_index(schema: SchemaBackend, table: str, name: str) -> None:
296
+ """Delete an index."""
297
+ info = require_table(schema, table)
298
+ spec = schema.read_table(info.name)
299
+ match = next((i for i in spec.indexes if _key(i.name) == _key(name)), None)
300
+ if match is None:
301
+ raise ObjectNotFoundError(
302
+ f"table {spec.name!r} has no index {name!r}", kind=ObjectKind.INDEX, name=name
303
+ )
304
+ for rel in schema.list_relationships():
305
+ if (
306
+ match.unique
307
+ and _key(rel.primary_table) == _key(spec.name)
308
+ and {_key(c) for c in rel.primary_columns} == {_key(f) for f in match.field_names}
309
+ ):
310
+ raise SchemaError(
311
+ f"index {match.name!r} is required by relationship {rel.effective_name!r}; drop the relationship first"
312
+ )
313
+ schema.drop_index(spec.name, match.name)
314
+
315
+
316
+ # ------------------------------------------------------------------------------- relationships
317
+ def _canonical_columns(spec: TableSpec, names: Iterable[str]) -> tuple[ColumnBase, ...]:
318
+ return tuple(_column(spec, name) for name in names)
319
+
320
+
321
+ def create_relationship(schema: SchemaBackend, spec: RelationshipSpec) -> RelationshipSpec:
322
+ """Validate a relationship thoroughly, then create it. Returns the normalized spec used."""
323
+ primary_info = require_table(schema, spec.primary_table)
324
+ foreign_info = require_table(schema, spec.foreign_table)
325
+ primary = schema.read_table(primary_info.name)
326
+ foreign = schema.read_table(foreign_info.name)
327
+ primary_cols = _canonical_columns(primary, spec.primary_columns)
328
+ foreign_cols = _canonical_columns(foreign, spec.foreign_columns)
329
+
330
+ normalized = spec.model_copy(
331
+ update={
332
+ "primary_table": primary.name,
333
+ "foreign_table": foreign.name,
334
+ "primary_columns": tuple(c.name for c in primary_cols),
335
+ "foreign_columns": tuple(c.name for c in foreign_cols),
336
+ "name": spec.name or f"{primary.name}{foreign.name}",
337
+ }
338
+ )
339
+ name = normalized.effective_name
340
+ existing = schema.list_relationships()
341
+ if any(_key(rel.effective_name) == _key(name) for rel in existing):
342
+ hint = (
343
+ ""
344
+ if spec.name
345
+ else "; pass name=... to create another relationship between these tables"
346
+ )
347
+ raise ObjectExistsError(
348
+ f"a relationship named {name!r} already exists{hint}",
349
+ kind=ObjectKind.RELATIONSHIP,
350
+ name=name,
351
+ )
352
+ if any(_key(i.name) == _key(name) for i in foreign.indexes):
353
+ raise ObjectExistsError(
354
+ f"relationship name {name!r} clashes with an index of table {foreign.name!r} (the relationship's "
355
+ "hidden index takes its name)",
356
+ kind=ObjectKind.RELATIONSHIP,
357
+ name=name,
358
+ )
359
+ for p_col, f_col in zip(primary_cols, foreign_cols, strict=True):
360
+ if not relationship_compatible(p_col, f_col):
361
+ raise RelationshipError(
362
+ f"cannot relate {primary.name}.{p_col.name} ({storage_type(p_col)}) to "
363
+ f"{foreign.name}.{f_col.name} ({storage_type(f_col)}): related columns must have the same "
364
+ "storage type (an AutoNumber pairs with a Number/Long Integer)"
365
+ )
366
+ wanted = {_key(c.name) for c in primary_cols}
367
+ if not any(i.unique and {_key(f) for f in i.field_names} == wanted for i in primary.indexes):
368
+ columns = ", ".join(c.name for c in primary_cols)
369
+ raise RelationshipError(
370
+ f"{primary.name}({columns}) must be the primary key or have a unique index to be referenced by "
371
+ "a relationship"
372
+ )
373
+ if normalized.enforce_integrity:
374
+ _index_budget(schema, foreign.name, 1)
375
+ warn_name(name, what="relationship name", stacklevel=4)
376
+ schema.create_relationship(normalized)
377
+ return normalized
378
+
379
+
380
+ def drop_relationship(schema: SchemaBackend, name: str) -> None:
381
+ """Delete a relationship."""
382
+ match = next(
383
+ (r for r in schema.list_relationships() if _key(r.effective_name) == _key(name)), None
384
+ )
385
+ if match is None:
386
+ raise ObjectNotFoundError(
387
+ f"relationship {name!r} does not exist", kind=ObjectKind.RELATIONSHIP, name=name
388
+ )
389
+ schema.drop_relationship(match.effective_name)
390
+
391
+
392
+ # ------------------------------------------------------------------------------------- queries
393
+ def create_or_replace_query(
394
+ schema: SchemaBackend, spec: QuerySpec, *, replace: bool = False
395
+ ) -> bool:
396
+ """Create a saved query, or replace its SQL when ``replace=True``.
397
+
398
+ Returns ``True`` if the query was created or changed, ``False`` if an identical query already existed.
399
+ """
400
+ existing = find_query(schema, spec.name)
401
+ if existing is not None:
402
+ if not replace:
403
+ raise ObjectExistsError(
404
+ f"a query named {existing!r} already exists", kind=ObjectKind.QUERY, name=spec.name
405
+ )
406
+ current = schema.read_query(existing)
407
+ if current.pass_through != spec.pass_through:
408
+ _swap_query(schema, existing, spec.normalized())
409
+ return True
410
+ if spec.pass_through is not None:
411
+ # Server dialect: Access's SQL normalization (case folding...) does not apply.
412
+ unchanged = _line_endings(current.sql) == _line_endings(spec.sql)
413
+ else:
414
+ unchanged = sql_equivalent(current.sql, spec.sql)
415
+ if unchanged:
416
+ return False
417
+ schema.set_query_sql(existing, spec.normalized().sql)
418
+ return True
419
+ ensure_name_free(schema, spec.name)
420
+ warn_name(spec.name, what="query name", stacklevel=4)
421
+ schema.create_query(spec.normalized())
422
+ return True
423
+
424
+
425
+ def _line_endings(sql: str) -> str:
426
+ return sql.replace("\r\n", "\n").replace("\r", "\n").strip()
427
+
428
+
429
+ def _swap_query(schema: SchemaBackend, existing: str, replacement: QuerySpec) -> None:
430
+ """Replace a query whose type changes: build the new one first so a failure keeps the original."""
431
+ original = schema.read_query(existing)
432
+ temporary = f"~pak_tmp_{uuid.uuid4().hex[:8]}"
433
+ try:
434
+ schema.create_query(replacement.model_copy(update={"name": temporary}))
435
+ except BaseException:
436
+ with contextlib.suppress(Exception):
437
+ if find_query(schema, temporary) is not None:
438
+ schema.drop_query(temporary)
439
+ raise
440
+ try:
441
+ schema.drop_query(existing)
442
+ schema.rename_query(temporary, replacement.name)
443
+ except BaseException:
444
+ with contextlib.suppress(Exception):
445
+ if find_query(schema, existing) is None:
446
+ schema.create_query(original)
447
+ with contextlib.suppress(Exception):
448
+ schema.drop_query(temporary)
449
+ raise
450
+
451
+
452
+ def rename_query(schema: SchemaBackend, old: str, new: str) -> str:
453
+ """Rename a saved query; returns the new name."""
454
+ actual = find_query(schema, old)
455
+ if actual is None:
456
+ raise ObjectNotFoundError(f"query {old!r} does not exist", kind=ObjectKind.QUERY, name=old)
457
+ check_name(new, what="query name")
458
+ ensure_name_free(schema, new, ignore=actual)
459
+ warn_name(new, what="query name", stacklevel=4)
460
+ schema.rename_query(actual, new)
461
+ return new
462
+
463
+
464
+ def drop_query(schema: SchemaBackend, name: str) -> None:
465
+ """Delete a saved query."""
466
+ actual = find_query(schema, name)
467
+ if actual is None:
468
+ raise ObjectNotFoundError(
469
+ f"query {name!r} does not exist", kind=ObjectKind.QUERY, name=name
470
+ )
471
+ schema.drop_query(actual)
@@ -0,0 +1 @@
1
+ """Session management (COM-free)."""
@@ -0,0 +1,78 @@
1
+ """COM-free interfaces between the session and the engines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Literal, Protocol
9
+
10
+ from pyaccesskit._backends.protocols import DesignBackend, SchemaBackend
11
+ from pyaccesskit.enums import Transport
12
+ from pyaccesskit.options import SessionOptions
13
+
14
+ __all__ = ["EngineHandle", "EnginePlan", "EnvironmentProbe", "RawKind"]
15
+
16
+ RawKind = Literal["dao", "dbengine", "access"]
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class EnginePlan:
21
+ """What engine to open, on which file, and how."""
22
+
23
+ kind: Literal["dao", "access"]
24
+ path: Path
25
+ create: bool
26
+ readonly: bool
27
+ exclusive: bool
28
+ password: str | None
29
+ design: bool
30
+ options: SessionOptions
31
+ on_created: Callable[[], None] | None = None
32
+ """Called by the engine as soon as it has created ``path`` itself (so failures may delete it)."""
33
+
34
+
35
+ class EngineHandle(Protocol):
36
+ """An open engine: owns every COM root reference (and, for Access, the process)."""
37
+
38
+ @property
39
+ def transport(self) -> Transport:
40
+ """How the database is currently reached."""
41
+ ...
42
+
43
+ @property
44
+ def supports_design(self) -> bool:
45
+ """Whether design features are possible without switching engines."""
46
+ ...
47
+
48
+ def schema(self) -> SchemaBackend:
49
+ """The schema backend."""
50
+ ...
51
+
52
+ def design(self) -> DesignBackend:
53
+ """The design backend (may upgrade to a design session, or raise ``CapabilityError``)."""
54
+ ...
55
+
56
+ def raw(self, which: str) -> Any:
57
+ """Raw COM object for the escape hatch."""
58
+ ...
59
+
60
+ def close(self) -> list[BaseException]:
61
+ """Release everything; returns the errors of failed cleanup steps."""
62
+ ...
63
+
64
+ def terminate(self) -> None:
65
+ """Emergency stop from any thread (terminates an owned Access process)."""
66
+ ...
67
+
68
+
69
+ class EnvironmentProbe(Protocol):
70
+ """Answers "which engines can this process use?" (replaceable in tests)."""
71
+
72
+ def inproc_dao(self) -> tuple[bool, str]:
73
+ """``(available, reason_if_not)`` for in-process DAO."""
74
+ ...
75
+
76
+ def access(self, progid: str) -> bool:
77
+ """Whether Microsoft Access is registered."""
78
+ ...