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,223 @@
|
|
|
1
|
+
"""``pyaccesskit inspect``: describe a database without changing it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.markup import escape
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from pyaccesskit.cli._output import EXIT_OK, handle_errors, print_json, redact, stdout
|
|
13
|
+
from pyaccesskit.database import AccessDatabase
|
|
14
|
+
from pyaccesskit.enums import DataType, Engine, ObjectKind
|
|
15
|
+
from pyaccesskit.errors import PyAccessKitError
|
|
16
|
+
from pyaccesskit.schema import (
|
|
17
|
+
AutoNumberColumn,
|
|
18
|
+
ColumnBase,
|
|
19
|
+
DecimalColumn,
|
|
20
|
+
Expr,
|
|
21
|
+
LongTextColumn,
|
|
22
|
+
NumberColumn,
|
|
23
|
+
TableSpec,
|
|
24
|
+
TextColumn,
|
|
25
|
+
render_literal,
|
|
26
|
+
)
|
|
27
|
+
from pyaccesskit.schema.columns import UnsupportedColumn
|
|
28
|
+
|
|
29
|
+
OTHER_OBJECTS = (ObjectKind.FORM, ObjectKind.REPORT, ObjectKind.MACRO, ObjectKind.MODULE)
|
|
30
|
+
_TYPE_NAMES = {
|
|
31
|
+
DataType.TEXT: "Short Text",
|
|
32
|
+
DataType.LONG_TEXT: "Long Text",
|
|
33
|
+
DataType.NUMBER: "Number",
|
|
34
|
+
DataType.DECIMAL: "Decimal",
|
|
35
|
+
DataType.CURRENCY: "Currency",
|
|
36
|
+
DataType.AUTONUMBER: "AutoNumber",
|
|
37
|
+
DataType.DATE_TIME: "Date/Time",
|
|
38
|
+
DataType.YES_NO: "Yes/No",
|
|
39
|
+
DataType.HYPERLINK: "Hyperlink",
|
|
40
|
+
DataType.OLE_OBJECT: "OLE Object",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def inspect_database(
|
|
45
|
+
database: Annotated[Path, typer.Argument(help="The .accdb or .mdb file.", show_default=False)],
|
|
46
|
+
json_output: Annotated[bool, typer.Option("--json", help="Print the report as JSON.")] = False,
|
|
47
|
+
counts: Annotated[
|
|
48
|
+
bool, typer.Option("--counts", help="Also count the rows of every table.")
|
|
49
|
+
] = False,
|
|
50
|
+
password: Annotated[
|
|
51
|
+
str | None,
|
|
52
|
+
typer.Option(
|
|
53
|
+
"--password",
|
|
54
|
+
envvar="PYACCESSKIT_PASSWORD",
|
|
55
|
+
help="Database password (prefer the PYACCESSKIT_PASSWORD environment variable).",
|
|
56
|
+
show_default=False,
|
|
57
|
+
),
|
|
58
|
+
] = None,
|
|
59
|
+
engine: Annotated[Engine, typer.Option(help="Engine to use.")] = Engine.AUTO,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Describe tables, relationships, queries and other objects (read-only; no startup code runs)."""
|
|
62
|
+
with handle_errors():
|
|
63
|
+
db = AccessDatabase.open(database, readonly=True, password=password, engine=engine)
|
|
64
|
+
with db:
|
|
65
|
+
report = describe(db, counts=counts)
|
|
66
|
+
if json_output:
|
|
67
|
+
print_json(report)
|
|
68
|
+
else:
|
|
69
|
+
render(report)
|
|
70
|
+
raise typer.Exit(EXIT_OK)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def describe(db: AccessDatabase, *, counts: bool = False) -> dict[str, Any]:
|
|
74
|
+
"""Everything ``inspect`` reports, as JSON-ready data. Problems with single objects become warnings.
|
|
75
|
+
|
|
76
|
+
Passwords in linked-table and pass-through connection strings are redacted.
|
|
77
|
+
"""
|
|
78
|
+
warnings: list[str] = []
|
|
79
|
+
tables: list[dict[str, Any]] = []
|
|
80
|
+
for table in db.tables:
|
|
81
|
+
entry: dict[str, Any] = {"name": table.name, "linked": None, "rows": None, "spec": None}
|
|
82
|
+
try:
|
|
83
|
+
if table.is_linked:
|
|
84
|
+
entry["linked"] = {
|
|
85
|
+
"connect": redact(table.connect),
|
|
86
|
+
"source_table": table.source_table,
|
|
87
|
+
}
|
|
88
|
+
entry["spec"] = table.to_spec().model_dump(mode="json")
|
|
89
|
+
if counts:
|
|
90
|
+
entry["rows"] = table.record_count()
|
|
91
|
+
except PyAccessKitError as exc:
|
|
92
|
+
warnings.append(f"table {table.name!r}: {exc}")
|
|
93
|
+
tables.append(entry)
|
|
94
|
+
|
|
95
|
+
queries: list[dict[str, Any]] = []
|
|
96
|
+
for query in db.queries:
|
|
97
|
+
try:
|
|
98
|
+
spec = query.to_spec().model_dump(mode="json")
|
|
99
|
+
passthrough = spec.get("pass_through")
|
|
100
|
+
if isinstance(passthrough, dict):
|
|
101
|
+
passthrough["connect"] = redact(str(passthrough.get("connect"))) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
|
|
102
|
+
queries.append({"kind": query.kind.value, **spec})
|
|
103
|
+
except PyAccessKitError as exc:
|
|
104
|
+
warnings.append(f"query {query.name!r}: {exc}")
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
"path": str(db.path),
|
|
108
|
+
"format_version": db.format_version,
|
|
109
|
+
"transport": db.transport.value,
|
|
110
|
+
"tables": tables,
|
|
111
|
+
"relationships": [spec.model_dump(mode="json") for spec in db.relationships.specs()],
|
|
112
|
+
"queries": queries,
|
|
113
|
+
**{f"{kind.value}s": db.objects.names(kind) for kind in OTHER_OBJECTS},
|
|
114
|
+
"warnings": warnings,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------------------------ rendering
|
|
119
|
+
def column_type(column: ColumnBase) -> str:
|
|
120
|
+
"""How the Access table designer would describe a column's type."""
|
|
121
|
+
if isinstance(column, UnsupportedColumn):
|
|
122
|
+
return f"{column.detail} (unsupported)"
|
|
123
|
+
name = _TYPE_NAMES.get(column.data_type, column.data_type.value)
|
|
124
|
+
if isinstance(column, TextColumn):
|
|
125
|
+
return f"{name}({column.length})"
|
|
126
|
+
if isinstance(column, NumberColumn):
|
|
127
|
+
return f"{name} ({column.size.value.replace('_', ' ')})"
|
|
128
|
+
if isinstance(column, DecimalColumn):
|
|
129
|
+
return f"{name}({column.precision},{column.scale})"
|
|
130
|
+
if isinstance(column, AutoNumberColumn) and column.replication_id:
|
|
131
|
+
return f"{name} (replication ID)"
|
|
132
|
+
if isinstance(column, LongTextColumn):
|
|
133
|
+
flags = [
|
|
134
|
+
flag
|
|
135
|
+
for flag, on in (("rich text", column.rich_text), ("append only", column.append_only))
|
|
136
|
+
if on
|
|
137
|
+
]
|
|
138
|
+
return f"{name} ({', '.join(flags)})" if flags else name
|
|
139
|
+
return name
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _default_text(column: ColumnBase) -> str:
|
|
143
|
+
default = column.default
|
|
144
|
+
if default is None:
|
|
145
|
+
return ""
|
|
146
|
+
return default.expr if isinstance(default, Expr) else render_literal(default)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _render_table(entry: dict[str, Any]) -> None:
|
|
150
|
+
console = stdout()
|
|
151
|
+
details: list[str] = []
|
|
152
|
+
if entry["linked"]:
|
|
153
|
+
link = entry["linked"]
|
|
154
|
+
details.append(f"linked to {link['source_table']} ({link['connect']})")
|
|
155
|
+
if entry["rows"] is not None:
|
|
156
|
+
details.append(f"{entry['rows']} row{'' if entry['rows'] == 1 else 's'}")
|
|
157
|
+
suffix = f" [dim]{escape('; '.join(details))}[/]" if details else ""
|
|
158
|
+
console.print(f"\n[bold cyan]Table {escape(entry['name'])}[/]{suffix}")
|
|
159
|
+
if entry["spec"] is None:
|
|
160
|
+
console.print(" [yellow]could not be read (see warnings)[/]")
|
|
161
|
+
return
|
|
162
|
+
spec = TableSpec.model_validate(entry["spec"])
|
|
163
|
+
grid = Table(box=None, padding=(0, 2), show_edge=False, header_style="dim")
|
|
164
|
+
for heading in ("Field", "Type", "Required", "Default", "Description"):
|
|
165
|
+
grid.add_column(heading)
|
|
166
|
+
for column in spec.columns:
|
|
167
|
+
grid.add_row(
|
|
168
|
+
escape(column.name),
|
|
169
|
+
escape(column_type(column)),
|
|
170
|
+
"yes" if column.required else "",
|
|
171
|
+
escape(_default_text(column)),
|
|
172
|
+
escape(column.description or ""),
|
|
173
|
+
)
|
|
174
|
+
console.print(grid)
|
|
175
|
+
for index in spec.indexes:
|
|
176
|
+
kind = "primary key" if index.primary else ("unique" if index.unique else "")
|
|
177
|
+
columns = ", ".join(f.name + (" DESC" if f.descending else "") for f in index.fields)
|
|
178
|
+
console.print(
|
|
179
|
+
f" [dim]index[/] {escape(index.name)} ({escape(columns)})"
|
|
180
|
+
+ (f" [dim]{kind}[/]" if kind else "")
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def render(report: dict[str, Any]) -> None:
|
|
185
|
+
"""Print a human-readable report."""
|
|
186
|
+
console = stdout()
|
|
187
|
+
console.print(
|
|
188
|
+
f"[bold]{escape(report['path'])}[/] "
|
|
189
|
+
f"[dim](format {report['format_version']}, via {report['transport']})[/]"
|
|
190
|
+
)
|
|
191
|
+
for entry in report["tables"]:
|
|
192
|
+
_render_table(entry)
|
|
193
|
+
if report["relationships"]:
|
|
194
|
+
console.print("\n[bold cyan]Relationships[/]")
|
|
195
|
+
for rel in report["relationships"]:
|
|
196
|
+
rules = [
|
|
197
|
+
rule
|
|
198
|
+
for rule, on in (
|
|
199
|
+
("enforced", rel["enforce_integrity"]),
|
|
200
|
+
("cascade update", rel["cascade_update"]),
|
|
201
|
+
("cascade delete", rel["cascade_delete"]),
|
|
202
|
+
("one-to-one", rel["one_to_one"]),
|
|
203
|
+
)
|
|
204
|
+
if on
|
|
205
|
+
]
|
|
206
|
+
primary = f"{rel['primary_table']}({', '.join(rel['primary_columns'])})"
|
|
207
|
+
foreign = f"{rel['foreign_table']}({', '.join(rel['foreign_columns'])})"
|
|
208
|
+
console.print(
|
|
209
|
+
f" {escape(rel['name'] or '')}: {escape(primary)} -> {escape(foreign)}"
|
|
210
|
+
+ (f" [dim]{', '.join(rules)}[/]" if rules else "")
|
|
211
|
+
)
|
|
212
|
+
if report["queries"]:
|
|
213
|
+
console.print("\n[bold cyan]Queries[/]")
|
|
214
|
+
for query in report["queries"]:
|
|
215
|
+
lines = query["sql"].strip().splitlines()
|
|
216
|
+
first = lines[0][:100] if lines else ""
|
|
217
|
+
console.print(f" {escape(query['name'])} [dim]({query['kind']})[/] {escape(first)}")
|
|
218
|
+
for kind in OTHER_OBJECTS:
|
|
219
|
+
names = report[f"{kind.value}s"]
|
|
220
|
+
if names:
|
|
221
|
+
console.print(f"\n[bold cyan]{kind.value.title()}s[/] {escape(', '.join(names))}")
|
|
222
|
+
for warning in report["warnings"]:
|
|
223
|
+
console.print(f"\n[yellow]warning:[/] {escape(warning)}")
|
pyaccesskit/database.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""The :class:`AccessDatabase` facade — the entry point of PyAccessKit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
import uuid
|
|
8
|
+
import warnings
|
|
9
|
+
import weakref
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
from typing import Any, Self
|
|
14
|
+
|
|
15
|
+
from pyaccesskit._backends.protocols import PropertyTarget
|
|
16
|
+
from pyaccesskit._session.session import Session
|
|
17
|
+
from pyaccesskit.enums import Engine, Transport
|
|
18
|
+
from pyaccesskit.errors import (
|
|
19
|
+
DatabaseExistsError,
|
|
20
|
+
DatabaseLockedError,
|
|
21
|
+
DatabaseNotFoundError,
|
|
22
|
+
SpecError,
|
|
23
|
+
)
|
|
24
|
+
from pyaccesskit.forms.collection import FormCollection
|
|
25
|
+
from pyaccesskit.modules import ModuleCollection
|
|
26
|
+
from pyaccesskit.objects import AccessObjects
|
|
27
|
+
from pyaccesskit.options import SessionOptions
|
|
28
|
+
from pyaccesskit.properties import PropertyBag
|
|
29
|
+
from pyaccesskit.queries import QueryCollection
|
|
30
|
+
from pyaccesskit.relationships import RelationshipCollection
|
|
31
|
+
from pyaccesskit.tables import TableCollection
|
|
32
|
+
|
|
33
|
+
__all__ = ["AccessDatabase", "RawAccess"]
|
|
34
|
+
|
|
35
|
+
MAX_PATH_LENGTH = 255
|
|
36
|
+
_EXTENSIONS = (".accdb", ".mdb", ".accde", ".mde")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _resolve(path: str | os.PathLike[str]) -> Path:
|
|
40
|
+
resolved = Path(path).expanduser().resolve()
|
|
41
|
+
if resolved.suffix.lower() not in _EXTENSIONS:
|
|
42
|
+
raise SpecError(
|
|
43
|
+
f"{resolved.name!r} is not an Access database file name (expected .accdb or .mdb)"
|
|
44
|
+
)
|
|
45
|
+
if len(str(resolved)) > MAX_PATH_LENGTH:
|
|
46
|
+
raise SpecError(
|
|
47
|
+
f"database paths are limited to {MAX_PATH_LENGTH} characters by DAO: {resolved}"
|
|
48
|
+
)
|
|
49
|
+
return resolved
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _abandon(session: Session) -> None:
|
|
53
|
+
"""Finalizer for sessions that were never closed (garbage collection or interpreter exit)."""
|
|
54
|
+
if session.state != "open":
|
|
55
|
+
return
|
|
56
|
+
warnings.warn(
|
|
57
|
+
f"PyAccessKit session for {session.target} was not closed; use 'with AccessDatabase...' or call "
|
|
58
|
+
"close(). Newly created databases are discarded.",
|
|
59
|
+
ResourceWarning,
|
|
60
|
+
stacklevel=2,
|
|
61
|
+
)
|
|
62
|
+
if threading.get_ident() == session.thread_id:
|
|
63
|
+
try:
|
|
64
|
+
session.close(error=RuntimeError("session abandoned"))
|
|
65
|
+
except BaseException:
|
|
66
|
+
session.terminate()
|
|
67
|
+
else:
|
|
68
|
+
session.terminate()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RawAccess:
|
|
72
|
+
"""Escape hatch to the underlying COM objects.
|
|
73
|
+
|
|
74
|
+
Every object returned is a revocable proxy: it stops working (and releases its COM reference) when the
|
|
75
|
+
session closes or switches engines. Save your own design changes; PyAccessKit quits Access without
|
|
76
|
+
saving objects it did not manage.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, session: Session) -> None:
|
|
80
|
+
self._session = session
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def dao(self) -> Any:
|
|
84
|
+
"""The DAO ``Database`` object."""
|
|
85
|
+
return self._session.raw("dao")
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def dbengine(self) -> Any:
|
|
89
|
+
"""The DAO ``DBEngine`` object."""
|
|
90
|
+
return self._session.raw("dbengine")
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def access(self) -> Any:
|
|
94
|
+
"""The ``Access.Application`` object (switches the session to a design session if needed)."""
|
|
95
|
+
return self._session.raw("access")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class AccessDatabase:
|
|
99
|
+
"""An open Microsoft Access database.
|
|
100
|
+
|
|
101
|
+
Create or open one with :meth:`create` / :meth:`open`, ideally as a context manager so that everything
|
|
102
|
+
(including the Access process) is cleaned up even if an exception occurs::
|
|
103
|
+
|
|
104
|
+
with AccessDatabase.create("crm.accdb") as db:
|
|
105
|
+
db.tables.create("Customers", columns=[...])
|
|
106
|
+
|
|
107
|
+
Attributes:
|
|
108
|
+
tables: The tables (:class:`~pyaccesskit.tables.TableCollection`).
|
|
109
|
+
relationships: The relationships.
|
|
110
|
+
queries: The saved queries.
|
|
111
|
+
forms: The forms (building forms needs Microsoft Access).
|
|
112
|
+
modules: The VBA modules (needs Microsoft Access).
|
|
113
|
+
objects: Text import/export and management of forms, reports, macros and modules.
|
|
114
|
+
properties: The database's DAO properties (``AppTitle``, ``StartUpForm``...).
|
|
115
|
+
raw: Escape hatch to the underlying COM objects.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(self, session: Session) -> None:
|
|
119
|
+
self._session = session
|
|
120
|
+
self.tables = TableCollection(session)
|
|
121
|
+
self.relationships = RelationshipCollection(session)
|
|
122
|
+
self.queries = QueryCollection(session)
|
|
123
|
+
self.forms = FormCollection(session)
|
|
124
|
+
self.modules = ModuleCollection(session)
|
|
125
|
+
self.objects = AccessObjects(session)
|
|
126
|
+
self.properties = PropertyBag(session, PropertyTarget.database())
|
|
127
|
+
self.raw = RawAccess(session)
|
|
128
|
+
self._finalizer = weakref.finalize(self, _abandon, session)
|
|
129
|
+
|
|
130
|
+
# ----------------------------------------------------------------------------- construction
|
|
131
|
+
@classmethod
|
|
132
|
+
def create(
|
|
133
|
+
cls,
|
|
134
|
+
path: str | os.PathLike[str],
|
|
135
|
+
*,
|
|
136
|
+
overwrite: bool = False,
|
|
137
|
+
atomic: bool = True,
|
|
138
|
+
engine: Engine | str = Engine.AUTO,
|
|
139
|
+
options: SessionOptions | None = None,
|
|
140
|
+
) -> AccessDatabase:
|
|
141
|
+
"""Create a new ``.accdb`` database.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
path: Where to create the database.
|
|
145
|
+
overwrite: Replace an existing file at ``path``.
|
|
146
|
+
atomic: Build in a temporary sibling file and move it into place only when the session closes
|
|
147
|
+
without an error; on error the target is left untouched.
|
|
148
|
+
engine: ``"auto"`` (default), ``"dao"`` or ``"access"`` — see :class:`~pyaccesskit.enums.Engine`.
|
|
149
|
+
options: Advanced session options.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
DatabaseExistsError: If ``path`` exists and ``overwrite`` is false.
|
|
153
|
+
"""
|
|
154
|
+
target = _resolve(path)
|
|
155
|
+
if target.suffix.lower() != ".accdb":
|
|
156
|
+
raise SpecError("PyAccessKit creates .accdb databases (Access 2007 and later format)")
|
|
157
|
+
if not target.parent.is_dir():
|
|
158
|
+
raise DatabaseNotFoundError(f"folder {target.parent} does not exist", path=target)
|
|
159
|
+
if target.exists() and not overwrite:
|
|
160
|
+
raise DatabaseExistsError(
|
|
161
|
+
f"{target} already exists (pass overwrite=True to replace it)", path=target
|
|
162
|
+
)
|
|
163
|
+
if atomic:
|
|
164
|
+
working = target.with_name(f".{target.stem}.pak-{uuid.uuid4().hex[:8]}{target.suffix}")
|
|
165
|
+
else:
|
|
166
|
+
working = target
|
|
167
|
+
if target.exists():
|
|
168
|
+
try:
|
|
169
|
+
target.unlink()
|
|
170
|
+
except PermissionError as exc:
|
|
171
|
+
raise DatabaseLockedError(
|
|
172
|
+
f"cannot overwrite {target}: it is in use", path=target
|
|
173
|
+
) from exc
|
|
174
|
+
session = Session(
|
|
175
|
+
target=target,
|
|
176
|
+
working=working,
|
|
177
|
+
create=True,
|
|
178
|
+
readonly=False,
|
|
179
|
+
exclusive=True,
|
|
180
|
+
password=None,
|
|
181
|
+
engine=Engine(engine),
|
|
182
|
+
options=options or SessionOptions(),
|
|
183
|
+
overwrite=overwrite,
|
|
184
|
+
)
|
|
185
|
+
return cls(session)
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def open(
|
|
189
|
+
cls,
|
|
190
|
+
path: str | os.PathLike[str],
|
|
191
|
+
*,
|
|
192
|
+
readonly: bool = False,
|
|
193
|
+
exclusive: bool | None = None,
|
|
194
|
+
password: str | None = None,
|
|
195
|
+
engine: Engine | str = Engine.AUTO,
|
|
196
|
+
options: SessionOptions | None = None,
|
|
197
|
+
) -> AccessDatabase:
|
|
198
|
+
"""Open an existing database.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
path: The database file.
|
|
202
|
+
readonly: Open read-only (also allows opening a database that is open in Access elsewhere).
|
|
203
|
+
exclusive: Open exclusively (default: ``True`` unless ``readonly``). Design changes need it.
|
|
204
|
+
password: Database password, if any.
|
|
205
|
+
engine: ``"auto"`` (default), ``"dao"`` or ``"access"``.
|
|
206
|
+
options: Advanced session options.
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
DatabaseNotFoundError: If the file does not exist.
|
|
210
|
+
DatabaseLockedError: If it is in use elsewhere.
|
|
211
|
+
"""
|
|
212
|
+
target = _resolve(path)
|
|
213
|
+
if not target.is_file():
|
|
214
|
+
raise DatabaseNotFoundError(f"{target} does not exist", path=target)
|
|
215
|
+
session = Session(
|
|
216
|
+
target=target,
|
|
217
|
+
working=target,
|
|
218
|
+
create=False,
|
|
219
|
+
readonly=readonly,
|
|
220
|
+
exclusive=(not readonly) if exclusive is None else exclusive,
|
|
221
|
+
password=password,
|
|
222
|
+
engine=Engine(engine),
|
|
223
|
+
options=options or SessionOptions(),
|
|
224
|
+
)
|
|
225
|
+
return cls(session)
|
|
226
|
+
|
|
227
|
+
# ----------------------------------------------------------------------------------- facts
|
|
228
|
+
@property
|
|
229
|
+
def path(self) -> Path:
|
|
230
|
+
"""The database file (for atomic creation: where it will be when the session closes)."""
|
|
231
|
+
return self._session.target
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def transport(self) -> Transport:
|
|
235
|
+
"""How the database is currently reached (in-process DAO, Access-hosted DAO, design session)."""
|
|
236
|
+
return self._session.transport
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def format_version(self) -> str:
|
|
240
|
+
"""The database engine format (DAO ``Database.Version``): ``"12.0"`` for ``.accdb``, ``"4.0"`` for Jet 4."""
|
|
241
|
+
return self._session.schema().database_info().version
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def readonly(self) -> bool:
|
|
245
|
+
"""Whether the database was opened read-only."""
|
|
246
|
+
return self._session.readonly
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def is_open(self) -> bool:
|
|
250
|
+
"""Whether the session is still open."""
|
|
251
|
+
return self._session.state == "open"
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def access_pid(self) -> int | None:
|
|
255
|
+
"""PID of the owned ``MSACCESS.EXE`` (``None`` when no Access process is used)."""
|
|
256
|
+
return getattr(self._session.engine_handle, "pid", None)
|
|
257
|
+
|
|
258
|
+
# ------------------------------------------------------------------------------------- data
|
|
259
|
+
def execute(self, sql: str, params: Mapping[str, Any] | None = None) -> int:
|
|
260
|
+
"""Run an action statement (INSERT/UPDATE/DELETE/DDL); returns the number of affected rows.
|
|
261
|
+
|
|
262
|
+
``params`` binds parameters by name (``[Name]`` references in the SQL); values are never
|
|
263
|
+
interpolated into the SQL text.
|
|
264
|
+
"""
|
|
265
|
+
self._session.check_writable("run SQL")
|
|
266
|
+
return self._session.schema().execute(sql, params)
|
|
267
|
+
|
|
268
|
+
def fetch_all(
|
|
269
|
+
self, sql: str, params: Mapping[str, Any] | None = None, *, limit: int | None = None
|
|
270
|
+
) -> list[dict[str, Any]]:
|
|
271
|
+
"""Run a SELECT and return its rows as dictionaries."""
|
|
272
|
+
return self._session.schema().fetch(sql, params, limit=limit).as_dicts()
|
|
273
|
+
|
|
274
|
+
# ------------------------------------------------------------------------------------ close
|
|
275
|
+
def close(self) -> None:
|
|
276
|
+
"""Close the session and release everything (idempotent; only from the thread that opened it)."""
|
|
277
|
+
self._session.check_thread()
|
|
278
|
+
self._finalizer.detach()
|
|
279
|
+
self._session.close()
|
|
280
|
+
|
|
281
|
+
def __enter__(self) -> Self:
|
|
282
|
+
return self
|
|
283
|
+
|
|
284
|
+
def __exit__(
|
|
285
|
+
self,
|
|
286
|
+
exc_type: type[BaseException] | None,
|
|
287
|
+
exc: BaseException | None,
|
|
288
|
+
tb: TracebackType | None,
|
|
289
|
+
) -> None:
|
|
290
|
+
self._session.check_thread()
|
|
291
|
+
self._finalizer.detach()
|
|
292
|
+
self._session.close(error=exc)
|
|
293
|
+
|
|
294
|
+
def __repr__(self) -> str:
|
|
295
|
+
state = self.transport.value if self.is_open else "closed"
|
|
296
|
+
return f"<AccessDatabase {str(self.path)!r} ({state})>"
|