pytest-live-pause 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.
@@ -0,0 +1,3 @@
1
+ from .plugin import checkpoint
2
+
3
+ __all__ = ["checkpoint"]
@@ -0,0 +1,234 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from .runtime import (
9
+ default_pause_dir,
10
+ effective_pause_status,
11
+ is_local_process_alive,
12
+ list_pauses,
13
+ parse_result_token,
14
+ pause_created_at,
15
+ read_json_file,
16
+ write_json_file,
17
+ )
18
+
19
+
20
+ def main(argv: list[str] | None = None) -> int:
21
+ parser = argparse.ArgumentParser(prog="pytest-live-pause")
22
+ subparsers = parser.add_subparsers(dest="command", required=True)
23
+
24
+ ls = subparsers.add_parser("ls")
25
+ ls.add_argument("--pause-dir", default=str(default_pause_dir()))
26
+ ls.add_argument("-a", "--all", action="store_true", dest="include_finished")
27
+
28
+ watch = subparsers.add_parser("watch")
29
+ watch.add_argument("--pause-dir", default=str(default_pause_dir()))
30
+ watch.add_argument("--run-id", required=True)
31
+ watch.add_argument("-a", "--all", action="store_true", dest="include_finished")
32
+ watch.add_argument("--interval", type=float, default=0.2)
33
+ watch.add_argument("--timeout", type=float, default=None)
34
+
35
+ resume = subparsers.add_parser("resume")
36
+ resume.add_argument("target", help="Pause request file path or pause_id.")
37
+ resume.add_argument("--pause-dir", default=str(default_pause_dir()))
38
+ resume.add_argument("--failure-reason", default=None)
39
+ resume.add_argument("--result", choices=["true", "false", "none"], default=None)
40
+ resume.add_argument("--reason", default=None)
41
+
42
+ args = parser.parse_args(argv)
43
+ if args.command == "ls":
44
+ return _ls(Path(args.pause_dir), args.include_finished)
45
+ if args.command == "watch":
46
+ return _watch(Path(args.pause_dir), args.run_id, args.include_finished, args.interval, args.timeout)
47
+ if args.command == "resume":
48
+ return _resume(args.target, Path(args.pause_dir), args.failure_reason, args.result, args.reason)
49
+ parser.error(f"unknown command: {args.command}")
50
+ return 2
51
+
52
+
53
+ def _resume(target: str, root_dir: Path, failure_reason: str | None, result_token: str | None, reason: str | None) -> int:
54
+ pause_path = _resolve_pause_target(target, root_dir)
55
+ if pause_path is None:
56
+ print(f"pause target not found: {target}", file=sys.stderr)
57
+ return 1
58
+
59
+ pause_payload = read_json_file(pause_path)
60
+ pause_status = effective_pause_status(pause_payload)
61
+ if pause_status == "stale":
62
+ print(f"pause belongs to a dead process and is stale: {pause_path}", file=sys.stderr)
63
+ return 1
64
+ if pause_status != "pending":
65
+ print(f"pause is no longer pending: {pause_path}", file=sys.stderr)
66
+ return 1
67
+ validation_error = _validate_resume_args(pause_payload, failure_reason, result_token, reason)
68
+ if validation_error is not None:
69
+ print(validation_error, file=sys.stderr)
70
+ return 1
71
+
72
+ commands_dir = pause_path.parent.parent / "commands"
73
+ command_path = commands_dir / f"{pause_path.stem}.resume.json"
74
+ if command_path.exists():
75
+ print(f"resume command already exists: {command_path}", file=sys.stderr)
76
+ return 1
77
+ commands_dir.mkdir(parents=True, exist_ok=True)
78
+ payload: dict[str, object] = {}
79
+ if failure_reason is not None:
80
+ payload["failure_reason"] = failure_reason
81
+ if result_token is not None:
82
+ payload["result"] = parse_result_token(result_token)
83
+ if reason is not None:
84
+ payload["reason"] = reason
85
+ write_json_file(command_path, payload)
86
+ print(f"resume command accepted for {pause_path.stem}")
87
+ return 0
88
+
89
+
90
+ def _validate_resume_args(
91
+ pause_payload: dict[str, object],
92
+ failure_reason: str | None,
93
+ result_token: str | None,
94
+ reason: str | None,
95
+ ) -> str | None:
96
+ pause_kind = pause_payload.get("kind")
97
+ if pause_kind == "failure":
98
+ if result_token is not None or reason is not None:
99
+ return "--result/--reason are only allowed for checkpoint pauses; got kind=failure"
100
+ return None
101
+ if pause_kind == "checkpoint":
102
+ if failure_reason is not None:
103
+ return "--failure-reason is only allowed for failure pauses; got kind=checkpoint"
104
+ return None
105
+ if failure_reason is not None or result_token is not None or reason is not None:
106
+ kind_text = str(pause_kind) if pause_kind is not None else "unknown"
107
+ return f"unsupported pause kind for supplied arguments: {kind_text}"
108
+ return None
109
+
110
+
111
+ def _resolve_pause_target(target: str, root_dir: Path) -> Path | None:
112
+ direct_path = Path(target)
113
+ if direct_path.exists():
114
+ return direct_path
115
+
116
+ matches: list[Path] = []
117
+ for pause in list_pauses(root_dir, include_finished=True):
118
+ if pause.get("pause_id") != target:
119
+ continue
120
+ pause_file = pause.get("pause_file")
121
+ if isinstance(pause_file, str):
122
+ matches.append(Path(pause_file))
123
+ if not matches:
124
+ return None
125
+ if len(matches) > 1:
126
+ print(f"multiple pauses matched id {target}; use the full pause file path", file=sys.stderr)
127
+ return None
128
+ return matches[0]
129
+
130
+
131
+ def _ls(root_dir: Path, include_finished: bool) -> int:
132
+ pauses = list_pauses(root_dir, include_finished=include_finished)
133
+ if not pauses:
134
+ return 0
135
+
136
+ for pause in pauses:
137
+ print(_format_pause_line(pause))
138
+ return 0
139
+
140
+
141
+ def _watch(root_dir: Path, run_id: str, include_finished: bool, interval: float, timeout: float | None) -> int:
142
+ run_check = _check_run_watchable(root_dir, run_id)
143
+ if run_check is not None:
144
+ print(run_check, file=sys.stderr)
145
+ return 1
146
+
147
+ deadline = None if timeout is None else time.monotonic() + timeout
148
+
149
+ while True:
150
+ pauses = list_pauses(root_dir, include_finished=include_finished, run_id=run_id)
151
+ latest_pause = _select_latest_pause(pauses)
152
+ if latest_pause is not None:
153
+ print(_format_watch_details(latest_pause))
154
+ return 0
155
+
156
+ if deadline is not None and time.monotonic() >= deadline:
157
+ return 0
158
+ time.sleep(interval)
159
+
160
+
161
+ def _select_latest_pause(pauses: list[dict[str, object]]) -> dict[str, object] | None:
162
+ if not pauses:
163
+ return None
164
+ return max(pauses, key=pause_created_at)
165
+
166
+
167
+ def _check_run_watchable(root_dir: Path, run_id: str) -> str | None:
168
+ run_file = root_dir / "runs" / run_id / "run.json"
169
+ if not run_file.exists():
170
+ return f"run not found: {run_id}"
171
+
172
+ run_payload = read_json_file(run_file)
173
+
174
+ pid = run_payload.get("pid")
175
+ hostname = run_payload.get("hostname") if isinstance(run_payload.get("hostname"), str) else None
176
+ if not isinstance(pid, int) or not is_local_process_alive(pid, hostname):
177
+ return f"run is no longer active: {run_id}"
178
+ return None
179
+
180
+
181
+ def _format_watch_details(pause: dict[str, object]) -> str:
182
+ pause_id = str(pause.get("pause_id", ""))
183
+ kind = str(pause.get("kind", "unknown"))
184
+ nodeid = str(pause.get("nodeid", ""))
185
+ task = _task_for_pause(pause)
186
+ lines = [
187
+ f"pause detected: {pause_id}",
188
+ f"kind: {kind}",
189
+ f"nodeid: {nodeid}",
190
+ f"task: {task}",
191
+ ]
192
+ failure_details = _failure_details(pause)
193
+ if failure_details is not None:
194
+ lines.append("failure:")
195
+ lines.extend(f" {line}" for line in failure_details)
196
+ return "\n".join(lines)
197
+
198
+
199
+ def _task_for_pause(pause: dict[str, object]) -> str:
200
+ task = pause.get("task")
201
+ if isinstance(task, str) and task.strip():
202
+ return task
203
+ if pause.get("kind") == "failure":
204
+ return "Inspect the failure and then resume the test."
205
+ return "Complete the pending task and then resume the paused test."
206
+
207
+
208
+ def _failure_details(pause: dict[str, object]) -> list[str] | None:
209
+ original_failure = pause.get("original_failure")
210
+ if not isinstance(original_failure, str) or not original_failure.strip():
211
+ return None
212
+
213
+ lines = [line.rstrip() for line in original_failure.strip().splitlines() if line.strip()]
214
+ if not lines:
215
+ return None
216
+ return lines
217
+
218
+
219
+ def _format_pause_line(pause: dict[str, object]) -> str:
220
+ worker_id = pause.get("worker_id") or "-"
221
+ return "\t".join(
222
+ [
223
+ str(pause.get("status", "unknown")),
224
+ str(pause.get("pause_id", "")),
225
+ str(pause.get("nodeid", "")),
226
+ f"pid={pause.get('pid', '')}",
227
+ f"worker={worker_id}",
228
+ str(pause.get("pause_file", "")),
229
+ ]
230
+ )
231
+
232
+
233
+ if __name__ == "__main__":
234
+ raise SystemExit(main())
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ from contextvars import ContextVar
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from .runtime import PauseManager, ResumeDecision, default_pause_dir, parse_result_token
10
+
11
+ _MANAGER_KEY = object()
12
+ _RUN_ID_ANNOUNCED_KEY = object()
13
+ _ACTIVE_ITEM: ContextVar[pytest.Item | None] = ContextVar("pytest_live_pause_active_item", default=None)
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class LivePause:
18
+ manager: PauseManager
19
+ item: pytest.Item
20
+
21
+ @property
22
+ def run_id(self) -> str:
23
+ return self.manager.run_id
24
+
25
+ def checkpoint(self, *, task: str, timeout: float | None = None) -> ResumeDecision:
26
+ return _checkpoint_for_item(self.item, task=task, timeout=timeout)
27
+
28
+
29
+ def pytest_addoption(parser: pytest.Parser) -> None:
30
+ group = parser.getgroup("live-pause")
31
+ group.addoption(
32
+ "--pause-on-failure",
33
+ action="store_true",
34
+ default=False,
35
+ help="Pause failed tests before teardown and wait for an external resume command.",
36
+ )
37
+ group.addoption(
38
+ "--pause-dir",
39
+ action="store",
40
+ default=str(default_pause_dir()),
41
+ help="Directory used for live pause state and resume commands.",
42
+ )
43
+ group.addoption(
44
+ "--pause-timeout",
45
+ action="store",
46
+ type=float,
47
+ default=300.0,
48
+ help="Maximum number of seconds to wait before resuming automatically. Default is 300 seconds (5 minutes).",
49
+ )
50
+ group.addoption(
51
+ "--pause-checkpoint-mock",
52
+ action="store",
53
+ choices=["true", "false", "none"],
54
+ default=None,
55
+ help="Mock checkpoint result and skip creating an external pause.",
56
+ )
57
+
58
+
59
+ def pytest_configure(config: pytest.Config) -> None:
60
+ config.stash[_MANAGER_KEY] = _create_manager(config)
61
+ config.stash[_RUN_ID_ANNOUNCED_KEY] = False
62
+
63
+
64
+ def pytest_report_header(config: pytest.Config) -> list[str] | None:
65
+ manager = config.stash[_MANAGER_KEY]
66
+ config.stash[_RUN_ID_ANNOUNCED_KEY] = True
67
+ return [
68
+ f"pytest-live-pause: run_id={manager.run_id}",
69
+ ]
70
+
71
+
72
+ def checkpoint(*, task: str, timeout: float | None = None):
73
+ if not task.strip():
74
+ raise ValueError("checkpoint task must not be empty")
75
+
76
+ item = _ACTIVE_ITEM.get()
77
+ if item is None:
78
+ raise RuntimeError("checkpoint can only be used while a pytest test is running")
79
+
80
+ return _checkpoint_for_item(item, task=task, timeout=timeout)
81
+
82
+
83
+ @pytest.fixture
84
+ def live_pause(request: pytest.FixtureRequest) -> LivePause:
85
+ item = request.node
86
+ assert isinstance(item, pytest.Item)
87
+ manager = _get_or_create_manager(item.config)
88
+ return LivePause(manager=manager, item=item)
89
+
90
+
91
+ @pytest.fixture
92
+ def pause_run_id(live_pause: LivePause) -> str:
93
+ return live_pause.run_id
94
+
95
+
96
+ @pytest.hookimpl(hookwrapper=True)
97
+ def pytest_runtest_protocol(item: pytest.Item, nextitem: pytest.Item | None) -> object:
98
+ token = _ACTIVE_ITEM.set(item)
99
+ try:
100
+ yield
101
+ finally:
102
+ _ACTIVE_ITEM.reset(token)
103
+
104
+
105
+ @pytest.hookimpl(hookwrapper=True)
106
+ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[None]) -> object:
107
+ outcome = yield
108
+ report = outcome.get_result()
109
+
110
+ if report.when != "call" or not report.failed:
111
+ return
112
+ if not item.config.getoption("pause_on_failure"):
113
+ return
114
+
115
+ manager = _get_or_create_manager(item.config)
116
+ decision = manager.pause_on_failure(
117
+ nodeid=item.nodeid,
118
+ original_failure=report.longreprtext,
119
+ timeout=item.config.getoption("pause_timeout"),
120
+ )
121
+ if decision.failure_reason:
122
+ report.sections.append(("live pause", f"External failure reason: {decision.failure_reason}"))
123
+
124
+
125
+ def _create_manager(config: pytest.Config) -> PauseManager:
126
+ root_dir = Path(config.getoption("pause_dir"))
127
+ timeout = config.getoption("pause_timeout")
128
+ return PauseManager(root_dir=root_dir, default_timeout=timeout)
129
+
130
+
131
+ def _get_or_create_manager(config: pytest.Config) -> PauseManager:
132
+ try:
133
+ manager = config.stash[_MANAGER_KEY]
134
+ except KeyError:
135
+ manager = _create_manager(config)
136
+ config.stash[_MANAGER_KEY] = manager
137
+ config.stash[_RUN_ID_ANNOUNCED_KEY] = False
138
+ _announce_run_id(config, manager)
139
+ return manager
140
+ return manager
141
+
142
+
143
+ def _announce_run_id(config: pytest.Config, manager: PauseManager) -> None:
144
+ try:
145
+ announced = config.stash[_RUN_ID_ANNOUNCED_KEY]
146
+ except KeyError:
147
+ announced = False
148
+ if announced:
149
+ return
150
+
151
+ terminal = config.pluginmanager.get_plugin("terminalreporter")
152
+ if terminal is not None:
153
+ terminal.write_line(f"pytest-live-pause: run_id={manager.run_id}")
154
+ config.stash[_RUN_ID_ANNOUNCED_KEY] = True
155
+
156
+
157
+ def _checkpoint_for_item(item: pytest.Item, *, task: str, timeout: float | None) -> ResumeDecision:
158
+ mock_decision = _mock_checkpoint_decision(item.config)
159
+ if mock_decision is not None:
160
+ return mock_decision
161
+
162
+ manager = _get_or_create_manager(item.config)
163
+ return manager.pause_checkpoint(nodeid=item.nodeid, task=task, timeout=timeout)
164
+
165
+
166
+ def _mock_checkpoint_decision(config: pytest.Config) -> ResumeDecision | None:
167
+ result_token = config.getoption("pause_checkpoint_mock")
168
+ if result_token is None:
169
+ return None
170
+
171
+ return ResumeDecision(
172
+ outcome="resumed",
173
+ result=parse_result_token(result_token),
174
+ reason="Mock",
175
+ )
@@ -0,0 +1,288 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import socket
6
+ import sys
7
+ import tempfile
8
+ import time
9
+ import uuid
10
+ from dataclasses import dataclass, asdict
11
+ from pathlib import Path
12
+ from typing import Literal
13
+
14
+
15
+ def default_pause_dir() -> Path:
16
+ return Path(tempfile.gettempdir()) / "pytest-live-pause"
17
+
18
+
19
+ def is_local_process_alive(pid: int, hostname: str | None) -> bool:
20
+ if pid <= 0:
21
+ return False
22
+ if hostname and hostname != socket.gethostname():
23
+ return False
24
+
25
+ try:
26
+ os.kill(pid, 0)
27
+ except ProcessLookupError:
28
+ return False
29
+ except PermissionError:
30
+ return True
31
+ return True
32
+
33
+
34
+ def is_pause_stale(payload: dict[str, object]) -> bool:
35
+ if payload.get("status") != "pending":
36
+ return False
37
+
38
+ hostname = payload.get("hostname")
39
+ pid = payload.get("pid")
40
+ if not isinstance(pid, int) or pid <= 0:
41
+ return False
42
+ return not is_local_process_alive(pid, hostname if isinstance(hostname, str) else None)
43
+
44
+
45
+ def effective_pause_status(payload: dict[str, object]) -> str:
46
+ if is_pause_stale(payload):
47
+ return "stale"
48
+ status = payload.get("status")
49
+ return str(status) if status is not None else "unknown"
50
+
51
+
52
+ def parse_result_token(result_token: str) -> bool | None:
53
+ if result_token == "true":
54
+ return True
55
+ if result_token == "false":
56
+ return False
57
+ return None
58
+
59
+
60
+ def pause_created_at(payload: dict[str, object]) -> float:
61
+ created_at = payload.get("created_at")
62
+ if isinstance(created_at, int | float):
63
+ return float(created_at)
64
+ return 0.0
65
+
66
+
67
+ def read_json_file(path: Path) -> dict[str, object]:
68
+ with path.open("r", encoding="utf-8") as handle:
69
+ return json.load(handle)
70
+
71
+
72
+ def write_json_file(path: Path, payload: dict[str, object]) -> None:
73
+ path.parent.mkdir(parents=True, exist_ok=True)
74
+ temp_path = path.with_suffix(path.suffix + ".tmp")
75
+ with temp_path.open("w", encoding="utf-8") as handle:
76
+ json.dump(payload, handle, ensure_ascii=True, indent=2)
77
+ temp_path.replace(path)
78
+
79
+
80
+ @dataclass(slots=True)
81
+ class ResumeDecision:
82
+ outcome: Literal["resumed", "timed_out"]
83
+ failure_reason: str | None = None
84
+ result: bool | None = None
85
+ reason: str | None = None
86
+
87
+
88
+ @dataclass(slots=True)
89
+ class PauseRequest:
90
+ pause_id: str
91
+ run_id: str
92
+ kind: Literal["failure", "checkpoint"]
93
+ nodeid: str
94
+ task: str | None
95
+ timeout: float
96
+ original_failure: str
97
+ status: Literal["pending", "resumed", "timed_out"]
98
+ created_at: float
99
+ pid: int
100
+ hostname: str
101
+ cwd: str
102
+ worker_id: str | None = None
103
+ finished_at: float | None = None
104
+ failure_reason: str | None = None
105
+
106
+
107
+ @dataclass(slots=True)
108
+ class RunMetadata:
109
+ run_id: str
110
+ pid: int
111
+ hostname: str
112
+ cwd: str
113
+ argv: list[str]
114
+ started_at: float
115
+ worker_id: str | None = None
116
+
117
+
118
+ class PauseManager:
119
+ def __init__(self, root_dir: Path, default_timeout: float) -> None:
120
+ self.root_dir = root_dir
121
+ self.default_timeout = default_timeout
122
+ self.run_id = uuid.uuid4().hex
123
+ self.run_dir = self.root_dir / "runs" / self.run_id
124
+ self.pauses_dir = self.run_dir / "pauses"
125
+ self.commands_dir = self.run_dir / "commands"
126
+ self.events_file = self.run_dir / "events.jsonl"
127
+ self.run_file = self.run_dir / "run.json"
128
+ self.worker_id = os.environ.get("PYTEST_XDIST_WORKER")
129
+ self.pauses_dir.mkdir(parents=True, exist_ok=True)
130
+ self.commands_dir.mkdir(parents=True, exist_ok=True)
131
+ metadata = RunMetadata(
132
+ run_id=self.run_id,
133
+ pid=os.getpid(),
134
+ hostname=socket.gethostname(),
135
+ cwd=os.getcwd(),
136
+ argv=sys.argv.copy(),
137
+ started_at=time.time(),
138
+ worker_id=self.worker_id,
139
+ )
140
+ write_json_file(self.run_file, asdict(metadata))
141
+ self._emit(
142
+ {
143
+ "event": "run_started",
144
+ "run_id": self.run_id,
145
+ "pid": metadata.pid,
146
+ "worker_id": metadata.worker_id,
147
+ }
148
+ )
149
+
150
+ def pause_on_failure(self, nodeid: str, original_failure: str, timeout: float | None) -> ResumeDecision:
151
+ request = self._build_request(
152
+ kind="failure",
153
+ nodeid=nodeid,
154
+ task="Inspect the failure and then resume the test.",
155
+ timeout=timeout,
156
+ original_failure=original_failure,
157
+ )
158
+ return self._pause(request)
159
+
160
+ def pause_checkpoint(self, nodeid: str, task: str, timeout: float | None) -> ResumeDecision:
161
+ request = self._build_request(
162
+ kind="checkpoint",
163
+ nodeid=nodeid,
164
+ task=task,
165
+ timeout=timeout,
166
+ original_failure="",
167
+ )
168
+ return self._pause(request)
169
+
170
+ def _build_request(
171
+ self,
172
+ kind: Literal["failure", "checkpoint"],
173
+ nodeid: str,
174
+ task: str | None,
175
+ timeout: float | None,
176
+ original_failure: str,
177
+ ) -> PauseRequest:
178
+ return PauseRequest(
179
+ pause_id=uuid.uuid4().hex,
180
+ run_id=self.run_id,
181
+ kind=kind,
182
+ nodeid=nodeid,
183
+ task=task,
184
+ timeout=timeout if timeout is not None else self.default_timeout,
185
+ original_failure=original_failure,
186
+ status="pending",
187
+ created_at=time.time(),
188
+ pid=os.getpid(),
189
+ hostname=socket.gethostname(),
190
+ cwd=os.getcwd(),
191
+ worker_id=self.worker_id,
192
+ )
193
+
194
+ def _pause(self, request: PauseRequest) -> ResumeDecision:
195
+ pause_path = self.pauses_dir / f"{request.pause_id}.json"
196
+ write_json_file(pause_path, asdict(request))
197
+ self._emit(
198
+ {
199
+ "event": "pause_created",
200
+ "kind": request.kind,
201
+ "pause_id": request.pause_id,
202
+ "nodeid": request.nodeid,
203
+ "timeout": request.timeout,
204
+ "pid": request.pid,
205
+ "worker_id": request.worker_id,
206
+ }
207
+ )
208
+ decision = self._wait_for_resume(request)
209
+ write_json_file(
210
+ pause_path,
211
+ {
212
+ **asdict(request),
213
+ "status": "resumed" if decision.outcome == "resumed" else "timed_out",
214
+ "finished_at": time.time(),
215
+ "failure_reason": decision.failure_reason,
216
+ "result": decision.result,
217
+ "reason": decision.reason,
218
+ },
219
+ )
220
+ self._emit(
221
+ {
222
+ "event": "pause_finished",
223
+ "pause_id": request.pause_id,
224
+ "outcome": decision.outcome,
225
+ "failure_reason": decision.failure_reason,
226
+ "result": decision.result,
227
+ "reason": decision.reason,
228
+ }
229
+ )
230
+ return decision
231
+
232
+ def _wait_for_resume(self, request: PauseRequest) -> ResumeDecision:
233
+ deadline = time.monotonic() + request.timeout
234
+ command_file = self.commands_dir / f"{request.pause_id}.resume.json"
235
+
236
+ while time.monotonic() < deadline:
237
+ if command_file.exists():
238
+ payload = read_json_file(command_file)
239
+ try:
240
+ command_file.unlink()
241
+ except FileNotFoundError:
242
+ pass
243
+ failure_reason = payload.get("failure_reason")
244
+ result = payload.get("result")
245
+ reason = payload.get("reason")
246
+ return ResumeDecision(
247
+ outcome="resumed",
248
+ failure_reason=failure_reason if isinstance(failure_reason, str) else None,
249
+ result=result if isinstance(result, bool) or result is None else None,
250
+ reason=reason if isinstance(reason, str) else None,
251
+ )
252
+ time.sleep(0.1)
253
+
254
+ return ResumeDecision(outcome="timed_out")
255
+
256
+ def _emit(self, payload: dict[str, object]) -> None:
257
+ payload = {
258
+ "ts": time.time(),
259
+ **payload,
260
+ }
261
+ self.events_file.parent.mkdir(parents=True, exist_ok=True)
262
+ with self.events_file.open("a", encoding="utf-8") as handle:
263
+ handle.write(json.dumps(payload, ensure_ascii=True) + os.linesep)
264
+
265
+
266
+ def list_pauses(root_dir: Path, include_finished: bool = False, run_id: str | None = None) -> list[dict[str, object]]:
267
+ pause_files = sorted(_iter_pause_files(root_dir, run_id))
268
+ items: list[dict[str, object]] = []
269
+ for pause_file in pause_files:
270
+ payload = read_json_file(pause_file)
271
+ if run_id is not None and payload.get("run_id") != run_id:
272
+ continue
273
+ status = effective_pause_status(payload)
274
+ if not include_finished and status != "pending":
275
+ continue
276
+ payload["recorded_status"] = payload.get("status")
277
+ payload["status"] = status
278
+ payload["pause_file"] = str(pause_file)
279
+ items.append(payload)
280
+ items.sort(key=lambda item: (str(item.get("status")), pause_created_at(item)))
281
+ return items
282
+
283
+
284
+ def _iter_pause_files(root_dir: Path, run_id: str | None) -> list[Path]:
285
+ runs_dir = root_dir / "runs"
286
+ if run_id is not None:
287
+ return list((runs_dir / run_id / "pauses").glob("*.json"))
288
+ return list(runs_dir.glob("*/pauses/*.json"))
@@ -0,0 +1,204 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-live-pause
3
+ Version: 0.1.0
4
+ Summary: Pytest plugin and protocol for pausing live test execution and resuming in-process
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: pytest>=9.0.3
8
+ Description-Content-Type: text/markdown
9
+
10
+ ## pytest-live-pause
11
+
12
+ `pytest-live-pause` is intended to be a standalone pytest plugin and runtime protocol for pausing a live test process, inspecting state before teardown, and resuming the same process through a structured control channel.
13
+
14
+ The repository is currently in bootstrap stage. The design target is:
15
+
16
+ - a transport-neutral pause runtime
17
+ - a thin pytest plugin adapter
18
+ - structured stdout and JSONL events
19
+ - packaged CLI commands for monitoring and resuming paused runs
20
+ - a compatibility layer for existing QAMule-style workflows
21
+
22
+ The current design scope lives in `CURRENT_SCOPE.md`.
23
+
24
+ ## Current Status
25
+
26
+ The first working slice is `--pause-on-failure` using a file-backed control channel.
27
+
28
+ Checkpoint pauses are also supported through `pytest_live_pause.checkpoint(...)`.
29
+
30
+ The plugin also auto-registers `live_pause` and `pause_run_id` fixtures for direct injection into test function arguments.
31
+
32
+ When a test fails in the `call` phase and `--pause-on-failure` is enabled, the plugin:
33
+
34
+ - writes a pause request JSON file
35
+ - waits for an external resume command file or timeout
36
+ - resumes the same pytest process before teardown starts
37
+ - appends any externally supplied failure reason to the pytest report
38
+
39
+ Tests can also pause explicitly:
40
+
41
+ ```python
42
+ from pytest_live_pause import checkpoint
43
+
44
+
45
+ def test_example():
46
+ decision = checkpoint(task="Approve the external step", timeout=300)
47
+ if decision.result is False:
48
+ raise AssertionError(decision.reason or "checkpoint failed")
49
+ ```
50
+
51
+ Or through the auto-registered fixture:
52
+
53
+ ```python
54
+ def test_example(live_pause, pause_run_id):
55
+ assert live_pause.run_id == pause_run_id
56
+ decision = live_pause.checkpoint(task="Approve the external step", timeout=300)
57
+ assert decision.result is True
58
+ ```
59
+
60
+ Checkpoint resumes use `--result` and `--reason`:
61
+
62
+ ```bash
63
+ pytest-live-pause resume <pause_id> --result true --reason "approved"
64
+ ```
65
+
66
+ Checkpoint behavior can also be mocked during pytest startup:
67
+
68
+ ```bash
69
+ pytest --pause-checkpoint-mock false
70
+ ```
71
+
72
+ ## Usage
73
+
74
+ Run pytest with pause-on-failure enabled:
75
+
76
+ ```bash
77
+ pytest --pause-on-failure --pause-timeout 300
78
+ ```
79
+
80
+ By default, pause state is written under `/tmp/pytest-live-pause`.
81
+
82
+ You can still override it explicitly:
83
+
84
+ ```bash
85
+ pytest --pause-on-failure --pause-dir /some/other/path --pause-timeout 300
86
+ ```
87
+
88
+ When a test fails, a pause file appears under the run directory:
89
+
90
+ ```text
91
+
92
+ /tmp/pytest-live-pause/
93
+ runs/<run_id>/
94
+ events.jsonl
95
+ run.json
96
+ pauses/<pause_id>.json
97
+ commands/
98
+ ```
99
+
100
+ Resume the paused failure by pointing the CLI at the pause file:
101
+
102
+ ```bash
103
+ pytest-live-pause resume \
104
+ /tmp/pytest-live-pause/runs/<run_id>/pauses/<pause_id>.json \
105
+ --failure-reason "manually confirmed"
106
+ ```
107
+
108
+ You can also resume directly by `pause_id`, which is more convenient when multiple pytest processes share the same pause directory:
109
+
110
+ ```bash
111
+ pytest-live-pause resume <pause_id> --failure-reason "manually confirmed"
112
+ ```
113
+
114
+ If you are using a non-default pause directory, pass it explicitly:
115
+
116
+ ```bash
117
+ pytest-live-pause resume <pause_id> --pause-dir /some/other/path --failure-reason "manually confirmed"
118
+ ```
119
+
120
+ For failure pauses, `--failure-reason` appends an extra explanation to the pytest failure report. Non-failure pauses must not receive `--failure-reason`; the CLI will reject that combination.
121
+
122
+ For checkpoint pauses, use `--result true|false|none` and optional `--reason`.
123
+
124
+ For mocked checkpoint pauses, `reason` is fixed to `Mock`.
125
+
126
+ The CLI writes the matching command file into `commands/`. The pytest process polls for that command and then continues into teardown.
127
+
128
+ When multiple pytest processes share the same pause directory, each process gets its own `runs/<run_id>/` directory. `run.json` and each pause file also record `pid`, `hostname`, `cwd`, and optional `worker_id` metadata.
129
+
130
+ You can inspect currently pending pauses across all runs with:
131
+
132
+ ```bash
133
+ pytest-live-pause ls
134
+ ```
135
+
136
+ You can also watch for newly paused tests in real time:
137
+
138
+ ```bash
139
+ pytest-live-pause watch --run-id <run_id>
140
+ ```
141
+
142
+ `watch` requires a specific pytest `run_id`:
143
+
144
+ ```bash
145
+ pytest-live-pause watch --run-id <run_id>
146
+ ```
147
+
148
+ For bounded monitoring, pass a timeout:
149
+
150
+ ```bash
151
+ pytest-live-pause watch --run-id <run_id> --timeout 30
152
+ ```
153
+
154
+ `watch` waits until the latest matching pending pause appears, prints the pause details, and then exits.
155
+
156
+ By default, `ls` only shows currently resumable `pending` pauses. To include `stale`, `resumed`, and `timed_out` entries, use:
157
+
158
+ ```bash
159
+ pytest-live-pause ls -a
160
+ ```
161
+
162
+ If a pytest process is killed while paused, its pause file remains on disk. The CLI treats that as `stale` when the recorded `pid` is no longer alive on the current host. `ls -a` will show the stale state, and `resume` will refuse to write a command for it.
163
+
164
+ If no command arrives before `--pause-timeout`, the test resumes automatically without adding an external failure reason.
165
+
166
+ When pytest starts, it prints the current `run_id`, so you can monitor that specific pytest process with `pytest-live-pause watch --run-id <run_id>`.
167
+
168
+ ## File Protocol
169
+
170
+ `pauses/<pause_id>.json` currently contains:
171
+
172
+ ```json
173
+ {
174
+ "pause_id": "...",
175
+ "run_id": "...",
176
+ "nodeid": "tests/test_sample.py::test_failure",
177
+ "timeout": 300.0,
178
+ "original_failure": "AssertionError: boom",
179
+ "status": "pending",
180
+ "created_at": 1710000000.0,
181
+ "pid": 12345,
182
+ "hostname": "host.local",
183
+ "cwd": "/repo"
184
+ }
185
+ ```
186
+
187
+ `commands/<pause_id>.resume.json` currently contains:
188
+
189
+ ```json
190
+ {
191
+ "failure_reason": "manually confirmed"
192
+ }
193
+ ```
194
+
195
+ Or for a checkpoint pause:
196
+
197
+ ```json
198
+ {
199
+ "result": true,
200
+ "reason": "approved"
201
+ }
202
+ ```
203
+
204
+ This file transport is only the first implementation. The runtime can be split from the transport later if you want to add sockets or HTTP.
@@ -0,0 +1,9 @@
1
+ pytest_live_pause/__init__.py,sha256=VMyQiRUpYa23UsOnYA2zObHr52mVtFd3t9PmcU5sjHo,57
2
+ pytest_live_pause/cli.py,sha256=4LxEGPYxdg0_q_g0Ioq_RSnMVuQ-uVNTt7vJUohNicA,8404
3
+ pytest_live_pause/plugin.py,sha256=6p7RkfwNbMo_caeadlniJdTq2hL_ON8G_3cq80GJ_YM,5400
4
+ pytest_live_pause/runtime.py,sha256=RqC7Y1l7jdtQjTps24-boMuqAsaJ996jakoLDAoT-e0,9346
5
+ pytest_live_pause-0.1.0.dist-info/METADATA,sha256=Bhu6Ld0a0kBsEx92_hJj1vZnYi-cx9QudOHXaZkSmhE,5965
6
+ pytest_live_pause-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
7
+ pytest_live_pause-0.1.0.dist-info/entry_points.txt,sha256=wHzbD8y6F_ENS9qmZsPAO4m-VsEeSQfBnH0v00s1KG0,122
8
+ pytest_live_pause-0.1.0.dist-info/licenses/LICENSE,sha256=hYRaJ2ed2IMo9RmO96UxUSTt9iwL49leBY5kVtDymRo,1063
9
+ pytest_live_pause-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ pytest-live-pause = pytest_live_pause.cli:main
3
+
4
+ [pytest11]
5
+ pytest_live_pause = pytest_live_pause.plugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lanbao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.