tft-request-watch 0.36.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
+ """tft-request-watch — wait on Testing Farm requests with optional restart on retriable errors."""
2
+
3
+ __version__ = "0.36.0"
@@ -0,0 +1,8 @@
1
+ """python -m tft_request_watch."""
2
+
3
+ import sys
4
+
5
+ from tft_request_watch.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,42 @@
1
+ """HTTP helpers for Testing Farm request polling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import urljoin
6
+
7
+ import requests
8
+ from requests.adapters import HTTPAdapter
9
+ from urllib3.util import Retry
10
+
11
+
12
+ def build_session(http_retries: int, backoff_factor: float) -> requests.Session:
13
+ """Build a requests session for API polling with transient-error retries."""
14
+ session = requests.Session()
15
+ retry = Retry(
16
+ total=http_retries,
17
+ backoff_factor=backoff_factor,
18
+ status_forcelist=[502, 503, 504],
19
+ allowed_methods=frozenset({"GET"}),
20
+ )
21
+ adapter = HTTPAdapter(max_retries=retry)
22
+ session.mount("https://", adapter)
23
+ session.mount("http://", adapter)
24
+ return session
25
+
26
+
27
+ def fetch_request(
28
+ api_base: str,
29
+ request_id: str,
30
+ session: requests.Session,
31
+ request_timeout: float,
32
+ ) -> dict:
33
+ """GET ``{api_base}/requests/{request_id}`` and return JSON object."""
34
+ base = api_base.rstrip("/") + "/"
35
+ url = urljoin(base, f"requests/{request_id}")
36
+ response = session.get(url, timeout=request_timeout)
37
+ response.raise_for_status()
38
+ data = response.json()
39
+ if not isinstance(data, dict):
40
+ msg = f"unexpected JSON from API (expected object): {type(data).__name__}"
41
+ raise TypeError(msg)
42
+ return data
@@ -0,0 +1,206 @@
1
+ """CLI for tft-request-watch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+
9
+ from tft_request_watch import __version__
10
+ from tft_request_watch.merge_cmd import run_merge
11
+ from tft_request_watch.watch_core import WatchConfig
12
+ from tft_request_watch.watch_core import run_watch
13
+ from tft_request_watch.watch_core import watch_config_defaults
14
+
15
+ _DEFAULT_TESTING_FARM_API = "https://api.dev.testing-farm.io/v0.1"
16
+ _WD = watch_config_defaults()
17
+
18
+
19
+ # ── watch subcommand ──────────────────────────────────────────────────────────
20
+
21
+
22
+ def _add_watch_parser(subparsers: argparse._SubParsersAction) -> None:
23
+ p = subparsers.add_parser(
24
+ "watch",
25
+ help="Watch a Testing Farm request and restart on retriable failures.",
26
+ description=(
27
+ "Wait for a Testing Farm request to finish. Restarts the request when "
28
+ "the API reports retriable infrastructure errors, or when a required test "
29
+ "task is aborted."
30
+ ),
31
+ )
32
+ p.add_argument(
33
+ "--api-url",
34
+ default=_DEFAULT_TESTING_FARM_API,
35
+ help=f"Testing Farm API base URL (default: {_DEFAULT_TESTING_FARM_API})",
36
+ )
37
+ p.add_argument(
38
+ "--request-id",
39
+ default="",
40
+ help="Request UUID to watch. If empty, exit successfully without doing anything.",
41
+ )
42
+ p.add_argument(
43
+ "--dry-run",
44
+ action="store_true",
45
+ help="Query and watch the request but skip write operations (restart, cancel). "
46
+ "Logs what would have been done and exits successfully.",
47
+ )
48
+ p.add_argument(
49
+ "--hard-timeout-hours",
50
+ type=int,
51
+ default=_WD["hard_timeout_hours"],
52
+ metavar="H",
53
+ help=(
54
+ f"Cancel the request via testing-farm after H hours"
55
+ f" (default: {_WD['hard_timeout_hours']})."
56
+ ),
57
+ )
58
+ p.add_argument(
59
+ "--max-restarts",
60
+ type=int,
61
+ default=_WD["max_restarts"],
62
+ metavar="N",
63
+ help=f"Maximum retriable-error handling iterations (default: {_WD['max_restarts']}).",
64
+ )
65
+ p.add_argument(
66
+ "--restart-sleep-seconds",
67
+ type=int,
68
+ default=_WD["restart_sleep_seconds"],
69
+ metavar="S",
70
+ help=(
71
+ f"Seconds to sleep before testing-farm restart"
72
+ f" (default: {_WD['restart_sleep_seconds']})."
73
+ ),
74
+ )
75
+ p.add_argument(
76
+ "--http-timeout",
77
+ type=float,
78
+ default=_WD["http_timeout"],
79
+ metavar="SEC",
80
+ help=(
81
+ f"Per-call HTTP timeout in seconds when polling the Testing Farm API"
82
+ f" (default: {_WD['http_timeout']})."
83
+ ),
84
+ )
85
+ p.add_argument(
86
+ "--http-retries",
87
+ type=int,
88
+ default=_WD["http_retries"],
89
+ metavar="N",
90
+ help=f"urllib3 retry count for HTTP GET (default: {_WD['http_retries']}).",
91
+ )
92
+ p.add_argument(
93
+ "--http-backoff-factor",
94
+ type=float,
95
+ default=_WD["http_backoff_factor"],
96
+ metavar="F",
97
+ help=f"Backoff factor for HTTP retries (default: {_WD['http_backoff_factor']}).",
98
+ )
99
+ p.add_argument(
100
+ "--result-file",
101
+ metavar="PATH",
102
+ default=None,
103
+ help="Write a JSON list of per-run results to PATH (accumulated across restarts).",
104
+ )
105
+ p.add_argument(
106
+ "--required-task",
107
+ dest="required_tasks",
108
+ action="append",
109
+ default=[],
110
+ metavar="TASK",
111
+ help=(
112
+ "Restart the affected plan(s) if this test is aborted (result=error) in the"
113
+ " XUnit results. Use the test path without the discover alias prefix"
114
+ " (e.g. /distribution/install). Can be specified multiple times."
115
+ ),
116
+ )
117
+ p.set_defaults(func=_cmd_watch)
118
+
119
+
120
+ def _cmd_watch(ns: argparse.Namespace) -> int:
121
+ cfg = WatchConfig(
122
+ api_url=ns.api_url,
123
+ request_id=ns.request_id,
124
+ dry_run=ns.dry_run,
125
+ hard_timeout_hours=ns.hard_timeout_hours,
126
+ max_restarts=ns.max_restarts,
127
+ restart_sleep_seconds=ns.restart_sleep_seconds,
128
+ http_timeout=ns.http_timeout,
129
+ http_retries=ns.http_retries,
130
+ http_backoff_factor=ns.http_backoff_factor,
131
+ result_file=ns.result_file,
132
+ required_tasks=ns.required_tasks,
133
+ )
134
+ return run_watch(cfg)
135
+
136
+
137
+ # ── merge subcommand ──────────────────────────────────────────────────────────
138
+
139
+
140
+ def _add_merge_parser(subparsers: argparse._SubParsersAction) -> None:
141
+ p = subparsers.add_parser(
142
+ "merge",
143
+ help="Merge XUnit XML results from a tft-request-watch result JSON file.",
144
+ description=(
145
+ "Fetch the XUnit XML for every run recorded in RESULT_JSON and produce a single "
146
+ "merged XML at OUTPUT. Testsuites from restarted plans (annotated via "
147
+ "restarted_plans in the JSON) are replaced with the retry results; all other "
148
+ "testsuites are kept from the original run."
149
+ ),
150
+ )
151
+ p.add_argument(
152
+ "result_json",
153
+ metavar="RESULT_JSON",
154
+ help="Path to the tft-request-watch result JSON file.",
155
+ )
156
+ p.add_argument("output", metavar="OUTPUT", help="Path to write the merged XUnit XML.")
157
+ p.add_argument(
158
+ "--timeout",
159
+ type=float,
160
+ default=120.0,
161
+ metavar="SECONDS",
162
+ help="HTTP request timeout for fetching XUnit URLs (default: 120).",
163
+ )
164
+ p.set_defaults(func=_cmd_merge)
165
+
166
+
167
+ def _cmd_merge(ns: argparse.Namespace) -> int:
168
+ return run_merge(ns.result_json, ns.output, ns.timeout)
169
+
170
+
171
+ # ── entry point ───────────────────────────────────────────────────────────────
172
+
173
+
174
+ def main(args: list[str] | None = None) -> int:
175
+ """Entry point for console script."""
176
+ parser = argparse.ArgumentParser(
177
+ prog="tft-request-watch",
178
+ description="Testing Farm request watcher and XUnit result merger.",
179
+ )
180
+ parser.add_argument(
181
+ "--debug",
182
+ action="store_true",
183
+ help="Enable DEBUG logging on stderr.",
184
+ )
185
+ parser.add_argument(
186
+ "--version",
187
+ action="version",
188
+ version=f"%(prog)s {__version__}",
189
+ )
190
+ subparsers = parser.add_subparsers(dest="command", required=True)
191
+ _add_watch_parser(subparsers)
192
+ _add_merge_parser(subparsers)
193
+
194
+ ns = parser.parse_args(args=args)
195
+ logging.basicConfig(
196
+ level=logging.DEBUG if ns.debug else logging.INFO,
197
+ format="%(levelname)s: %(message)s",
198
+ stream=sys.stderr,
199
+ force=True,
200
+ )
201
+ logging.getLogger(__name__).info("tft-request-watch v%s", __version__)
202
+ return ns.func(ns)
203
+
204
+
205
+ if __name__ == "__main__":
206
+ sys.exit(main())
@@ -0,0 +1,166 @@
1
+ """Merge XUnit XML results from a tft-request-watch JSON result file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+ from xml.etree import ElementTree as ET
10
+
11
+ from tft_request_watch.api_client import build_session
12
+ from tft_request_watch.watch_core import fetch_xunit_xml
13
+ from tft_request_watch.watch_core import get_testsuite_arch
14
+
15
+ if TYPE_CHECKING:
16
+ import requests
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def merge_xunit(
22
+ base_xml: str,
23
+ retry_xml: str,
24
+ restarted_plans: dict[str, list[str]],
25
+ ) -> str:
26
+ """Merge *retry_xml* into *base_xml*, replacing ``(arch, plan_name)`` testsuites.
27
+
28
+ Testsuites listed in *restarted_plans* are removed from *base_xml* and replaced
29
+ by the matching testsuites from *retry_xml*. All other testsuites are kept
30
+ unchanged. The ``overall-result`` attribute on the root element is recomputed
31
+ from the merged set of testsuite results.
32
+
33
+ If either XML cannot be parsed the original *base_xml* is returned unchanged.
34
+ """
35
+ try:
36
+ base_root = ET.fromstring(base_xml) # noqa: S314
37
+ retry_root = ET.fromstring(retry_xml) # noqa: S314
38
+ except ET.ParseError:
39
+ logger.exception("Could not parse XUnit XML for merge")
40
+ return base_xml
41
+
42
+ to_replace: set[tuple[str, str]] = {
43
+ (arch, plan) for arch, plans in restarted_plans.items() for plan in plans
44
+ }
45
+
46
+ # Build lookup of retry testsuites for the pairs we need to replace
47
+ retry_lookup: dict[tuple[str, str], ET.Element] = {}
48
+ for ts in retry_root.findall("testsuite"):
49
+ key = (get_testsuite_arch(ts), ts.get("name", ""))
50
+ if key in to_replace:
51
+ retry_lookup[key] = ts
52
+
53
+ # Remove matched testsuites from base, then append replacements from retry
54
+ for ts in list(base_root.findall("testsuite")):
55
+ if (get_testsuite_arch(ts), ts.get("name", "")) in to_replace:
56
+ base_root.remove(ts)
57
+ for ts in retry_lookup.values():
58
+ base_root.append(ts)
59
+
60
+ # Recompute overall-result
61
+ suite_results = {ts.get("result", "") for ts in base_root.findall("testsuite")}
62
+ if "error" in suite_results:
63
+ overall = "error"
64
+ elif "failed" in suite_results:
65
+ overall = "failed"
66
+ elif suite_results <= {"passed"}:
67
+ overall = "passed"
68
+ else:
69
+ overall = "unknown"
70
+ base_root.set("overall-result", overall)
71
+
72
+ return '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(
73
+ base_root,
74
+ encoding="unicode",
75
+ )
76
+
77
+
78
+ def _fetch_all_xml(
79
+ entries: list[dict],
80
+ session: requests.Session,
81
+ timeout: float,
82
+ ) -> list[str | None]:
83
+ """Fetch XUnit XML for every entry that has a ``xunit_url``. Returns one item per entry."""
84
+ xmls: list[str | None] = []
85
+ for i, entry in enumerate(entries):
86
+ url = entry.get("xunit_url") or (entry.get("result") or {}).get("xunit_url", "")
87
+ if not url:
88
+ logger.warning("Entry %d (id=%s) has no xunit_url; skipping.", i, entry.get("id"))
89
+ xmls.append(None)
90
+ else:
91
+ xml = fetch_xunit_xml(url, session, timeout)
92
+ if xml is None:
93
+ logger.error("Failed to fetch XUnit for entry %d (id=%s).", i, entry.get("id"))
94
+ xmls.append(xml)
95
+ return xmls
96
+
97
+
98
+ def merge_results(entries: list[dict], xmls: list[str | None]) -> str | None:
99
+ """Merge XUnit XMLs according to the ``restarted_plans`` annotations in *entries*.
100
+
101
+ Returns the merged XML string, or ``None`` if no usable XML was found.
102
+ """
103
+ base: str | None = None
104
+ base_idx: int = -1
105
+ for i, xml in enumerate(xmls):
106
+ if xml is not None:
107
+ base = xml
108
+ base_idx = i
109
+ break
110
+ if base is None:
111
+ logger.error("No XUnit XML could be fetched; cannot produce merged output.")
112
+ return None
113
+
114
+ for i in range(base_idx + 1, len(entries)):
115
+ retry_xml = xmls[i]
116
+ if retry_xml is None:
117
+ logger.warning(
118
+ "Skipping entry %d (id=%s): no XUnit available.",
119
+ i,
120
+ entries[i].get("id"),
121
+ )
122
+ continue
123
+ restarted_plans: dict[str, list[str]] = entries[i].get("restarted_plans") or {}
124
+ if not restarted_plans:
125
+ # No annotation → full restart, replace everything
126
+ logger.info(
127
+ "Entry %d (id=%s) has no restarted_plans; replacing entire base with this run.",
128
+ i,
129
+ entries[i].get("id"),
130
+ )
131
+ base = retry_xml
132
+ else:
133
+ base = merge_xunit(base, retry_xml, restarted_plans)
134
+
135
+ return base
136
+
137
+
138
+ def run_merge(result_json: str, output: str, timeout: float = 120.0) -> int:
139
+ """Read *result_json*, fetch XUnit URLs, merge, and write to *output*. Returns exit code."""
140
+ try:
141
+ entries = json.loads(Path(result_json).read_text(encoding="utf-8"))
142
+ except (OSError, ValueError):
143
+ logger.exception("Could not read result JSON %s", result_json)
144
+ return 1
145
+
146
+ if not isinstance(entries, list):
147
+ logger.error("Result JSON must be a list of entries; got %s.", type(entries).__name__)
148
+ return 1
149
+ if not entries:
150
+ logger.error("Result JSON is empty; nothing to merge.")
151
+ return 1
152
+
153
+ session = build_session(http_retries=3, backoff_factor=0.5)
154
+ xmls = _fetch_all_xml(entries, session, timeout)
155
+ merged = merge_results(entries, xmls)
156
+ if merged is None:
157
+ return 1
158
+
159
+ try:
160
+ Path(output).write_text(merged, encoding="utf-8")
161
+ logger.info("Wrote merged XUnit: %s", output)
162
+ except OSError:
163
+ logger.exception("Could not write %s", output)
164
+ return 1
165
+
166
+ return 0