aepkit 0.6.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.
aepkit/negcontrol.py ADDED
@@ -0,0 +1,234 @@
1
+ """Run the backup -> break -> test (expect fail) -> restore -> test (expect pass)
2
+ sequence this protocol requires for every gate that guards a security, privacy, or
3
+ financial-integrity boundary, and print a paste-ready transcript.
4
+
5
+ This replaces doing the same four shell commands by hand, which is where the sequence
6
+ tends to get shortened under time pressure.
7
+
8
+ With --file, the backup is the source of truth: whatever --restore-cmd does, the file
9
+ must end byte-identical to the backup, or it is restored from the backup. The backup is
10
+ deleted only once the file is verified identical; otherwise its path is printed.
11
+
12
+ A failure only counts if it is the right failure. With --expect, the output of the
13
+ failing run must match that regular expression, so a syntax error, a missing import or a
14
+ typo in the test command cannot pass for the protection being caught. Without it, output
15
+ that looks like such an error is flagged in the verdict.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import filecmp
21
+ import os
22
+ import re
23
+ import shutil
24
+ import signal
25
+ import subprocess
26
+ import sys
27
+ import tempfile
28
+ from pathlib import Path
29
+
30
+ TIMEOUT_EXIT = 124
31
+
32
+ # Output that usually means the test never ran, rather than ran and caught the break.
33
+ _CRASH_SIGNATURES = re.compile(
34
+ r"SyntaxError|IndentationError|ImportError|ModuleNotFoundError|ERROR collecting"
35
+ r"|Cannot find module|command not found|is not recognized as an internal or external command"
36
+ r"|^\(timed out after ",
37
+ re.MULTILINE,
38
+ )
39
+
40
+
41
+ def _kill_tree(proc: subprocess.Popen[str]) -> None:
42
+ """Kill the shell and every process it started, not only the shell."""
43
+ if os.name == "nt":
44
+ subprocess.run(
45
+ ["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True, check=False
46
+ )
47
+ else:
48
+ try:
49
+ os.killpg(proc.pid, signal.SIGKILL)
50
+ except ProcessLookupError:
51
+ pass
52
+
53
+
54
+ def _run(cmd: str, timeout: float | None = None) -> tuple[int, str]:
55
+ # Each command runs in its own process group (session), so a timeout can kill the
56
+ # processes it started: a timed-out break must not keep changing the file afterwards.
57
+ if os.name == "nt":
58
+ group = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} # type: ignore[attr-defined]
59
+ else:
60
+ group = {"start_new_session": True}
61
+ proc = subprocess.Popen(
62
+ cmd,
63
+ shell=True,
64
+ stdout=subprocess.PIPE,
65
+ stderr=subprocess.STDOUT,
66
+ text=True,
67
+ **group,
68
+ )
69
+ try:
70
+ output, _ = proc.communicate(timeout=timeout)
71
+ except subprocess.TimeoutExpired:
72
+ _kill_tree(proc)
73
+ proc.communicate()
74
+ return TIMEOUT_EXIT, f"(timed out after {timeout:g}s)"
75
+ return proc.returncode, output or ""
76
+
77
+
78
+ def _is_identical(file_path: str, backup_path: Path) -> bool:
79
+ try:
80
+ return Path(file_path).is_file() and filecmp.cmp(file_path, str(backup_path), shallow=False)
81
+ except OSError:
82
+ return False
83
+
84
+
85
+ def _ensure_restored(
86
+ file_path: str, backup_path: Path, transcript: list[str], after_restore_cmd: bool
87
+ ) -> bool:
88
+ """Leave file_path byte-identical to the backup, whatever the break or restore did
89
+ (modified, deleted, moved). Returns False only if that could not be achieved."""
90
+ if _is_identical(file_path, backup_path):
91
+ transcript.append(f"$ {file_path} is byte-identical to the backup")
92
+ return True
93
+ if after_restore_cmd:
94
+ transcript.append("WARNING: --restore-cmd did not restore the file; using the backup.")
95
+ try:
96
+ target = Path(file_path)
97
+ target.parent.mkdir(parents=True, exist_ok=True)
98
+ shutil.copy2(backup_path, target)
99
+ except OSError as error:
100
+ transcript.append(f"ERROR: could not restore {file_path} from the backup: {error}")
101
+ return False
102
+ identical = _is_identical(file_path, backup_path)
103
+ transcript.append(
104
+ f"$ restored {file_path} from backup "
105
+ + ("(byte-identical)" if identical else "- WARNING: still differs from backup")
106
+ )
107
+ return identical
108
+
109
+
110
+ def run(
111
+ test_cmd: str,
112
+ *,
113
+ break_cmd: str | None = None,
114
+ file_path: str | None = None,
115
+ restore_cmd: str | None = None,
116
+ timeout: float | None = None,
117
+ expect: str | None = None,
118
+ ) -> int:
119
+ expect_re: re.Pattern[str] | None = None
120
+ if expect is not None:
121
+ try:
122
+ expect_re = re.compile(expect, re.MULTILINE)
123
+ except re.error as error:
124
+ print(f"error: --expect is not a valid regular expression: {error}", file=sys.stderr)
125
+ return 2
126
+ if not break_cmd and not restore_cmd:
127
+ print(
128
+ "error: pass --break-cmd, or --restore-cmd for a break you apply yourself.",
129
+ file=sys.stderr,
130
+ )
131
+ return 2
132
+ if not file_path and not restore_cmd:
133
+ print(
134
+ "error: without --file, --restore-cmd is required (nothing to copy back).",
135
+ file=sys.stderr,
136
+ )
137
+ return 2
138
+
139
+ tmp_dir: Path | None = None
140
+ backup_path: Path | None = None
141
+ transcript: list[str] = []
142
+ # With --file, nothing counts as restored until the file is verified identical.
143
+ restored_ok = not file_path
144
+
145
+ try:
146
+ if file_path:
147
+ src = Path(file_path)
148
+ if not src.exists():
149
+ print(f"error: {file_path} does not exist.", file=sys.stderr)
150
+ return 2
151
+ tmp_dir = Path(tempfile.mkdtemp(prefix="aepkit-negcontrol-"))
152
+ backup_path = tmp_dir / src.name
153
+ shutil.copy2(src, backup_path)
154
+ transcript.append(f"$ backed up {file_path} -> {backup_path}")
155
+
156
+ broke_as_expected = False
157
+ out1 = ""
158
+ try:
159
+ if break_cmd:
160
+ transcript.append(f"$ {break_cmd}")
161
+ code, out = _run(break_cmd, timeout)
162
+ transcript.append(out.rstrip("\n"))
163
+ if code != 0:
164
+ print(
165
+ f"warning: break command exited {code}. "
166
+ "Continuing, but check it did what you meant.",
167
+ file=sys.stderr,
168
+ )
169
+
170
+ expect_note = f" matching /{expect}/" if expect is not None else ""
171
+ transcript.append(f"$ {test_cmd} # expect FAILURE{expect_note}")
172
+ code1, out1 = _run(test_cmd, timeout)
173
+ transcript.append(out1.rstrip("\n"))
174
+ broke_as_expected = code1 != 0
175
+ transcript.append(f"(exit {code1})")
176
+ finally:
177
+ if restore_cmd:
178
+ transcript.append(f"$ {restore_cmd}")
179
+ restore_code, out = _run(restore_cmd, timeout)
180
+ transcript.append(out.rstrip("\n"))
181
+ transcript.append(f"(restore exit {restore_code})")
182
+ restored_ok = restore_code == 0
183
+ if file_path and backup_path is not None:
184
+ restored_ok = _ensure_restored(
185
+ file_path, backup_path, transcript, after_restore_cmd=bool(restore_cmd)
186
+ )
187
+
188
+ transcript.append(f"$ {test_cmd} # expect SUCCESS")
189
+ code2, out2 = _run(test_cmd, timeout)
190
+ transcript.append(out2.rstrip("\n"))
191
+ restored_to_green = code2 == 0
192
+ transcript.append(f"(exit {code2})")
193
+
194
+ print("\n".join(transcript))
195
+ print()
196
+
197
+ if not restored_ok:
198
+ print("FAIL: the protection was not restored. Check the tree before continuing.")
199
+ return 1
200
+ if not broke_as_expected:
201
+ print(
202
+ "FAIL: the test did not fail when the protection was removed. "
203
+ "Either the break command didn't do what you meant, or the protection isn't real."
204
+ )
205
+ return 1
206
+ if expect_re is not None and not expect_re.search(out1):
207
+ print(
208
+ f"FAIL: the test failed, but its output does not match --expect /{expect}/. "
209
+ "It may have failed for an unrelated reason (a syntax error, a missing import, "
210
+ "a wrong command); read the output above."
211
+ )
212
+ return 1
213
+ if not restored_to_green:
214
+ print("FAIL: the test did not return to passing after restore. The tree may be dirty.")
215
+ return 1
216
+
217
+ print(
218
+ "OK: negative control verified — the check fails without the protection and passes with it."
219
+ )
220
+ if expect_re is None:
221
+ crash = _CRASH_SIGNATURES.search(out1)
222
+ if crash:
223
+ print(
224
+ f"WARNING: the failing output contains {crash.group(0).strip()!r}, which "
225
+ "usually means the test did not run rather than caught the break. Read the "
226
+ "output above, and pass --expect with the failure you mean to see."
227
+ )
228
+ return 0
229
+ finally:
230
+ if tmp_dir and tmp_dir.exists():
231
+ if restored_ok:
232
+ shutil.rmtree(tmp_dir, ignore_errors=True)
233
+ else:
234
+ print(f"Backup kept at {backup_path}", file=sys.stderr)
aepkit/py.typed ADDED
File without changes
aepkit/scaffold.py ADDED
@@ -0,0 +1,40 @@
1
+ """Scaffold the document set: three documents and an annexes/ directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ TEMPLATES_DIR = Path(__file__).parent / "templates"
8
+
9
+ DOCUMENTS = ("plan-of-record.md", "execution-guide.md", "compliance-log.md")
10
+
11
+
12
+ def run(target_dir: str, project_name: str | None = None, force: bool = False) -> int:
13
+ target = Path(target_dir)
14
+ target.mkdir(parents=True, exist_ok=True)
15
+ (target / "annexes").mkdir(exist_ok=True)
16
+
17
+ name = project_name or target.resolve().name
18
+
19
+ wrote = []
20
+ skipped = []
21
+ for doc in DOCUMENTS:
22
+ src = TEMPLATES_DIR / doc
23
+ dst = target / doc
24
+ if dst.exists() and not force:
25
+ skipped.append(dst)
26
+ continue
27
+ content = src.read_text(encoding="utf-8").replace("{{PROJECT_NAME}}", name)
28
+ dst.write_text(content, encoding="utf-8")
29
+ wrote.append(dst)
30
+
31
+ for path in wrote:
32
+ print(f"wrote {path}")
33
+ for path in skipped:
34
+ print(f"skip {path} (exists, use --force to overwrite)")
35
+
36
+ if not (target / "annexes" / ".gitkeep").exists():
37
+ (target / "annexes" / ".gitkeep").write_text("", encoding="utf-8")
38
+
39
+ print(f"\n{target}/ scaffolded. Read execution-guide.md's Rules of engagement first.")
40
+ return 0