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,354 @@
1
+ """The Session: sole owner of an engine, its state machine and every cleanup guarantee (COM-free).
2
+
3
+ * **Engine selection** — ``auto`` prefers in-process DAO, else Access; the first design feature lazily
4
+ switches an in-process DAO session to Access (name-based handles survive; raw proxies are revoked).
5
+ * **Thread affinity** — COM objects belong to the apartment that created them; a session refuses calls
6
+ from other threads (:class:`~pyaccesskit.errors.WrongThreadError`).
7
+ * **Atomic creation** — ``create()`` builds a sibling temp file and moves it into place only when the
8
+ session closes without error; otherwise the temp file is deleted and the target is untouched.
9
+ * **Cleanup** — failures while closing never mask the exception that caused the close (they are attached
10
+ as notes); on a normal close they are raised together as :class:`~pyaccesskit.errors.CleanupError`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import contextlib
16
+ import logging
17
+ import os
18
+ import sys
19
+ import threading
20
+ import time
21
+ from collections.abc import Callable
22
+ from pathlib import Path
23
+ from typing import Any, Literal
24
+
25
+ from pyaccesskit._backends.protocols import DesignBackend, SchemaBackend
26
+ from pyaccesskit._com.raw import ProxyRegistry
27
+ from pyaccesskit._session.protocols import EngineHandle, EnginePlan, EnvironmentProbe, RawKind
28
+ from pyaccesskit.enums import Engine, Transport
29
+ from pyaccesskit.errors import (
30
+ AccessNotInstalledError,
31
+ CapabilityError,
32
+ CleanupError,
33
+ DaoNotAvailableError,
34
+ DatabaseExistsError,
35
+ DatabaseLockedError,
36
+ EngineUnavailableError,
37
+ ReadOnlyError,
38
+ SessionClosedError,
39
+ WrongThreadError,
40
+ )
41
+ from pyaccesskit.options import SessionOptions
42
+
43
+ __all__ = ["DefaultProbe", "EngineFactory", "Session"]
44
+
45
+ logger = logging.getLogger("pyaccesskit.session")
46
+
47
+ EngineFactory = Callable[[EnginePlan], EngineHandle]
48
+
49
+ _ACCESS_ADVICE = (
50
+ "Install Microsoft Access (Microsoft 365, 2016 or later) for full functionality, or the Microsoft 365 Access "
51
+ "Runtime together with a Python of the same bitness for DAO-only work. Run 'pyaccesskit doctor' for details."
52
+ )
53
+
54
+
55
+ class DefaultProbe:
56
+ """Probes the real machine (imports the COM layer lazily)."""
57
+
58
+ def inproc_dao(self) -> tuple[bool, str]:
59
+ from pyaccesskit._engines.probe import inproc_dao
60
+
61
+ result = inproc_dao()
62
+ return result.available, result.reason
63
+
64
+ def access(self, progid: str) -> bool:
65
+ from pyaccesskit._engines.probe import (
66
+ access_registered,
67
+ )
68
+
69
+ return access_registered(progid)
70
+
71
+
72
+ def default_factory(plan: EnginePlan) -> EngineHandle:
73
+ """Open a real engine (imports the COM layer lazily)."""
74
+ from pyaccesskit._engines import open_engine
75
+
76
+ engine: EngineHandle = open_engine(plan)
77
+ return engine
78
+
79
+
80
+ class Session:
81
+ """Owns one engine for one database file."""
82
+
83
+ def __init__(
84
+ self,
85
+ *,
86
+ target: Path,
87
+ working: Path,
88
+ create: bool,
89
+ readonly: bool,
90
+ exclusive: bool,
91
+ password: str | None,
92
+ engine: Engine,
93
+ options: SessionOptions,
94
+ factory: EngineFactory | None = None,
95
+ probe: EnvironmentProbe | None = None,
96
+ overwrite: bool = False,
97
+ ) -> None:
98
+ self.target = target
99
+ self.overwrite = overwrite
100
+ self.working = working
101
+ self.created = create
102
+ self.readonly = readonly
103
+ self.exclusive = exclusive
104
+ self.password = password
105
+ self.engine_choice = engine
106
+ self.options = options
107
+ self.thread_id = threading.get_ident()
108
+ self.state: Literal["opening", "open", "closing", "closed"] = "opening"
109
+ self._factory = factory or default_factory
110
+ self._probe = probe or DefaultProbe()
111
+ self._raw = ProxyRegistry()
112
+ self._owns_working = False # set only when our engine created the file
113
+ self._kind = self._select()
114
+ try:
115
+ self._engine: EngineHandle = self._factory(
116
+ self._plan(self._kind, create=create, design=False)
117
+ )
118
+ except BaseException:
119
+ # No AccessDatabase (and so no finalizer) exists yet. Delete the file only if our engine created
120
+ # it: with atomic=False another process may have created the target meanwhile.
121
+ self.state = "closed"
122
+ if self._owns_working:
123
+ with contextlib.suppress(Exception):
124
+ self._discard()
125
+ raise
126
+ self.state = "open"
127
+ logger.info("opened %s via %s", target, self._engine.transport.value)
128
+
129
+ # ------------------------------------------------------------------------------ engine choice
130
+ def _plan(self, kind: Literal["dao", "access"], *, create: bool, design: bool) -> EnginePlan:
131
+ return EnginePlan(
132
+ kind=kind,
133
+ path=self.working,
134
+ create=create,
135
+ readonly=self.readonly,
136
+ exclusive=self.exclusive,
137
+ password=self.password,
138
+ design=design,
139
+ options=self.options,
140
+ on_created=self._mark_created if create else None,
141
+ )
142
+
143
+ def _mark_created(self) -> None:
144
+ self._owns_working = True
145
+
146
+ def _select(self) -> Literal["dao", "access"]:
147
+ progid = self.options.access_progid
148
+ if self.engine_choice is Engine.DAO:
149
+ available, reason = self._probe.inproc_dao()
150
+ if not available:
151
+ raise DaoNotAvailableError("in-process DAO is not available", diagnosis=reason)
152
+ return "dao"
153
+ if self.engine_choice is Engine.ACCESS:
154
+ if not self._probe.access(progid):
155
+ raise AccessNotInstalledError(
156
+ "Microsoft Access is not installed", diagnosis=_ACCESS_ADVICE
157
+ )
158
+ return "access"
159
+ available, reason = self._probe.inproc_dao()
160
+ if available:
161
+ return "dao"
162
+ if self._probe.access(progid):
163
+ return "access"
164
+ raise EngineUnavailableError(
165
+ "neither in-process DAO nor Microsoft Access is available",
166
+ diagnosis=f"In-process DAO: {reason}\nMicrosoft Access: not installed.\n{_ACCESS_ADVICE}",
167
+ )
168
+
169
+ # ------------------------------------------------------------------------------------ checks
170
+ def check(self) -> None:
171
+ """Raise if the session is closed or used from the wrong thread."""
172
+ if self.state != "open":
173
+ raise SessionClosedError(f"the session for {self.target} is closed")
174
+ if threading.get_ident() != self.thread_id:
175
+ raise WrongThreadError(
176
+ "a PyAccessKit session can only be used from the thread that opened it (COM objects are "
177
+ "bound to their apartment); open a separate session per thread or process"
178
+ )
179
+
180
+ def check_thread(self) -> None:
181
+ """Raise :class:`WrongThreadError` if an unclosed session is used from another thread."""
182
+ if self.state != "closed" and threading.get_ident() != self.thread_id:
183
+ raise WrongThreadError(
184
+ "a PyAccessKit session can only be closed from the thread that opened it (COM objects are "
185
+ "bound to their apartment); close it there, or call terminate() for an emergency stop"
186
+ )
187
+
188
+ def check_writable(self, action: str) -> None:
189
+ """Raise :class:`ReadOnlyError` for read-only sessions."""
190
+ self.check()
191
+ if self.readonly:
192
+ raise ReadOnlyError(
193
+ f"cannot {action}: {self.target.name} was opened with readonly=True"
194
+ )
195
+
196
+ @property
197
+ def transport(self) -> Transport:
198
+ """How the database is currently reached."""
199
+ return self._engine.transport
200
+
201
+ @property
202
+ def engine_handle(self) -> EngineHandle:
203
+ """The current engine (for diagnostics and tests)."""
204
+ return self._engine
205
+
206
+ # ---------------------------------------------------------------------------------- backends
207
+ def schema(self) -> SchemaBackend:
208
+ """The schema backend of the current engine."""
209
+ self.check()
210
+ return self._engine.schema()
211
+
212
+ def design(self) -> DesignBackend:
213
+ """The design backend, switching to Microsoft Access first if needed."""
214
+ self.check()
215
+ self._ensure_access()
216
+ return self._engine.design()
217
+
218
+ def _ensure_access(self) -> None:
219
+ if self._kind == "access":
220
+ return
221
+ if self.engine_choice is Engine.DAO:
222
+ raise CapabilityError(
223
+ "forms, reports, modules and text import/export need Microsoft Access; this session was "
224
+ "opened with engine='dao'"
225
+ )
226
+ if self.readonly:
227
+ raise CapabilityError("read-only sessions cannot use design features")
228
+ if not self._probe.access(self.options.access_progid):
229
+ raise AccessNotInstalledError(
230
+ "this feature needs Microsoft Access, which is not installed",
231
+ diagnosis=_ACCESS_ADVICE,
232
+ )
233
+ logger.info("switching %s from in-process DAO to Microsoft Access", self.target)
234
+ self._raw.revoke_all()
235
+ self._raw = ProxyRegistry()
236
+ errors = self._engine.close()
237
+ if errors:
238
+ raise CleanupError(
239
+ "could not release in-process DAO before switching to Access", errors=tuple(errors)
240
+ )
241
+ self._engine = self._factory(self._plan("access", create=False, design=True))
242
+ self._kind = "access"
243
+
244
+ def raw(self, which: RawKind) -> Any:
245
+ """A revocable proxy for a raw COM object (escape hatch)."""
246
+ self.check()
247
+ if which == "access":
248
+ self._ensure_access()
249
+ return self._raw.wrap(self._engine.raw(which), f"db.raw.{which}")
250
+
251
+ # ------------------------------------------------------------------------------------- close
252
+ @property
253
+ def atomic(self) -> bool:
254
+ """Whether the database is being built in a temp file."""
255
+ return self.working != self.target
256
+
257
+ def close(self, error: BaseException | None = None) -> None:
258
+ """Close the session. With ``error`` set, a newly created database is discarded."""
259
+ if self.state in ("closing", "closed"):
260
+ return
261
+ self.check_thread()
262
+ self.state = "closing"
263
+ cleanup: list[BaseException] = []
264
+ interrupt: BaseException | None = None
265
+ try:
266
+ self._raw.revoke_all()
267
+ cleanup.extend(self._engine.close())
268
+ except (KeyboardInterrupt, SystemExit) as exc:
269
+ interrupt = exc
270
+ with contextlib.suppress(Exception):
271
+ self._engine.terminate()
272
+ except Exception as exc:
273
+ cleanup.append(exc)
274
+ finally:
275
+ try:
276
+ if self.atomic:
277
+ if error is None and interrupt is None:
278
+ self._commit()
279
+ else:
280
+ self._discard()
281
+ except Exception as exc:
282
+ cleanup.append(exc)
283
+ self.state = "closed"
284
+ logger.info("closed %s", self.target)
285
+ if interrupt is not None:
286
+ raise interrupt
287
+ if cleanup:
288
+ if error is not None:
289
+ for problem in cleanup:
290
+ error.add_note(
291
+ f"PyAccessKit cleanup also failed: {type(problem).__name__}: {problem}"
292
+ )
293
+ else:
294
+ raise CleanupError(
295
+ f"closing {self.target.name} did not complete cleanly", errors=tuple(cleanup)
296
+ )
297
+
298
+ def terminate(self) -> None:
299
+ """Emergency stop (safe from any thread): terminate an owned Access process, discard temp files."""
300
+ if self.state == "closed":
301
+ return
302
+ self.state = "closed"
303
+ with contextlib.suppress(Exception):
304
+ self._engine.terminate()
305
+ if self.atomic:
306
+ with contextlib.suppress(Exception):
307
+ self._discard()
308
+
309
+ @staticmethod
310
+ def _retry(action: Callable[[], object], attempts: int = 40, delay: float = 0.25) -> None:
311
+ for attempt in range(attempts):
312
+ try:
313
+ action()
314
+ return
315
+ except PermissionError:
316
+ if attempt == attempts - 1:
317
+ raise
318
+ time.sleep(delay)
319
+
320
+ def _commit(self) -> None:
321
+ try:
322
+ if self.overwrite:
323
+ self._retry(lambda: self.working.replace(self.target))
324
+ else:
325
+ self._retry(self._move_without_replacing)
326
+ except FileExistsError as exc:
327
+ raise DatabaseExistsError(
328
+ f"{self.target} was created by someone else while this session was building it; the new "
329
+ f"database was left at {self.working}",
330
+ path=self.target,
331
+ ) from exc
332
+ except OSError as exc:
333
+ raise DatabaseLockedError(
334
+ f"could not move the new database into place at {self.target} ({exc}); it was left at {self.working}",
335
+ path=self.target,
336
+ ) from exc
337
+ self._remove_lock_file(self.working)
338
+
339
+ def _move_without_replacing(self) -> None:
340
+ if sys.platform == "win32":
341
+ self.working.rename(self.target) # MoveFileEx without REPLACE_EXISTING: fails if taken
342
+ else:
343
+ os.link(self.working, self.target) # atomic "create if absent" on POSIX
344
+ self.working.unlink()
345
+
346
+ def _discard(self) -> None:
347
+ self._retry(lambda: self.working.unlink(missing_ok=True))
348
+ self._remove_lock_file(self.working)
349
+
350
+ @staticmethod
351
+ def _remove_lock_file(path: Path) -> None:
352
+ lock_suffix = ".ldb" if path.suffix.lower() in (".mdb", ".mde") else ".laccdb"
353
+ with contextlib.suppress(OSError):
354
+ path.with_suffix(lock_suffix).unlink(missing_ok=True)
File without changes
@@ -0,0 +1,114 @@
1
+ """Encodings used by Access's ``SaveAsText``/``LoadFromText`` (verified in ADR 0001, spike S7).
2
+
3
+ * Forms, reports, queries and macros are exported as **UTF-16LE with a BOM** and must be imported the same
4
+ way (UTF-8 input silently loses non-ASCII text).
5
+ * VBA modules are read and written in the **ANSI code page** (e.g. cp1252); a UTF-8 BOM would become literal
6
+ ```` characters in the code.
7
+ * Class modules are recognised by four leading ``Attribute`` lines (Access's own export format); a VB6-style
8
+ ``VERSION 1.0 CLASS`` header produces an *empty standard* module instead.
9
+
10
+ This module converts between those native byte formats and ordinary Python ``str`` (LF line endings),
11
+ which is how PyAccessKit stores text on disk (UTF-8, git-friendly).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import codecs
17
+ import locale
18
+ import re
19
+
20
+ from pyaccesskit.enums import ModuleKind, ObjectKind
21
+ from pyaccesskit.errors import SpecError
22
+
23
+ __all__ = [
24
+ "CLASS_MODULE_HEADER",
25
+ "ansi_encoding",
26
+ "decode_export",
27
+ "encode_import",
28
+ "module_import_text",
29
+ "split_module_export",
30
+ ]
31
+
32
+ CLASS_MODULE_HEADER = (
33
+ "Attribute VB_GlobalNameSpace = False",
34
+ "Attribute VB_Creatable = False",
35
+ "Attribute VB_PredeclaredId = False",
36
+ "Attribute VB_Exposed = False",
37
+ )
38
+ """The lines Access writes at the top of an exported class module (and uses to recognise one)."""
39
+
40
+ _UNICODE_KINDS = frozenset({ObjectKind.FORM, ObjectKind.REPORT, ObjectKind.QUERY, ObjectKind.MACRO})
41
+ _VB6_HEADER = re.compile(
42
+ r"\AVERSION\s+1\.0\s+CLASS\s*\nBEGIN\s*\n.*?\nEND\s*\n", re.IGNORECASE | re.DOTALL
43
+ )
44
+ _ATTRIBUTE_LINE = re.compile(r"^Attribute\s+VB_\w+\s*=.*$", re.IGNORECASE)
45
+
46
+
47
+ def ansi_encoding() -> str:
48
+ """The Windows ANSI code page Access uses for module text (e.g. ``cp1252``)."""
49
+ return locale.getencoding()
50
+
51
+
52
+ def _to_lf(text: str) -> str:
53
+ return text.replace("\r\n", "\n").replace("\r", "\n")
54
+
55
+
56
+ def decode_export(kind: ObjectKind, raw: bytes) -> str:
57
+ """Decode bytes written by ``SaveAsText`` into text with LF line endings."""
58
+ if raw.startswith(codecs.BOM_UTF16_LE):
59
+ text = raw.decode("utf-16")
60
+ elif raw.startswith(codecs.BOM_UTF8):
61
+ text = raw.decode("utf-8-sig")
62
+ elif kind in _UNICODE_KINDS:
63
+ text = raw.decode("utf-16-le")
64
+ else:
65
+ text = raw.decode(ansi_encoding())
66
+ return _to_lf(text)
67
+
68
+
69
+ def encode_import(kind: ObjectKind, text: str) -> bytes:
70
+ """Encode text for ``LoadFromText`` (CRLF line endings, UTF-16LE+BOM or ANSI depending on ``kind``).
71
+
72
+ Raises:
73
+ SpecError: If module text contains characters the ANSI code page cannot represent.
74
+ """
75
+ crlf = _to_lf(text).replace("\n", "\r\n")
76
+ if kind in _UNICODE_KINDS:
77
+ return codecs.BOM_UTF16_LE + crlf.encode("utf-16-le")
78
+ encoding = ansi_encoding()
79
+ try:
80
+ return crlf.encode(encoding)
81
+ except UnicodeEncodeError as exc:
82
+ bad = crlf[exc.start : exc.end]
83
+ raise SpecError(
84
+ f"VBA module text contains {bad!r}, which the ANSI code page {encoding} cannot represent; "
85
+ "VBA source is stored in the system code page (use ChrW(...) for other characters)"
86
+ ) from exc
87
+
88
+
89
+ def split_module_export(text: str) -> tuple[ModuleKind, str]:
90
+ """Split exported module text into its kind and the code (class header removed)."""
91
+ lines = _to_lf(text).split("\n")
92
+ header = [line.strip() for line in lines[: len(CLASS_MODULE_HEADER)]]
93
+ if [h.casefold() for h in header] == [h.casefold() for h in CLASS_MODULE_HEADER]:
94
+ return ModuleKind.CLASS, "\n".join(lines[len(CLASS_MODULE_HEADER) :])
95
+ return ModuleKind.STANDARD, "\n".join(lines)
96
+
97
+
98
+ def module_import_text(code: str, kind: ModuleKind) -> str:
99
+ """Prepare module code for ``LoadFromText``.
100
+
101
+ * converts VB6 ``.cls`` files (``VERSION 1.0 CLASS`` header) and strips ``Attribute`` lines;
102
+ * adds ``Option Compare Database`` if missing (as Access does for new modules);
103
+ * prefixes class modules with :data:`CLASS_MODULE_HEADER`.
104
+ """
105
+ text = _to_lf(code).lstrip("")
106
+ text = _VB6_HEADER.sub("", text)
107
+ body = [line for line in text.split("\n") if not _ATTRIBUTE_LINE.match(line.strip())]
108
+ while body and not body[0].strip():
109
+ body.pop(0)
110
+ if not any(line.strip().casefold().startswith("option compare") for line in body):
111
+ body.insert(0, "Option Compare Database")
112
+ lines = list(CLASS_MODULE_HEADER) + body if kind is ModuleKind.CLASS else body
113
+ result = "\n".join(lines)
114
+ return result if result.endswith("\n") else result + "\n"
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the package version (read by hatchling)."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """Win32 process management (pywin32). Imported lazily by the engines."""