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
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))