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
pyaccesskit/_ledger.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""The ownership ledger: a record of every Access process PyAccessKit started.
|
|
2
|
+
|
|
3
|
+
Each live Access process owned by PyAccessKit has a small JSON file in
|
|
4
|
+
``%LOCALAPPDATA%/PyAccessKit/owned``. It is deleted when the process is shut down cleanly. If Python died
|
|
5
|
+
before that (and the kill-on-close job object could not be used), :func:`reap_orphans` finds the leftovers.
|
|
6
|
+
|
|
7
|
+
A process is only ever terminated when **all** of these hold:
|
|
8
|
+
|
|
9
|
+
* its owner (the Python process that started it) is gone — PID *and* creation time checked;
|
|
10
|
+
* a process with the recorded PID *and* creation time still exists (so the PID was not recycled);
|
|
11
|
+
* its image is ``MSACCESS.EXE`` (checked again by the inspector right before terminating).
|
|
12
|
+
|
|
13
|
+
This module is pure Python; the Windows-specific inspector lives in ``_win.inspector``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
from dataclasses import asdict, dataclass
|
|
24
|
+
from pathlib import Path, PureWindowsPath
|
|
25
|
+
from typing import Literal, Protocol
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"OwnedProcess",
|
|
29
|
+
"ProcessInspector",
|
|
30
|
+
"ReapResult",
|
|
31
|
+
"ledger_dir",
|
|
32
|
+
"load_all",
|
|
33
|
+
"reap_orphans",
|
|
34
|
+
"record",
|
|
35
|
+
"remove",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger("pyaccesskit.process")
|
|
39
|
+
|
|
40
|
+
LEDGER_ENV = "PYACCESSKIT_LEDGER_DIR"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class OwnedProcess:
|
|
45
|
+
"""One ledger entry."""
|
|
46
|
+
|
|
47
|
+
pid: int
|
|
48
|
+
creation_time: float
|
|
49
|
+
image: str
|
|
50
|
+
owner_pid: int
|
|
51
|
+
owner_creation_time: float
|
|
52
|
+
database: str | None
|
|
53
|
+
started_at: float
|
|
54
|
+
version: str
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def file_name(self) -> str:
|
|
58
|
+
"""Ledger file name (unique per process identity)."""
|
|
59
|
+
return f"{self.pid}-{int(self.creation_time * 1000)}.json"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ProcessInspector(Protocol):
|
|
63
|
+
"""Answers questions about processes; implemented with Win32 calls in ``_win.inspector``."""
|
|
64
|
+
|
|
65
|
+
def matches(self, pid: int, creation_time: float) -> bool:
|
|
66
|
+
"""Whether a running process has exactly this PID and creation time."""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
def terminate(self, pid: int, creation_time: float, image_name: str) -> bool:
|
|
70
|
+
"""Terminate the process if (re-verified) it has this identity and image; return success."""
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class ReapResult:
|
|
76
|
+
"""What :func:`reap_orphans` did with one ledger entry."""
|
|
77
|
+
|
|
78
|
+
entry: OwnedProcess
|
|
79
|
+
action: Literal["terminated", "would-terminate", "already-exited", "owner-alive", "failed"]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def ledger_dir() -> Path:
|
|
83
|
+
"""The ledger directory (``PYACCESSKIT_LEDGER_DIR`` overrides it, e.g. in tests)."""
|
|
84
|
+
override = os.environ.get(LEDGER_ENV)
|
|
85
|
+
if override:
|
|
86
|
+
return Path(override)
|
|
87
|
+
base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
|
|
88
|
+
return Path(base) / "PyAccessKit" / "owned"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def record(entry: OwnedProcess, directory: Path | None = None) -> Path:
|
|
92
|
+
"""Write a ledger entry atomically; returns its path."""
|
|
93
|
+
folder = directory or ledger_dir()
|
|
94
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
path = folder / entry.file_name
|
|
96
|
+
temporary = path.with_suffix(".tmp")
|
|
97
|
+
temporary.write_text(json.dumps(asdict(entry), indent=2), encoding="utf-8")
|
|
98
|
+
temporary.replace(path)
|
|
99
|
+
return path
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def remove(path: Path) -> None:
|
|
103
|
+
"""Delete a ledger entry (missing files are fine)."""
|
|
104
|
+
try:
|
|
105
|
+
path.unlink(missing_ok=True)
|
|
106
|
+
except (
|
|
107
|
+
OSError
|
|
108
|
+
) as exc: # pragma: no cover - e.g. a locked file; the entry is simply reaped later
|
|
109
|
+
logger.debug("could not delete ledger entry %s: %s", path, exc)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_all(directory: Path | None = None) -> Iterator[tuple[Path, OwnedProcess]]:
|
|
113
|
+
"""Yield every readable ledger entry (unreadable files are skipped and logged)."""
|
|
114
|
+
folder = directory or ledger_dir()
|
|
115
|
+
if not folder.is_dir():
|
|
116
|
+
return
|
|
117
|
+
for path in sorted(folder.glob("*.json")):
|
|
118
|
+
try:
|
|
119
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
120
|
+
yield path, OwnedProcess(**data)
|
|
121
|
+
except (OSError, ValueError, TypeError) as exc:
|
|
122
|
+
logger.warning("ignoring unreadable ledger entry %s: %s", path, exc)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def reap_orphans(
|
|
126
|
+
inspector: ProcessInspector, *, dry_run: bool = False, directory: Path | None = None
|
|
127
|
+
) -> list[ReapResult]:
|
|
128
|
+
"""Terminate Access processes whose PyAccessKit owner died without shutting them down.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
inspector: Process inspector (Win32 implementation by default in the public API).
|
|
132
|
+
dry_run: Report what would be terminated without terminating anything.
|
|
133
|
+
directory: Ledger directory override.
|
|
134
|
+
"""
|
|
135
|
+
results: list[ReapResult] = []
|
|
136
|
+
for path, entry in load_all(directory):
|
|
137
|
+
if inspector.matches(entry.owner_pid, entry.owner_creation_time):
|
|
138
|
+
results.append(ReapResult(entry, "owner-alive"))
|
|
139
|
+
continue
|
|
140
|
+
if not inspector.matches(entry.pid, entry.creation_time):
|
|
141
|
+
if not dry_run:
|
|
142
|
+
remove(path)
|
|
143
|
+
results.append(ReapResult(entry, "already-exited"))
|
|
144
|
+
continue
|
|
145
|
+
if dry_run:
|
|
146
|
+
results.append(ReapResult(entry, "would-terminate"))
|
|
147
|
+
continue
|
|
148
|
+
if inspector.terminate(entry.pid, entry.creation_time, PureWindowsPath(entry.image).name):
|
|
149
|
+
remove(path)
|
|
150
|
+
results.append(ReapResult(entry, "terminated"))
|
|
151
|
+
else:
|
|
152
|
+
results.append(ReapResult(entry, "failed"))
|
|
153
|
+
return results
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def now() -> float:
|
|
157
|
+
"""Current time (seconds since the epoch); separated for tests."""
|
|
158
|
+
return time.time()
|
|
File without changes
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Design operations (forms, modules, raw text objects): validation first, then the design backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pyaccesskit._backends.protocols import DesignBackend, SchemaBackend
|
|
6
|
+
from pyaccesskit._ops.schema import find_query, find_table
|
|
7
|
+
from pyaccesskit._text import codec
|
|
8
|
+
from pyaccesskit.enums import ModuleKind, ObjectKind
|
|
9
|
+
from pyaccesskit.errors import ObjectExistsError, ObjectNotFoundError, SpecError
|
|
10
|
+
from pyaccesskit.forms.layout import ResolvedForm, layout_form
|
|
11
|
+
from pyaccesskit.forms.spec import FormSpec
|
|
12
|
+
from pyaccesskit.schema.names import check_name, warn_name
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DESIGN_KINDS",
|
|
16
|
+
"build_form",
|
|
17
|
+
"create_module",
|
|
18
|
+
"export_object",
|
|
19
|
+
"find_object",
|
|
20
|
+
"import_object",
|
|
21
|
+
"read_module",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
DESIGN_KINDS = (ObjectKind.FORM, ObjectKind.REPORT, ObjectKind.MACRO, ObjectKind.MODULE)
|
|
25
|
+
_SQL_PREFIXES = ("SELECT", "PARAMETERS", "TRANSFORM", "(")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _check_kind(kind: ObjectKind) -> None:
|
|
29
|
+
if kind not in DESIGN_KINDS and kind is not ObjectKind.QUERY:
|
|
30
|
+
raise SpecError(f"{kind.value} objects cannot be exported/imported as text")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def find_object(design: DesignBackend, kind: ObjectKind, name: str) -> str | None:
|
|
34
|
+
"""The actual name of a form/report/macro/module called ``name`` (case-insensitive), or ``None``."""
|
|
35
|
+
key = name.casefold()
|
|
36
|
+
return next((n for n in design.list_objects(kind) if n.casefold() == key), None)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_form(
|
|
40
|
+
schema: SchemaBackend, design: DesignBackend, spec: FormSpec, *, replace: bool = False
|
|
41
|
+
) -> ResolvedForm:
|
|
42
|
+
"""Validate ``spec`` against the database, lay it out, and build it atomically."""
|
|
43
|
+
resolved = layout_form(spec)
|
|
44
|
+
existing = find_object(design, ObjectKind.FORM, spec.name)
|
|
45
|
+
if existing is not None and not replace:
|
|
46
|
+
raise ObjectExistsError(
|
|
47
|
+
f"a form named {existing!r} already exists (pass replace=True to rebuild it)",
|
|
48
|
+
kind=ObjectKind.FORM,
|
|
49
|
+
name=spec.name,
|
|
50
|
+
)
|
|
51
|
+
source = spec.record_source
|
|
52
|
+
if source is not None and not source.lstrip().upper().startswith(_SQL_PREFIXES):
|
|
53
|
+
table = find_table(schema, source)
|
|
54
|
+
if table is None and find_query(schema, source) is None:
|
|
55
|
+
raise ObjectNotFoundError(
|
|
56
|
+
f"record source {source!r} of form {spec.name!r} is neither a table nor a query",
|
|
57
|
+
kind=ObjectKind.TABLE,
|
|
58
|
+
name=source,
|
|
59
|
+
)
|
|
60
|
+
if table is not None:
|
|
61
|
+
columns = schema.read_table(table.name).column_names
|
|
62
|
+
known = {c.casefold() for c in columns}
|
|
63
|
+
for control in resolved.controls:
|
|
64
|
+
field = control.spec.bound_field
|
|
65
|
+
if field is not None and field.casefold() not in known:
|
|
66
|
+
raise SpecError(
|
|
67
|
+
f"control {control.name!r} of form {spec.name!r} is bound to {field!r}, which is not a "
|
|
68
|
+
f"column of {table.name!r} (columns: {', '.join(columns)})"
|
|
69
|
+
)
|
|
70
|
+
warn_name(spec.name, what="form name", stacklevel=4)
|
|
71
|
+
design.build_form(resolved, replace=existing is not None)
|
|
72
|
+
return resolved
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def create_module(
|
|
76
|
+
design: DesignBackend,
|
|
77
|
+
name: str,
|
|
78
|
+
code: str,
|
|
79
|
+
kind: ModuleKind = ModuleKind.STANDARD,
|
|
80
|
+
*,
|
|
81
|
+
replace: bool = False,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Create (or replace) a VBA module from source code."""
|
|
84
|
+
check_name(name, what="module name")
|
|
85
|
+
existing = find_object(design, ObjectKind.MODULE, name)
|
|
86
|
+
if existing is not None and not replace:
|
|
87
|
+
raise ObjectExistsError(
|
|
88
|
+
f"a module named {existing!r} already exists (pass replace=True to overwrite it)",
|
|
89
|
+
kind=ObjectKind.MODULE,
|
|
90
|
+
name=name,
|
|
91
|
+
)
|
|
92
|
+
text = codec.module_import_text(code, kind)
|
|
93
|
+
data = codec.encode_import(ObjectKind.MODULE, text)
|
|
94
|
+
design.import_text(ObjectKind.MODULE, existing or name, data)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def read_module(design: DesignBackend, name: str) -> tuple[ModuleKind, str]:
|
|
98
|
+
"""Return ``(kind, code)`` of a VBA module."""
|
|
99
|
+
actual = find_object(design, ObjectKind.MODULE, name)
|
|
100
|
+
if actual is None:
|
|
101
|
+
raise ObjectNotFoundError(
|
|
102
|
+
f"module {name!r} does not exist", kind=ObjectKind.MODULE, name=name
|
|
103
|
+
)
|
|
104
|
+
text = codec.decode_export(ObjectKind.MODULE, design.export_text(ObjectKind.MODULE, actual))
|
|
105
|
+
return codec.split_module_export(text)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def export_object(design: DesignBackend, kind: ObjectKind, name: str) -> str:
|
|
109
|
+
"""``SaveAsText`` an object and return its text (LF line endings)."""
|
|
110
|
+
_check_kind(kind)
|
|
111
|
+
return codec.decode_export(kind, design.export_text(kind, name))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def import_object(
|
|
115
|
+
design: DesignBackend, kind: ObjectKind, name: str, text: str, *, replace: bool = False
|
|
116
|
+
) -> None:
|
|
117
|
+
"""``LoadFromText`` an object from text (encoded the way Access expects for ``kind``)."""
|
|
118
|
+
_check_kind(kind)
|
|
119
|
+
check_name(name, what=f"{kind.value} name")
|
|
120
|
+
existing = find_object(design, kind, name)
|
|
121
|
+
if existing is not None and not replace: # LoadFromText would overwrite without asking
|
|
122
|
+
raise ObjectExistsError(
|
|
123
|
+
f"a {kind.value} named {existing!r} already exists (pass replace=True to overwrite it)",
|
|
124
|
+
kind=kind,
|
|
125
|
+
name=name,
|
|
126
|
+
)
|
|
127
|
+
design.import_text(kind, name, codec.encode_import(kind, text))
|