mcp-msaccess-database 0.7.31__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.
- access_mcp_server.py +30 -0
- mcp_access/__init__.py +1 -0
- mcp_access/code.py +568 -0
- mcp_access/compile.py +677 -0
- mcp_access/constants.py +170 -0
- mcp_access/controls.py +766 -0
- mcp_access/core.py +534 -0
- mcp_access/database.py +296 -0
- mcp_access/dispatcher.py +622 -0
- mcp_access/export.py +113 -0
- mcp_access/helpers.py +250 -0
- mcp_access/maintenance.py +341 -0
- mcp_access/properties.py +165 -0
- mcp_access/relations.py +426 -0
- mcp_access/server.py +115 -0
- mcp_access/sql.py +274 -0
- mcp_access/tips.py +145 -0
- mcp_access/tools.py +1274 -0
- mcp_access/ui.py +357 -0
- mcp_access/vba_exec.py +377 -0
- mcp_access/vbe.py +1495 -0
- mcp_msaccess_database-0.7.31.dist-info/METADATA +692 -0
- mcp_msaccess_database-0.7.31.dist-info/RECORD +26 -0
- mcp_msaccess_database-0.7.31.dist-info/WHEEL +5 -0
- mcp_msaccess_database-0.7.31.dist-info/entry_points.txt +2 -0
- mcp_msaccess_database-0.7.31.dist-info/top_level.txt +2 -0
access_mcp_server.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
access_mcp_server.py
|
|
4
|
+
====================
|
|
5
|
+
MCP Server for reading and editing Microsoft Access databases (.accdb/.mdb)
|
|
6
|
+
via COM automation (pywin32). Requires Windows + Microsoft Access installed.
|
|
7
|
+
|
|
8
|
+
Install dependencies:
|
|
9
|
+
pip install mcp pywin32
|
|
10
|
+
|
|
11
|
+
Register in Claude Code (one of two methods):
|
|
12
|
+
# Option A -- global
|
|
13
|
+
claude mcp add access -- python /path/to/access_mcp_server.py
|
|
14
|
+
|
|
15
|
+
# Option B -- this project only (creates .mcp.json in current directory)
|
|
16
|
+
claude mcp add --scope project access -- python /path/to/access_mcp_server.py
|
|
17
|
+
|
|
18
|
+
Typical workflow for editing VBA:
|
|
19
|
+
1. access_list_objects -> see which modules/forms exist
|
|
20
|
+
2. access_get_code -> export the object to text
|
|
21
|
+
3. (Claude edits the text)
|
|
22
|
+
4. access_set_code -> reimport the modified text
|
|
23
|
+
5. access_close -> release Access (optional)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
from mcp_access.server import main
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
asyncio.run(main())
|
mcp_access/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# mcp_access — MCP Server for Microsoft Access databases
|
mcp_access/code.py
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Object management: list, get, set, delete objects, create form, export structure.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .core import (
|
|
12
|
+
AC_TYPE, _Session, _parsed_controls_cache, log,
|
|
13
|
+
invalidate_all_caches, invalidate_object_caches,
|
|
14
|
+
)
|
|
15
|
+
from .constants import BINARY_SECTIONS, AC_FORM, AC_SAVE_NO
|
|
16
|
+
from .helpers import read_tmp, write_tmp, strip_binary_sections, restore_binary_sections
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Design-view helpers (used by _inject_vba_after_import)
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# These are small private helpers also used by controls module. Duplicated
|
|
23
|
+
# here to keep the module self-contained; the canonical copy lives in helpers
|
|
24
|
+
# once that module is extended.
|
|
25
|
+
|
|
26
|
+
_AC_DESIGN = 1 # acDesign / acViewDesign
|
|
27
|
+
_AC_SAVE_YES = 1 # acSaveYes
|
|
28
|
+
_AC_REPORT = 3 # acReport
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _open_in_design(app: Any, object_type: str, object_name: str) -> None:
|
|
32
|
+
"""Opens a form/report in Design view."""
|
|
33
|
+
try:
|
|
34
|
+
if object_type == "form":
|
|
35
|
+
app.DoCmd.OpenForm(object_name, _AC_DESIGN)
|
|
36
|
+
else:
|
|
37
|
+
app.DoCmd.OpenReport(object_name, _AC_DESIGN)
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
raise RuntimeError(
|
|
40
|
+
f"Could not open '{object_name}' in Design view. "
|
|
41
|
+
f"If it is open in Normal view, close it first.\nError: {exc}"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _save_and_close(app: Any, object_type: str, object_name: str) -> None:
|
|
46
|
+
"""Saves and closes a form/report open in Design view."""
|
|
47
|
+
ac_type = AC_FORM if object_type == "form" else _AC_REPORT
|
|
48
|
+
try:
|
|
49
|
+
app.DoCmd.Close(ac_type, object_name, _AC_SAVE_YES)
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
log.warning("Error closing '%s': %s", object_name, exc)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _get_design_obj(app: Any, object_type: str, object_name: str) -> Any:
|
|
55
|
+
"""Returns the Form or Report object open in Design view."""
|
|
56
|
+
return app.Forms(object_name) if object_type == "form" else app.Reports(object_name)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# List objects
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def ac_list_objects(db_path: str, object_type: str = "all") -> dict:
|
|
64
|
+
"""Returns a dict {type: [names...]} with the database objects."""
|
|
65
|
+
app = _Session.connect(db_path)
|
|
66
|
+
|
|
67
|
+
# CurrentData -> data objects (tables, queries)
|
|
68
|
+
# CurrentProject -> code objects (forms, reports, modules, macros)
|
|
69
|
+
containers = {
|
|
70
|
+
"table": app.CurrentData.AllTables,
|
|
71
|
+
"query": app.CurrentData.AllQueries,
|
|
72
|
+
"form": app.CurrentProject.AllForms,
|
|
73
|
+
"report": app.CurrentProject.AllReports,
|
|
74
|
+
"macro": app.CurrentProject.AllMacros,
|
|
75
|
+
"module": app.CurrentProject.AllModules,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
keys = list(containers) if object_type == "all" else [object_type]
|
|
79
|
+
result: dict[str, list] = {}
|
|
80
|
+
|
|
81
|
+
for k in keys:
|
|
82
|
+
if k not in containers:
|
|
83
|
+
continue
|
|
84
|
+
col = containers[k]
|
|
85
|
+
names = [col.Item(i).Name for i in range(col.Count)]
|
|
86
|
+
if k == "table":
|
|
87
|
+
# Filter out system and temp tables
|
|
88
|
+
names = [n for n in names if not n.startswith("MSys") and not n.startswith("~")]
|
|
89
|
+
result[k] = names
|
|
90
|
+
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Delete object
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def ac_delete_object(
|
|
99
|
+
db_path: str, object_type: str, object_name: str, confirm: bool = False,
|
|
100
|
+
) -> dict:
|
|
101
|
+
"""Deletes an Access object (module, form, report, query, macro) via DoCmd.DeleteObject."""
|
|
102
|
+
if object_type not in AC_TYPE:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"Invalid object_type '{object_type}'. Valid: {list(AC_TYPE)}"
|
|
105
|
+
)
|
|
106
|
+
if not confirm:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
"Destructive operation: confirm=true is required to delete an object."
|
|
109
|
+
)
|
|
110
|
+
app = _Session.connect(db_path)
|
|
111
|
+
try:
|
|
112
|
+
app.DoCmd.DeleteObject(AC_TYPE[object_type], object_name)
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
raise RuntimeError(
|
|
115
|
+
f"Error deleting {object_type} '{object_name}': {exc}"
|
|
116
|
+
)
|
|
117
|
+
finally:
|
|
118
|
+
invalidate_all_caches()
|
|
119
|
+
return {
|
|
120
|
+
"action": "deleted",
|
|
121
|
+
"object_type": object_type,
|
|
122
|
+
"object_name": object_name,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Get code (export)
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def ac_get_code(db_path: str, object_type: str, name: str) -> str:
|
|
131
|
+
"""
|
|
132
|
+
Exports an Access object to text and returns the content.
|
|
133
|
+
For forms and reports, strips binary sections (PrtMip, PrtDevMode...)
|
|
134
|
+
that are irrelevant for editing VBA/controls and represent 95% of the size.
|
|
135
|
+
ac_set_code restores them automatically before importing.
|
|
136
|
+
"""
|
|
137
|
+
if object_type not in AC_TYPE:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"Invalid object_type '{object_type}'. Valid: {list(AC_TYPE)}"
|
|
140
|
+
)
|
|
141
|
+
app = _Session.connect(db_path)
|
|
142
|
+
|
|
143
|
+
fd, tmp = tempfile.mkstemp(suffix=".txt", prefix="access_mcp_")
|
|
144
|
+
os.close(fd)
|
|
145
|
+
try:
|
|
146
|
+
app.SaveAsText(AC_TYPE[object_type], name, tmp)
|
|
147
|
+
text, _enc = read_tmp(tmp)
|
|
148
|
+
if object_type in ("form", "report"):
|
|
149
|
+
text = strip_binary_sections(text)
|
|
150
|
+
return text
|
|
151
|
+
finally:
|
|
152
|
+
try:
|
|
153
|
+
os.unlink(tmp)
|
|
154
|
+
except OSError:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# _split_code_behind has moved to helpers.split_code_behind — it was
|
|
159
|
+
# duplicated byte-for-byte in code.py and controls.py. Re-exported here
|
|
160
|
+
# under the old name for backwards compatibility within this module.
|
|
161
|
+
from .helpers import split_code_behind as _split_code_behind # noqa: E402,F401
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# Class module header injection (for ac_set_code object_type="class_module")
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
#
|
|
168
|
+
# Access distinguishes class vs standard modules in LoadFromText by the
|
|
169
|
+
# presence of the four Attribute VB_* lines (VB_GlobalNameSpace, VB_Creatable,
|
|
170
|
+
# VB_PredeclaredId, VB_Exposed) at the top of the text -- NOT by a
|
|
171
|
+
# "VERSION 1.0 CLASS" header. That header is the format used by
|
|
172
|
+
# VBComponent.Export / VBComponents.Import (a different mechanism). If
|
|
173
|
+
# LoadFromText receives text starting with "VERSION 1.0 CLASS", Access
|
|
174
|
+
# interprets those lines as literal VBA code and creates a *standard*
|
|
175
|
+
# module with garbage at the top. Tested against Access 2016 on
|
|
176
|
+
# production DB 2026-04-08 — Type=2 only when the 4 attributes are present.
|
|
177
|
+
|
|
178
|
+
_VB_ATTR_RE = re.compile(r"^\s*Attribute\s+VB_GlobalNameSpace\s*=", re.IGNORECASE)
|
|
179
|
+
# Also detect the legacy (wrong) VERSION header so callers can't accidentally
|
|
180
|
+
# hand us a VBComponent.Export-style file — we'd strip it below.
|
|
181
|
+
_VERSION_CLASS_RE = re.compile(r"^\s*VERSION\s+\d+\.\d+\s+CLASS\s*$", re.IGNORECASE)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _ensure_class_module_header(code: str, name: str) -> str:
|
|
185
|
+
"""Prepend the four Attribute VB_* lines if missing (class module marker).
|
|
186
|
+
|
|
187
|
+
`Application.LoadFromText(acModule=5)` decides class vs standard by the
|
|
188
|
+
presence of these four attribute lines at the top of the file:
|
|
189
|
+
Attribute VB_GlobalNameSpace = False
|
|
190
|
+
Attribute VB_Creatable = False
|
|
191
|
+
Attribute VB_PredeclaredId = False
|
|
192
|
+
Attribute VB_Exposed = False
|
|
193
|
+
|
|
194
|
+
This helper:
|
|
195
|
+
- strips a leading BOM (if any),
|
|
196
|
+
- strips any `VERSION 1.0 CLASS` / `BEGIN` / `MultiUse` / `END` /
|
|
197
|
+
`Attribute VB_Name = "..."` block that the user may have pasted from
|
|
198
|
+
a VBComponent.Export file (wrong format for LoadFromText),
|
|
199
|
+
- if `Attribute VB_GlobalNameSpace` is already present in the first
|
|
200
|
+
handful of non-blank lines, returns code unchanged (round-trip safe),
|
|
201
|
+
- otherwise prepends the 4 attribute lines,
|
|
202
|
+
- normalises the body's line endings to CRLF.
|
|
203
|
+
"""
|
|
204
|
+
if code.startswith("\ufeff"):
|
|
205
|
+
code = code.lstrip("\ufeff")
|
|
206
|
+
|
|
207
|
+
# Strip VBComponent.Export header block if present (wrong format; Access
|
|
208
|
+
# would interpret these lines as VBA code). We scan at most 8 lines.
|
|
209
|
+
lines = code.splitlines()
|
|
210
|
+
if lines and _VERSION_CLASS_RE.match(lines[0] or ""):
|
|
211
|
+
# Skip until we see either "END" (end of BEGIN block) or the first
|
|
212
|
+
# Attribute VB_Name line (which we also strip — LoadFromText takes
|
|
213
|
+
# the name as a parameter, Attribute VB_Name in the text is ignored
|
|
214
|
+
# or conflicts).
|
|
215
|
+
idx = 0
|
|
216
|
+
saw_end = False
|
|
217
|
+
for i, ln in enumerate(lines[:8]):
|
|
218
|
+
stripped = ln.strip()
|
|
219
|
+
if stripped.upper() == "END":
|
|
220
|
+
saw_end = True
|
|
221
|
+
idx = i + 1
|
|
222
|
+
break
|
|
223
|
+
if saw_end:
|
|
224
|
+
lines = lines[idx:]
|
|
225
|
+
# Also strip a leading Attribute VB_Name = "..." if present
|
|
226
|
+
while lines and re.match(r'^\s*Attribute\s+VB_Name\s*=', lines[0], re.IGNORECASE):
|
|
227
|
+
lines = lines[1:]
|
|
228
|
+
code = "\n".join(lines)
|
|
229
|
+
|
|
230
|
+
# Check if the 4 Attribute VB_* lines are already present at top
|
|
231
|
+
first_lines = [ln for ln in code.splitlines()[:10] if ln.strip()]
|
|
232
|
+
if any(_VB_ATTR_RE.match(ln) for ln in first_lines):
|
|
233
|
+
# Normalise endings, return unchanged header-wise
|
|
234
|
+
body = code.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\r\n")
|
|
235
|
+
if body and not body.endswith("\r\n"):
|
|
236
|
+
body += "\r\n"
|
|
237
|
+
return body
|
|
238
|
+
|
|
239
|
+
header = (
|
|
240
|
+
"Attribute VB_GlobalNameSpace = False\r\n"
|
|
241
|
+
"Attribute VB_Creatable = False\r\n"
|
|
242
|
+
"Attribute VB_PredeclaredId = False\r\n"
|
|
243
|
+
"Attribute VB_Exposed = False\r\n"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Normalise body to CRLF and ensure trailing newline
|
|
247
|
+
body = code.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\r\n")
|
|
248
|
+
if body and not body.endswith("\r\n"):
|
|
249
|
+
body += "\r\n"
|
|
250
|
+
|
|
251
|
+
return header + body
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
# Inject VBA after import
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
def _inject_vba_after_import(app: Any, object_type: str, name: str, vba_code: str) -> None:
|
|
259
|
+
"""
|
|
260
|
+
Injects VBA code into a form/report after importing it.
|
|
261
|
+
Activates HasModule by opening in Design view, then uses VBE to insert the code.
|
|
262
|
+
"""
|
|
263
|
+
if not vba_code.strip():
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
# 1. Open in Design view and activate HasModule
|
|
267
|
+
_open_in_design(app, object_type, name)
|
|
268
|
+
try:
|
|
269
|
+
obj = _get_design_obj(app, object_type, name)
|
|
270
|
+
obj.HasModule = True
|
|
271
|
+
finally:
|
|
272
|
+
_save_and_close(app, object_type, name)
|
|
273
|
+
|
|
274
|
+
# 2. Clear VBE cache (module was just created)
|
|
275
|
+
cache_key = f"{object_type}:{name}"
|
|
276
|
+
_Session._cm_cache.pop(cache_key, None)
|
|
277
|
+
|
|
278
|
+
# 3. Inject code via VBE (lazy import from .vbe)
|
|
279
|
+
from .vbe import _get_code_module
|
|
280
|
+
cm = _get_code_module(app, object_type, name)
|
|
281
|
+
total = cm.CountOfLines
|
|
282
|
+
|
|
283
|
+
# Delete auto-generated content by Access (Option Compare Database, etc.)
|
|
284
|
+
# to avoid duplicates with the VBA we are about to inject
|
|
285
|
+
if total > 0:
|
|
286
|
+
cm.DeleteLines(1, total)
|
|
287
|
+
|
|
288
|
+
# Normalize line endings to \r\n (VBE requires it)
|
|
289
|
+
vba_code = vba_code.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\r\n")
|
|
290
|
+
if not vba_code.endswith("\r\n"):
|
|
291
|
+
vba_code += "\r\n"
|
|
292
|
+
|
|
293
|
+
# Ensure Option Compare Database and Option Explicit at the top
|
|
294
|
+
vba_lines = vba_code.split("\r\n")
|
|
295
|
+
has_compare = any(re.match(r'^\s*Option\s+Compare', l, re.I) for l in vba_lines)
|
|
296
|
+
has_explicit = any(re.match(r'^\s*Option\s+Explicit', l, re.I) for l in vba_lines)
|
|
297
|
+
prepend = []
|
|
298
|
+
if not has_compare:
|
|
299
|
+
prepend.append("Option Compare Database")
|
|
300
|
+
if not has_explicit:
|
|
301
|
+
prepend.append("Option Explicit")
|
|
302
|
+
if prepend:
|
|
303
|
+
vba_code = "\r\n".join(prepend) + "\r\n" + vba_code
|
|
304
|
+
|
|
305
|
+
cm.InsertLines(1, vba_code)
|
|
306
|
+
|
|
307
|
+
# Invalidate caches
|
|
308
|
+
_Session._cm_cache.pop(cache_key, None)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
# Set code (import)
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
def ac_set_code(db_path: str, object_type: str, name: str, code: str) -> str:
|
|
316
|
+
"""
|
|
317
|
+
Imports text as an Access object definition (creates or overwrites).
|
|
318
|
+
For forms and reports, automatically re-injects binary sections
|
|
319
|
+
(PrtMip, PrtDevMode...) from the current export, so the caller doesn't need
|
|
320
|
+
to include them in the code they send.
|
|
321
|
+
|
|
322
|
+
If the code contains a CodeBehindForm/CodeBehindReport section, it is automatically
|
|
323
|
+
split: first the form/report is imported without VBA, then the VBA code is injected
|
|
324
|
+
via VBE (avoiding encoding issues with LoadFromText).
|
|
325
|
+
|
|
326
|
+
object_type='class_module' creates a VBA class module: the canonical
|
|
327
|
+
`VERSION 1.0 CLASS` header is prepended automatically if missing.
|
|
328
|
+
"""
|
|
329
|
+
valid_types = set(AC_TYPE) | {"class_module"}
|
|
330
|
+
if object_type not in valid_types:
|
|
331
|
+
raise ValueError(
|
|
332
|
+
f"Invalid object_type '{object_type}'. Valid: {sorted(valid_types)}"
|
|
333
|
+
)
|
|
334
|
+
# class_module re-uses acModule (5) but with a different text header
|
|
335
|
+
_ac_type_code = AC_TYPE["module"] if object_type == "class_module" else AC_TYPE[object_type]
|
|
336
|
+
app = _Session.connect(db_path)
|
|
337
|
+
|
|
338
|
+
# Split CodeBehindForm/CodeBehindReport if present
|
|
339
|
+
vba_code = ""
|
|
340
|
+
if object_type in ("form", "report"):
|
|
341
|
+
code, vba_code = _split_code_behind(code)
|
|
342
|
+
# Remove HasModule from form text — it will be activated when injecting VBA
|
|
343
|
+
if vba_code:
|
|
344
|
+
code = re.sub(r"^\s*HasModule\s*=.*$", "", code, flags=re.MULTILINE)
|
|
345
|
+
|
|
346
|
+
# If the code doesn't contain binary sections (returned by ac_get_code
|
|
347
|
+
# with filtering active), restore them from the current form/report.
|
|
348
|
+
if object_type in ("form", "report") and not any(
|
|
349
|
+
s in code for s in BINARY_SECTIONS
|
|
350
|
+
):
|
|
351
|
+
log.info("ac_set_code: restoring binary sections for '%s'", name)
|
|
352
|
+
code = restore_binary_sections(app, object_type, name, code)
|
|
353
|
+
|
|
354
|
+
# Ensure class module header is present (no-op if already there)
|
|
355
|
+
if object_type == "class_module":
|
|
356
|
+
code = _ensure_class_module_header(code, name)
|
|
357
|
+
|
|
358
|
+
# Backup existing object in case import fails
|
|
359
|
+
backup_tmp = None
|
|
360
|
+
if object_type in ("form", "report", "module", "class_module"):
|
|
361
|
+
try:
|
|
362
|
+
fd_bk, backup_tmp = tempfile.mkstemp(suffix=".txt", prefix="access_mcp_bk_")
|
|
363
|
+
os.close(fd_bk)
|
|
364
|
+
app.SaveAsText(_ac_type_code, name, backup_tmp)
|
|
365
|
+
except Exception:
|
|
366
|
+
# Doesn't exist yet — no backup needed
|
|
367
|
+
if backup_tmp:
|
|
368
|
+
try:
|
|
369
|
+
os.unlink(backup_tmp)
|
|
370
|
+
except OSError:
|
|
371
|
+
pass
|
|
372
|
+
backup_tmp = None
|
|
373
|
+
|
|
374
|
+
fd, tmp = tempfile.mkstemp(suffix=".txt", prefix="access_mcp_")
|
|
375
|
+
os.close(fd)
|
|
376
|
+
try:
|
|
377
|
+
# VBA modules (.bas and class modules) expect the system ANSI codepage
|
|
378
|
+
# (cp1252 on Western Windows, cp1253 on Greek, etc.);
|
|
379
|
+
# forms/reports/queries/macros expect UTF-16LE with BOM
|
|
380
|
+
if object_type in ("module", "class_module"):
|
|
381
|
+
import locale
|
|
382
|
+
enc = locale.getpreferredencoding(False) or "cp1252"
|
|
383
|
+
else:
|
|
384
|
+
enc = "utf-16"
|
|
385
|
+
write_tmp(tmp, code, encoding=enc)
|
|
386
|
+
try:
|
|
387
|
+
app.LoadFromText(_ac_type_code, name, tmp)
|
|
388
|
+
except Exception as import_exc:
|
|
389
|
+
# Restaurar backup si existe
|
|
390
|
+
if backup_tmp and os.path.exists(backup_tmp):
|
|
391
|
+
log.warning("ac_set_code: import failed, restoring backup for '%s'", name)
|
|
392
|
+
try:
|
|
393
|
+
app.LoadFromText(_ac_type_code, name, backup_tmp)
|
|
394
|
+
except Exception:
|
|
395
|
+
log.error("ac_set_code: could not restore backup for '%s'", name)
|
|
396
|
+
raise import_exc
|
|
397
|
+
|
|
398
|
+
# Invalidate caches for this object (code and controls changed).
|
|
399
|
+
# class_module also aliases the "module" key because access_get_code
|
|
400
|
+
# and _get_code_module read via the "module" key for all .bas modules.
|
|
401
|
+
invalidate_object_caches(object_type, name)
|
|
402
|
+
if object_type == "class_module":
|
|
403
|
+
invalidate_object_caches("module", name)
|
|
404
|
+
|
|
405
|
+
# Inject VBA if there was CodeBehindForm
|
|
406
|
+
vba_msg = ""
|
|
407
|
+
if vba_code:
|
|
408
|
+
_inject_vba_after_import(app, object_type, name, vba_code)
|
|
409
|
+
vba_msg = " (with VBA injected via VBE)"
|
|
410
|
+
|
|
411
|
+
return f"OK: '{name}' ({object_type}) imported successfully into {db_path}{vba_msg}"
|
|
412
|
+
finally:
|
|
413
|
+
try:
|
|
414
|
+
os.unlink(tmp)
|
|
415
|
+
except OSError:
|
|
416
|
+
pass
|
|
417
|
+
if backup_tmp:
|
|
418
|
+
try:
|
|
419
|
+
os.unlink(backup_tmp)
|
|
420
|
+
except OSError:
|
|
421
|
+
pass
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ---------------------------------------------------------------------------
|
|
425
|
+
# Create form
|
|
426
|
+
# ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
def ac_create_form(db_path: str, form_name: str, has_header: bool = False) -> dict:
|
|
429
|
+
"""Creates a new form avoiding the 'Save As' MsgBox that blocks COM.
|
|
430
|
+
|
|
431
|
+
CreateForm() generates a form with an auto name (Form1, Form2...).
|
|
432
|
+
DoCmd.Save saves with that name (no dialog).
|
|
433
|
+
DoCmd.Close with acSaveNo closes (already saved, no dialog).
|
|
434
|
+
DoCmd.Rename renames to the desired name.
|
|
435
|
+
"""
|
|
436
|
+
app = _Session.connect(db_path)
|
|
437
|
+
auto_name = None
|
|
438
|
+
try:
|
|
439
|
+
form = app.CreateForm()
|
|
440
|
+
auto_name = form.Name # e.g. "Form1"
|
|
441
|
+
|
|
442
|
+
if has_header:
|
|
443
|
+
app.RunCommand(36) # acCmdFormHdrFtr — toggle header/footer
|
|
444
|
+
|
|
445
|
+
# Save with auto-name — no dialog (DoCmd.Save uses current name)
|
|
446
|
+
app.DoCmd.Save(AC_FORM, auto_name)
|
|
447
|
+
# Close without prompt (already saved)
|
|
448
|
+
app.DoCmd.Close(AC_FORM, auto_name, AC_SAVE_NO)
|
|
449
|
+
|
|
450
|
+
# Rename to desired name
|
|
451
|
+
if auto_name != form_name:
|
|
452
|
+
app.DoCmd.Rename(form_name, AC_FORM, auto_name)
|
|
453
|
+
|
|
454
|
+
return {"name": form_name, "created_from": auto_name, "has_header": has_header}
|
|
455
|
+
except Exception as exc:
|
|
456
|
+
if auto_name:
|
|
457
|
+
try:
|
|
458
|
+
app.DoCmd.Close(AC_FORM, auto_name, AC_SAVE_NO)
|
|
459
|
+
except Exception:
|
|
460
|
+
pass
|
|
461
|
+
try:
|
|
462
|
+
app.DoCmd.DeleteObject(AC_FORM, auto_name)
|
|
463
|
+
except Exception:
|
|
464
|
+
pass
|
|
465
|
+
raise RuntimeError(f"Error creating form '{form_name}': {exc}")
|
|
466
|
+
finally:
|
|
467
|
+
invalidate_all_caches()
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# ---------------------------------------------------------------------------
|
|
471
|
+
# Export structure
|
|
472
|
+
# ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
def ac_export_structure(db_path: str, output_path: str | None = None) -> str:
|
|
475
|
+
"""
|
|
476
|
+
Generates a Markdown file with the complete database structure:
|
|
477
|
+
VBA modules with their function signatures, forms, reports and queries.
|
|
478
|
+
"""
|
|
479
|
+
from datetime import datetime
|
|
480
|
+
|
|
481
|
+
if output_path is None:
|
|
482
|
+
output_path = str(Path(db_path).parent / "db_structure.md")
|
|
483
|
+
|
|
484
|
+
objects = ac_list_objects(db_path, "all")
|
|
485
|
+
modules = objects.get("module", [])
|
|
486
|
+
forms = objects.get("form", [])
|
|
487
|
+
reports = objects.get("report", [])
|
|
488
|
+
queries = objects.get("query", [])
|
|
489
|
+
macros = objects.get("macro", [])
|
|
490
|
+
|
|
491
|
+
lines: list[str] = []
|
|
492
|
+
lines.append(f"# Structure of `{Path(db_path).name}`")
|
|
493
|
+
lines.append(f"\n**Path**: `{db_path}` ")
|
|
494
|
+
lines.append(f"**Generated**: {datetime.now().strftime('%Y-%m-%d %H:%M')} ")
|
|
495
|
+
lines.append(
|
|
496
|
+
f"**Summary**: {len(modules)} modules · {len(forms)} forms · "
|
|
497
|
+
f"{len(reports)} reports · {len(queries)} queries · {len(macros)} macros\n"
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
# -- VBA Modules with signatures --
|
|
501
|
+
# Read modules via VBE (no SaveAsText/disk) and warming up the code cache
|
|
502
|
+
# Lazy imports from .vbe
|
|
503
|
+
from .vbe import _get_code_module, _cm_all_code
|
|
504
|
+
|
|
505
|
+
app = _Session.connect(db_path)
|
|
506
|
+
lines.append(f"## VBA Modules ({len(modules)})\n")
|
|
507
|
+
for mod_name in modules:
|
|
508
|
+
lines.append(f"### `{mod_name}`")
|
|
509
|
+
try:
|
|
510
|
+
cm = _get_code_module(app, "module", mod_name)
|
|
511
|
+
cache_key = f"module:{mod_name}"
|
|
512
|
+
code = _cm_all_code(cm, cache_key)
|
|
513
|
+
sigs = []
|
|
514
|
+
for line in code.splitlines():
|
|
515
|
+
stripped = line.strip()
|
|
516
|
+
if re.match(
|
|
517
|
+
r"^(Public\s+|Private\s+|Friend\s+)?(Function|Sub)\s+\w+",
|
|
518
|
+
stripped,
|
|
519
|
+
re.IGNORECASE,
|
|
520
|
+
):
|
|
521
|
+
sigs.append(f" - `{stripped}`")
|
|
522
|
+
if sigs:
|
|
523
|
+
lines.extend(sigs)
|
|
524
|
+
else:
|
|
525
|
+
lines.append(" *(no public functions/subs)*")
|
|
526
|
+
except Exception as exc:
|
|
527
|
+
lines.append(f" *(error reading: {exc})*")
|
|
528
|
+
lines.append("")
|
|
529
|
+
|
|
530
|
+
# -- Forms --
|
|
531
|
+
lines.append(f"## Forms ({len(forms)})\n")
|
|
532
|
+
if forms:
|
|
533
|
+
for name in forms:
|
|
534
|
+
lines.append(f"- `{name}`")
|
|
535
|
+
else:
|
|
536
|
+
lines.append("*(none)*")
|
|
537
|
+
lines.append("")
|
|
538
|
+
|
|
539
|
+
# -- Reports --
|
|
540
|
+
lines.append(f"## Reports ({len(reports)})\n")
|
|
541
|
+
if reports:
|
|
542
|
+
for name in reports:
|
|
543
|
+
lines.append(f"- `{name}`")
|
|
544
|
+
else:
|
|
545
|
+
lines.append("*(none)*")
|
|
546
|
+
lines.append("")
|
|
547
|
+
|
|
548
|
+
# -- Queries --
|
|
549
|
+
lines.append(f"## Queries ({len(queries)})\n")
|
|
550
|
+
if queries:
|
|
551
|
+
for name in queries:
|
|
552
|
+
lines.append(f"- `{name}`")
|
|
553
|
+
else:
|
|
554
|
+
lines.append("*(none)*")
|
|
555
|
+
lines.append("")
|
|
556
|
+
|
|
557
|
+
# -- Macros --
|
|
558
|
+
if macros:
|
|
559
|
+
lines.append(f"## Macros ({len(macros)})\n")
|
|
560
|
+
for name in macros:
|
|
561
|
+
lines.append(f"- `{name}`")
|
|
562
|
+
lines.append("")
|
|
563
|
+
|
|
564
|
+
content = "\n".join(lines)
|
|
565
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
566
|
+
f.write(content)
|
|
567
|
+
|
|
568
|
+
return f"[Saved to `{output_path}`]\n\n{content}"
|