robotcode-runner 2.7.0__py3-none-any.whl → 2.8.0.dev8__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.
- robotcode/runner/__version__.py +1 -1
- robotcode/runner/cli/_markdown.py +13 -0
- robotcode/runner/cli/_robot_version.py +28 -0
- robotcode/runner/cli/_search.py +37 -1
- robotcode/runner/cli/discover/_models.py +11 -1
- robotcode/runner/cli/discover/_render.py +108 -2
- robotcode/runner/cli/discover/discover.py +267 -40
- robotcode/runner/cli/libdoc.py +7 -5
- robotcode/runner/cli/rebot.py +32 -5
- robotcode/runner/cli/results/_html.py +228 -64
- robotcode/runner/cli/results/_models.py +4 -0
- robotcode/runner/cli/results/_render.py +9 -1
- robotcode/runner/cli/results/results.py +241 -48
- robotcode/runner/cli/robot.py +145 -27
- robotcode/runner/cli/testdoc.py +7 -4
- {robotcode_runner-2.7.0.dist-info → robotcode_runner-2.8.0.dev8.dist-info}/METADATA +8 -7
- robotcode_runner-2.8.0.dev8.dist-info/RECORD +25 -0
- {robotcode_runner-2.7.0.dist-info → robotcode_runner-2.8.0.dev8.dist-info}/WHEEL +1 -1
- robotcode_runner-2.7.0.dist-info/RECORD +0 -24
- {robotcode_runner-2.7.0.dist-info → robotcode_runner-2.8.0.dev8.dist-info}/entry_points.txt +0 -0
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
"""HTML to Markdown conversion for Robot Framework log messages.
|
|
2
2
|
|
|
3
3
|
Two-stage strategy:
|
|
4
|
-
1. If `html-to-markdown` (optional dep) is installed, use it —
|
|
5
|
-
handles base64 image extraction natively.
|
|
4
|
+
1. If `html-to-markdown` (optional dep) 3.18 or newer is installed, use it —
|
|
5
|
+
better quality, handles base64 image extraction natively. Older versions,
|
|
6
|
+
and versions that cannot be read, count as not installed: up to 3.17 the
|
|
7
|
+
converter drops everything after the `</td></tr><tr><td colspan="3">` that
|
|
8
|
+
SeleniumLibrary and Browser put in front of their screenshots, and up to
|
|
9
|
+
3.1 it also has another API.
|
|
6
10
|
2. Otherwise fall back to a minimal stdlib `html.parser`-based converter for
|
|
7
11
|
the small HTML subset Robot Framework actually emits (Selenium screenshots,
|
|
8
12
|
bold/italic, links, lists, simple tables).
|
|
9
13
|
|
|
10
14
|
In both paths, file-system references (relative `<img src="x.png">` or
|
|
11
|
-
`<a href="x"
|
|
12
|
-
emitting absolute `file://` URLs for clickable terminal
|
|
15
|
+
`<a href="x">`, and `file:` URLs) are resolved against `base_dir` and checked
|
|
16
|
+
for path-traversal, emitting absolute `file://` URLs for clickable terminal
|
|
17
|
+
links (OSC 8).
|
|
13
18
|
|
|
14
19
|
Embedded data: URIs are recorded as ArtifactRefs (not extracted) unless the
|
|
15
20
|
caller decides to extract via the artefact list afterwards.
|
|
@@ -22,25 +27,32 @@ import weakref
|
|
|
22
27
|
from html.parser import HTMLParser
|
|
23
28
|
from pathlib import Path
|
|
24
29
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
25
|
-
from urllib.parse import unquote, urlparse
|
|
30
|
+
from urllib.parse import unquote, urlparse, urlsplit
|
|
31
|
+
from urllib.request import url2pathname
|
|
32
|
+
|
|
33
|
+
from robotcode.core.utils.version import InvalidVersionError, create_version_from_str
|
|
26
34
|
|
|
27
35
|
from ._models import ArtifactRef
|
|
28
36
|
|
|
29
37
|
try:
|
|
30
38
|
from html_to_markdown import ConversionOptions as _ExtOpts
|
|
39
|
+
from html_to_markdown import __version__ as _ext_version
|
|
31
40
|
from html_to_markdown import convert as _ext_convert
|
|
32
41
|
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
_ext_ver = create_version_from_str(_ext_version)
|
|
43
|
+
_HAS_EXT = (_ext_ver.major, _ext_ver.minor) >= (3, 18)
|
|
44
|
+
except (ImportError, InvalidVersionError):
|
|
35
45
|
_HAS_EXT = False
|
|
36
46
|
|
|
37
47
|
|
|
38
|
-
# `bytes` can't be weakref'd, so wrap them
|
|
48
|
+
# `bytes` can't be weakref'd, so wrap them, together with the `download` name
|
|
49
|
+
# of the link, which only matters for extraction.
|
|
39
50
|
class _DataBox:
|
|
40
|
-
__slots__ = ("__weakref__", "data")
|
|
51
|
+
__slots__ = ("__weakref__", "data", "name")
|
|
41
52
|
|
|
42
|
-
def __init__(self, data: bytes) -> None:
|
|
53
|
+
def __init__(self, data: bytes, name: Optional[str]) -> None:
|
|
43
54
|
self.data = data
|
|
55
|
+
self.name = name
|
|
44
56
|
|
|
45
57
|
|
|
46
58
|
# Out-of-band store keeps decoded payloads off the serialisable dataclass and
|
|
@@ -48,8 +60,8 @@ class _DataBox:
|
|
|
48
60
|
_EMBEDDED_DATA: "weakref.WeakKeyDictionary[ArtifactRef, _DataBox]" = weakref.WeakKeyDictionary()
|
|
49
61
|
|
|
50
62
|
|
|
51
|
-
def _store_embedded_data(ref: ArtifactRef, data: bytes) -> None:
|
|
52
|
-
_EMBEDDED_DATA[ref] = _DataBox(data)
|
|
63
|
+
def _store_embedded_data(ref: ArtifactRef, data: bytes, name: Optional[str]) -> None:
|
|
64
|
+
_EMBEDDED_DATA[ref] = _DataBox(data, name)
|
|
53
65
|
|
|
54
66
|
|
|
55
67
|
def get_embedded_data(ref: ArtifactRef) -> Optional[bytes]:
|
|
@@ -62,6 +74,17 @@ def get_embedded_data(ref: ArtifactRef) -> Optional[bytes]:
|
|
|
62
74
|
return box.data if box is not None else None
|
|
63
75
|
|
|
64
76
|
|
|
77
|
+
def get_embedded_name(ref: ArtifactRef) -> Optional[str]:
|
|
78
|
+
"""Return the `download` attribute of an embedded artefact's link, if any."""
|
|
79
|
+
box = _EMBEDDED_DATA.get(ref)
|
|
80
|
+
return box.name if box is not None else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def artefact_key(ref: ArtifactRef) -> Tuple[Any, ...]:
|
|
84
|
+
"""Return the key by which references to the same target are merged."""
|
|
85
|
+
return (ref.resolved_path or ref.src, ref.embedded, ref.skipped_reason)
|
|
86
|
+
|
|
87
|
+
|
|
65
88
|
def dedup_artefacts(refs: List[ArtifactRef]) -> List[ArtifactRef]:
|
|
66
89
|
"""Merge refs pointing to the same target.
|
|
67
90
|
|
|
@@ -73,7 +96,7 @@ def dedup_artefacts(refs: List[ArtifactRef]) -> List[ArtifactRef]:
|
|
|
73
96
|
seen: Dict[Tuple[Any, ...], int] = {}
|
|
74
97
|
out: List[ArtifactRef] = []
|
|
75
98
|
for ref in refs:
|
|
76
|
-
key
|
|
99
|
+
key = artefact_key(ref)
|
|
77
100
|
idx = seen.get(key)
|
|
78
101
|
if idx is None:
|
|
79
102
|
seen[key] = len(out)
|
|
@@ -89,8 +112,20 @@ _DATA_URI_RE = re.compile(
|
|
|
89
112
|
r"^data:(?P<mime>[^;,]+)?(?:;(?P<params>[^,]*))?,(?P<payload>.*)$",
|
|
90
113
|
re.IGNORECASE | re.DOTALL,
|
|
91
114
|
)
|
|
92
|
-
|
|
93
|
-
|
|
115
|
+
# A Markdown link target, in angle brackets (as html-to-markdown writes one with
|
|
116
|
+
# a space) or up to the next space or `)`, and an optional title.
|
|
117
|
+
_MD_DEST = r"(?:<[^>\n]*>|[^)\s]+)"
|
|
118
|
+
# A backslash escape of CommonMark, which html-to-markdown writes for `\`, `<` and
|
|
119
|
+
# `>` in a target in angle brackets.
|
|
120
|
+
_MD_ESCAPE_RE = re.compile(r"\\([!-/:-@\[-`{-~])")
|
|
121
|
+
_MD_TITLE = r'(?:\s+"[^"]*")?'
|
|
122
|
+
_MD_IMG_RE = re.compile(rf"!\[(?P<alt>[^\]]*)\]\((?P<src>{_MD_DEST}){_MD_TITLE}\)")
|
|
123
|
+
# Links, whose text may hold images, and stand-alone images in one pass, so that
|
|
124
|
+
# every target is resolved once.
|
|
125
|
+
_MD_REF_RE = re.compile(
|
|
126
|
+
rf"(?<!!)\[(?P<text>(?:!\[[^\]]*\]\({_MD_DEST}{_MD_TITLE}\)|[^\]])*)\]\((?P<href>{_MD_DEST}){_MD_TITLE}\)"
|
|
127
|
+
rf"|{_MD_IMG_RE.pattern}"
|
|
128
|
+
)
|
|
94
129
|
_TAG_STRIP_RE = re.compile(r"<[^>]+>")
|
|
95
130
|
|
|
96
131
|
|
|
@@ -146,47 +181,117 @@ def _is_external_url(href: str) -> bool:
|
|
|
146
181
|
return bool(parsed.scheme) and parsed.scheme not in ("file",)
|
|
147
182
|
|
|
148
183
|
|
|
149
|
-
def
|
|
150
|
-
"""Return
|
|
151
|
-
|
|
152
|
-
|
|
184
|
+
def _md_url(url: str) -> str:
|
|
185
|
+
"""Return `url` as a Markdown link destination, in angle brackets when it contains whitespace."""
|
|
186
|
+
return f"<{url}>" if any(c.isspace() for c in url) else url
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# OperatingSystem links a path on Windows as `file://C:\dir\x`, which puts the
|
|
190
|
+
# drive into the host of the URL.
|
|
191
|
+
_DRIVE_HOST_RE = re.compile(r"[A-Za-z]:")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _file_url_paths(href: str) -> Optional[Tuple[str, str]]:
|
|
195
|
+
"""Return the path a `file:` URL names, decoded and as written, or None
|
|
196
|
+
when the URL names another host."""
|
|
197
|
+
parts = urlsplit(href)
|
|
198
|
+
drive_host = _DRIVE_HOST_RE.match(parts.netloc) is not None
|
|
199
|
+
if parts.netloc and not drive_host and parts.netloc.lower() != "localhost":
|
|
200
|
+
return None
|
|
201
|
+
raw = href[len("file:") :]
|
|
202
|
+
if raw.startswith("//"):
|
|
203
|
+
raw = raw[2:] if drive_host else raw[2 + len(parts.netloc) :]
|
|
204
|
+
if drive_host:
|
|
205
|
+
return unquote(parts.netloc + parts.path), raw
|
|
206
|
+
return url2pathname(parts.path), raw
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _exists(path: Path) -> bool:
|
|
153
210
|
try:
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
211
|
+
return path.exists()
|
|
212
|
+
except (OSError, ValueError):
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _is_dir(path: Path) -> bool:
|
|
158
217
|
try:
|
|
159
|
-
|
|
160
|
-
except ValueError:
|
|
161
|
-
return
|
|
162
|
-
|
|
218
|
+
return path.is_dir()
|
|
219
|
+
except (OSError, ValueError):
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _local_path(href: str, base_dir: Path) -> Optional[Tuple[Path, bool]]:
|
|
224
|
+
"""Return the path a file-system reference names and whether it is a
|
|
225
|
+
`file:` URL, or None for a `file:` URL of another host.
|
|
226
|
+
|
|
227
|
+
Percent escapes are decoded and query and fragment dropped. OperatingSystem
|
|
228
|
+
links its paths without encoding them, so when the decoded path does not
|
|
229
|
+
exist but the reference as written does, that one is taken.
|
|
230
|
+
"""
|
|
231
|
+
is_file_url = urlsplit(href).scheme == "file"
|
|
232
|
+
if is_file_url:
|
|
233
|
+
paths = _file_url_paths(href)
|
|
234
|
+
if paths is None:
|
|
235
|
+
return None
|
|
236
|
+
decoded, raw = paths
|
|
237
|
+
else:
|
|
238
|
+
decoded, raw = unquote(urlsplit(href).path), href
|
|
239
|
+
path = base_dir / decoded
|
|
240
|
+
if not _exists(path) and _exists(base_dir / raw):
|
|
241
|
+
path = base_dir / raw
|
|
242
|
+
return path, is_file_url
|
|
163
243
|
|
|
164
244
|
|
|
165
245
|
def _make_external_artifact(
|
|
166
246
|
href: str, kind: str, base_dir: Path, on_artifact: Callable[[ArtifactRef], None]
|
|
167
247
|
) -> Optional[str]:
|
|
168
|
-
"""Handle a non-data href. Returns
|
|
169
|
-
|
|
170
|
-
|
|
248
|
+
"""Handle a non-data href. Returns the URL for the message: the `file://`
|
|
249
|
+
URL of the referenced file, the href itself, or None when the reference
|
|
250
|
+
can't be followed. Records an ArtifactRef for a file under `base_dir` and
|
|
251
|
+
for a relative reference that leaves it.
|
|
252
|
+
|
|
253
|
+
A `file:` URL outside `base_dir` or of another host keeps its href and is
|
|
254
|
+
no artefact, and neither is a link to a folder."""
|
|
171
255
|
if _is_external_url(href):
|
|
172
256
|
return href
|
|
173
257
|
|
|
174
|
-
resolved, skipped = _resolve_under(base_dir, href)
|
|
175
258
|
ref = ArtifactRef(kind=kind, src=href)
|
|
176
|
-
if
|
|
177
|
-
ref.skipped_reason =
|
|
259
|
+
if not href or href.startswith(("#", "?")):
|
|
260
|
+
ref.skipped_reason = "empty-or-fragment"
|
|
178
261
|
on_artifact(ref)
|
|
179
262
|
return None
|
|
180
|
-
ref.resolved_path = str(resolved)
|
|
181
263
|
try:
|
|
182
|
-
|
|
264
|
+
local = _local_path(href, base_dir)
|
|
265
|
+
if local is None:
|
|
266
|
+
return href
|
|
267
|
+
path, is_file_url = local
|
|
268
|
+
resolved = path.resolve()
|
|
269
|
+
base_resolved = base_dir.resolve()
|
|
270
|
+
except (OSError, RuntimeError, ValueError):
|
|
271
|
+
ref.skipped_reason = "resolve-failed"
|
|
272
|
+
on_artifact(ref)
|
|
273
|
+
return None
|
|
274
|
+
try:
|
|
275
|
+
rel: Optional[Path] = resolved.relative_to(base_resolved)
|
|
183
276
|
except ValueError:
|
|
184
|
-
|
|
277
|
+
rel = None
|
|
278
|
+
if rel is None and is_file_url:
|
|
279
|
+
return href
|
|
280
|
+
if _is_dir(resolved):
|
|
281
|
+
return resolved.as_uri()
|
|
282
|
+
if rel is None:
|
|
283
|
+
ref.skipped_reason = "path-traversal"
|
|
284
|
+
on_artifact(ref)
|
|
285
|
+
return None
|
|
286
|
+
ref.resolved_path = str(resolved)
|
|
287
|
+
ref.rel_path = str(rel)
|
|
185
288
|
on_artifact(ref)
|
|
186
289
|
return resolved.as_uri()
|
|
187
290
|
|
|
188
291
|
|
|
189
|
-
def _make_embedded_artifact(
|
|
292
|
+
def _make_embedded_artifact(
|
|
293
|
+
uri: str, kind: str, on_artifact: Callable[[ArtifactRef], None], name: Optional[str] = None
|
|
294
|
+
) -> ArtifactRef:
|
|
190
295
|
ref = ArtifactRef(kind=kind, src=uri, embedded=True)
|
|
191
296
|
parsed = _parse_data_uri(uri)
|
|
192
297
|
if parsed is None:
|
|
@@ -196,7 +301,7 @@ def _make_embedded_artifact(uri: str, kind: str, on_artifact: Callable[[Artifact
|
|
|
196
301
|
mime, data = parsed
|
|
197
302
|
ref.media_type = mime
|
|
198
303
|
ref.approx_bytes = len(data)
|
|
199
|
-
_store_embedded_data(ref, data)
|
|
304
|
+
_store_embedded_data(ref, data, name)
|
|
200
305
|
on_artifact(ref)
|
|
201
306
|
return ref
|
|
202
307
|
|
|
@@ -207,6 +312,27 @@ def _make_embedded_artifact(uri: str, kind: str, on_artifact: Callable[[Artifact
|
|
|
207
312
|
|
|
208
313
|
if _HAS_EXT:
|
|
209
314
|
|
|
315
|
+
class _DownloadNames(HTMLParser):
|
|
316
|
+
"""Collects the `download` attributes of `data:` links, which html-to-markdown drops."""
|
|
317
|
+
|
|
318
|
+
def __init__(self) -> None:
|
|
319
|
+
super().__init__(convert_charrefs=True)
|
|
320
|
+
self.names: Dict[str, str] = {}
|
|
321
|
+
|
|
322
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
|
|
323
|
+
a = dict(attrs)
|
|
324
|
+
href, name = a.get("href"), a.get("download")
|
|
325
|
+
if tag == "a" and href and name and href.startswith("data:"):
|
|
326
|
+
self.names.setdefault(href, name)
|
|
327
|
+
|
|
328
|
+
def _download_names(text: str) -> Dict[str, str]:
|
|
329
|
+
if "download" not in text:
|
|
330
|
+
return {}
|
|
331
|
+
parser = _DownloadNames()
|
|
332
|
+
parser.feed(text)
|
|
333
|
+
parser.close()
|
|
334
|
+
return parser.names
|
|
335
|
+
|
|
210
336
|
def _convert(
|
|
211
337
|
text: str,
|
|
212
338
|
*,
|
|
@@ -236,7 +362,13 @@ if _HAS_EXT:
|
|
|
236
362
|
embedded_urls.add(url)
|
|
237
363
|
_make_embedded_artifact(url, "image", on_artifact)
|
|
238
364
|
|
|
239
|
-
md = _post_process_refs(
|
|
365
|
+
md = _post_process_refs(
|
|
366
|
+
md,
|
|
367
|
+
base_dir=base_dir,
|
|
368
|
+
on_artifact=on_artifact,
|
|
369
|
+
already_recorded=embedded_urls,
|
|
370
|
+
download_names=_download_names(text),
|
|
371
|
+
)
|
|
240
372
|
return md.strip()
|
|
241
373
|
|
|
242
374
|
def _post_process_refs(
|
|
@@ -245,14 +377,18 @@ if _HAS_EXT:
|
|
|
245
377
|
base_dir: Path,
|
|
246
378
|
on_artifact: Callable[[ArtifactRef], None],
|
|
247
379
|
already_recorded: set[str],
|
|
380
|
+
download_names: Dict[str, str],
|
|
248
381
|
) -> str:
|
|
249
382
|
"""Replace relative file refs in markdown with absolute file:// URLs, and
|
|
250
383
|
drop or annotate embedded data URIs in the rendered output (the binary
|
|
251
384
|
payload is recorded via on_artifact instead)."""
|
|
252
385
|
|
|
386
|
+
def _target(dest: str) -> str:
|
|
387
|
+
return _MD_ESCAPE_RE.sub(r"\1", dest[1:-1]) if dest.startswith("<") else dest
|
|
388
|
+
|
|
253
389
|
def _replace_img(m: re.Match[str]) -> str:
|
|
254
390
|
alt = m.group("alt")
|
|
255
|
-
src = m.group("src")
|
|
391
|
+
src = _target(m.group("src"))
|
|
256
392
|
if src.startswith("data:"):
|
|
257
393
|
if src not in already_recorded:
|
|
258
394
|
_make_embedded_artifact(src, "image", on_artifact)
|
|
@@ -261,23 +397,26 @@ if _HAS_EXT:
|
|
|
261
397
|
if _is_external_url(src):
|
|
262
398
|
return m.group(0)
|
|
263
399
|
new = _make_external_artifact(src, "image", base_dir, on_artifact)
|
|
264
|
-
return f"" if new else f"_(missing image: {src})_"
|
|
400
|
+
return f"})" if new else f"_(missing image: {src})_"
|
|
265
401
|
|
|
266
|
-
def
|
|
267
|
-
|
|
268
|
-
|
|
402
|
+
def _replace_ref(m: re.Match[str]) -> str:
|
|
403
|
+
if m.group("href") is None:
|
|
404
|
+
return _replace_img(m)
|
|
405
|
+
text = _MD_IMG_RE.sub(_replace_img, m.group("text"))
|
|
406
|
+
href = _target(m.group("href"))
|
|
269
407
|
if href.startswith("data:"):
|
|
270
408
|
if href not in already_recorded:
|
|
271
|
-
_make_embedded_artifact(href, "file", on_artifact)
|
|
409
|
+
_make_embedded_artifact(href, "file", on_artifact, download_names.get(href))
|
|
272
410
|
already_recorded.add(href)
|
|
273
|
-
|
|
411
|
+
# html-to-markdown writes the URI as the text of a link with only spaces in it.
|
|
412
|
+
return f"_(embedded {text if text and text != href else 'data'} — see artefacts)_"
|
|
274
413
|
if _is_external_url(href):
|
|
275
|
-
|
|
414
|
+
# The target stays, the images in the text may have been replaced.
|
|
415
|
+
return f"[{text}]" + m.group(0)[len(m.group("text")) + 2 :]
|
|
276
416
|
new = _make_external_artifact(href, "file", base_dir, on_artifact)
|
|
277
|
-
return f"[{text}]({new})" if new else f"_(missing link: {href})_"
|
|
417
|
+
return f"[{text}]({_md_url(new)})" if new else f"_(missing link: {href})_"
|
|
278
418
|
|
|
279
|
-
|
|
280
|
-
return _MD_LINK_RE.sub(_replace_link, md)
|
|
419
|
+
return _MD_REF_RE.sub(_replace_ref, md)
|
|
281
420
|
|
|
282
421
|
else:
|
|
283
422
|
_INLINE_TAGS = {"b", "strong", "i", "em", "u", "code", "kbd", "samp", "var"}
|
|
@@ -298,7 +437,9 @@ else:
|
|
|
298
437
|
self._list_kind: List[str] = []
|
|
299
438
|
self._list_counter: List[int] = []
|
|
300
439
|
self._in_pre = False
|
|
301
|
-
|
|
440
|
+
# Per open <a>: whether it embeds a file, its URL, and where its output starts.
|
|
441
|
+
self._links: List[Tuple[bool, Optional[str], int]] = []
|
|
442
|
+
self._media: List[str] = []
|
|
302
443
|
self._row_cells: Optional[List[str]] = None
|
|
303
444
|
self._table_rows: Optional[List[List[str]]] = None
|
|
304
445
|
self._suppress_text = 0
|
|
@@ -360,13 +501,23 @@ else:
|
|
|
360
501
|
self._emit("#" * int(tag[1]) + " ")
|
|
361
502
|
elif tag == "a":
|
|
362
503
|
href = a.get("href") or ""
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
504
|
+
if href.startswith("data:"):
|
|
505
|
+
_make_embedded_artifact(href, "file", self._on_artifact, a.get("download"))
|
|
506
|
+
self._links.append((True, None, len(self._out)))
|
|
507
|
+
self._emit("_(embedded ")
|
|
508
|
+
else:
|
|
509
|
+
self._links.append((False, self._handle_href(href, kind="file"), len(self._out)))
|
|
510
|
+
self._emit("[")
|
|
366
511
|
elif tag == "img":
|
|
367
512
|
src = a.get("src") or ""
|
|
368
513
|
alt = a.get("alt") or ""
|
|
369
514
|
self._handle_img(src, alt)
|
|
515
|
+
elif tag in ("video", "audio"):
|
|
516
|
+
self._media.append(tag)
|
|
517
|
+
self._handle_media(tag, a.get("src") or "")
|
|
518
|
+
elif tag == "source":
|
|
519
|
+
if self._media:
|
|
520
|
+
self._handle_media(self._media[-1], a.get("src") or "")
|
|
370
521
|
elif tag == "table":
|
|
371
522
|
self._table_rows = []
|
|
372
523
|
self._blank_line()
|
|
@@ -406,12 +557,17 @@ else:
|
|
|
406
557
|
elif tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
|
407
558
|
self._blank_line()
|
|
408
559
|
elif tag == "a":
|
|
409
|
-
href = self.
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
560
|
+
embedded, href, start = self._links.pop() if self._links else (False, None, 0)
|
|
561
|
+
if embedded:
|
|
562
|
+
if not "".join(self._out[start + 1 :]).strip():
|
|
563
|
+
self._emit("data")
|
|
564
|
+
self._emit(" — see artefacts)_")
|
|
413
565
|
else:
|
|
414
|
-
self._emit("
|
|
566
|
+
self._emit("]")
|
|
567
|
+
self._emit(f"({_md_url(href)})" if href else "()")
|
|
568
|
+
elif tag in ("video", "audio"):
|
|
569
|
+
if self._media:
|
|
570
|
+
self._media.pop()
|
|
415
571
|
elif tag in ("td", "th"):
|
|
416
572
|
if self._stack and self._stack[-1] == "cell":
|
|
417
573
|
self._stack.pop()
|
|
@@ -459,9 +615,6 @@ else:
|
|
|
459
615
|
def _handle_href(self, href: str, *, kind: str) -> Optional[str]:
|
|
460
616
|
if not href:
|
|
461
617
|
return None
|
|
462
|
-
if href.startswith("data:"):
|
|
463
|
-
_make_embedded_artifact(href, kind, self._on_artifact)
|
|
464
|
-
return None
|
|
465
618
|
if _is_external_url(href):
|
|
466
619
|
return href
|
|
467
620
|
return _make_external_artifact(href, kind, self._base_dir, self._on_artifact)
|
|
@@ -474,14 +627,25 @@ else:
|
|
|
474
627
|
self._emit(f"_(embedded image — see artefacts: {alt or 'no alt text'})_")
|
|
475
628
|
return
|
|
476
629
|
if _is_external_url(src):
|
|
477
|
-
self._emit(f"")
|
|
630
|
+
self._emit(f"})")
|
|
478
631
|
return
|
|
479
632
|
url = _make_external_artifact(src, "image", self._base_dir, self._on_artifact)
|
|
480
633
|
if url:
|
|
481
|
-
self._emit(f"")
|
|
634
|
+
self._emit(f"})")
|
|
482
635
|
else:
|
|
483
636
|
self._emit(f"_(missing image: {src})_")
|
|
484
637
|
|
|
638
|
+
def _handle_media(self, kind: str, src: str) -> None:
|
|
639
|
+
# A link with the element name as its text, as html-to-markdown shows <video> and <audio>.
|
|
640
|
+
if not src:
|
|
641
|
+
return
|
|
642
|
+
if src.startswith("data:"):
|
|
643
|
+
_make_embedded_artifact(src, "file", self._on_artifact)
|
|
644
|
+
self._emit(f"_(embedded {kind} — see artefacts)_")
|
|
645
|
+
return
|
|
646
|
+
url = self._handle_href(src, kind="file")
|
|
647
|
+
self._emit(f"[{kind}]({_md_url(url)})" if url else f"[{kind}]()")
|
|
648
|
+
|
|
485
649
|
def _inline_open(tag: str) -> str:
|
|
486
650
|
return {
|
|
487
651
|
"b": "**",
|
|
@@ -76,6 +76,7 @@ class TestResultItem(CamelSnakeMixin):
|
|
|
76
76
|
message: str
|
|
77
77
|
full_message: Optional[str] = None
|
|
78
78
|
tags: Optional[List[str]] = None
|
|
79
|
+
metadata: Optional[Dict[str, str]] = None
|
|
79
80
|
elapsed_seconds: Optional[float] = None
|
|
80
81
|
start_time: Optional[str] = None
|
|
81
82
|
source: Optional[str] = None
|
|
@@ -152,6 +153,9 @@ class LogTest(CamelSnakeMixin):
|
|
|
152
153
|
# suite. Lets the TEXT renderer group tests under suite headers and
|
|
153
154
|
# JSON consumers cross-reference `LogResult.suites`.
|
|
154
155
|
suite: Optional[str] = None
|
|
156
|
+
# The test's `[Metadata]` (Robot Framework 7.5+), same shape as
|
|
157
|
+
# `LogSuite.metadata`.
|
|
158
|
+
metadata: Optional[Dict[str, str]] = None
|
|
155
159
|
|
|
156
160
|
|
|
157
161
|
@dataclass
|
|
@@ -29,6 +29,7 @@ from .._markdown import (
|
|
|
29
29
|
make_md_highlighter,
|
|
30
30
|
md_escape,
|
|
31
31
|
md_table,
|
|
32
|
+
metadata_md,
|
|
32
33
|
path_paren,
|
|
33
34
|
timing_suffix,
|
|
34
35
|
)
|
|
@@ -51,7 +52,8 @@ from ._models import (
|
|
|
51
52
|
# `show`); `LogTest` is the `log`-specific record carrying a body tree.
|
|
52
53
|
# Both expose the fields the per-test renderers need (`full_name`,
|
|
53
54
|
# `status`, `message`, `source`, `rel_source`, `lineno`, `elapsed_seconds`,
|
|
54
|
-
# `start_time`), so we treat them interchangeably at the renderer
|
|
55
|
+
# `start_time`, `metadata`), so we treat them interchangeably at the renderer
|
|
56
|
+
# level.
|
|
55
57
|
_TestLike = Union[TestResultItem, LogTest]
|
|
56
58
|
|
|
57
59
|
_LEVEL_ORDER = {"TRACE": 0, "DEBUG": 1, "INFO": 2, "WARN": 3, "ERROR": 4, "FAIL": 5}
|
|
@@ -172,6 +174,7 @@ def render_show(
|
|
|
172
174
|
data: ShowResult,
|
|
173
175
|
*,
|
|
174
176
|
show_tags: bool,
|
|
177
|
+
show_metadata: bool = False,
|
|
175
178
|
full_paths: bool = False,
|
|
176
179
|
show_timing: bool = False,
|
|
177
180
|
sort_field: Optional[str] = None,
|
|
@@ -197,6 +200,8 @@ def render_show(
|
|
|
197
200
|
if show_tags and t.tags:
|
|
198
201
|
tags = ", ".join(highlight_md(md_escape(tag), highlight) for tag in t.tags)
|
|
199
202
|
out.append(f" - _Tags:_ {tags}")
|
|
203
|
+
if show_metadata and t.metadata:
|
|
204
|
+
out.append(f" - _Metadata:_ {metadata_md(t.metadata, highlight)}")
|
|
200
205
|
|
|
201
206
|
if data.truncated:
|
|
202
207
|
out.append("")
|
|
@@ -490,6 +495,9 @@ def _test_header_md(
|
|
|
490
495
|
head += _source_paren(t, full_paths=full_paths)
|
|
491
496
|
head += f" {bold_status(t.status)}"
|
|
492
497
|
head += timing_suffix(t.elapsed_seconds, t.start_time, show_timing=show_timing)
|
|
498
|
+
if t.metadata:
|
|
499
|
+
# a paragraph, not a list: a list would merge with the list of the test body below
|
|
500
|
+
head += f"\n\n_Metadata:_ {metadata_md(t.metadata, highlight)}"
|
|
493
501
|
return head
|
|
494
502
|
|
|
495
503
|
|