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.
- pytest_live_pause/__init__.py +3 -0
- pytest_live_pause/cli.py +234 -0
- pytest_live_pause/plugin.py +175 -0
- pytest_live_pause/runtime.py +288 -0
- pytest_live_pause-0.1.0.dist-info/METADATA +204 -0
- pytest_live_pause-0.1.0.dist-info/RECORD +9 -0
- pytest_live_pause-0.1.0.dist-info/WHEEL +4 -0
- pytest_live_pause-0.1.0.dist-info/entry_points.txt +5 -0
- pytest_live_pause-0.1.0.dist-info/licenses/LICENSE +21 -0
pytest_live_pause/cli.py
ADDED
|
@@ -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,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.
|