robotcode-runner 2.8.0.dev677__py3-none-any.whl → 2.9.0.dev51__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/results/_html.py +228 -64
- robotcode/runner/cli/results/results.py +46 -5
- robotcode/runner/cli/robot.py +90 -20
- {robotcode_runner-2.8.0.dev677.dist-info → robotcode_runner-2.9.0.dev51.dist-info}/METADATA +6 -6
- {robotcode_runner-2.8.0.dev677.dist-info → robotcode_runner-2.9.0.dev51.dist-info}/RECORD +8 -8
- {robotcode_runner-2.8.0.dev677.dist-info → robotcode_runner-2.9.0.dev51.dist-info}/WHEEL +1 -1
- {robotcode_runner-2.8.0.dev677.dist-info → robotcode_runner-2.9.0.dev51.dist-info}/entry_points.txt +0 -0
robotcode/runner/__version__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "2.
|
|
1
|
+
__version__ = "2.9.0-dev.51"
|
|
@@ -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": "**",
|
|
@@ -2054,9 +2054,31 @@ def _make_message_entry(
|
|
|
2054
2054
|
)
|
|
2055
2055
|
|
|
2056
2056
|
|
|
2057
|
+
def _safe_name(s: str) -> str:
|
|
2058
|
+
return re.sub(r"[^\w.-]+", "_", s).strip("_")[:200]
|
|
2059
|
+
|
|
2060
|
+
|
|
2057
2061
|
def _slugify(s: str) -> str:
|
|
2058
|
-
|
|
2059
|
-
|
|
2062
|
+
return _safe_name(s) or "_test"
|
|
2063
|
+
|
|
2064
|
+
|
|
2065
|
+
# Windows takes these names for devices, also with an extension.
|
|
2066
|
+
_WINDOWS_DEVICE_NAMES = frozenset(
|
|
2067
|
+
["CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(1, 10)), *(f"LPT{i}" for i in range(1, 10))]
|
|
2068
|
+
)
|
|
2069
|
+
|
|
2070
|
+
|
|
2071
|
+
def _download_file_name(download: Optional[str]) -> Optional[str]:
|
|
2072
|
+
"""Return the file name for an embedded file from the `download` attribute
|
|
2073
|
+
of its link, made safe like test directory names, or None when it gives none."""
|
|
2074
|
+
if not download:
|
|
2075
|
+
return None
|
|
2076
|
+
name = _safe_name(re.split(r"[/\\]", download)[-1])
|
|
2077
|
+
if not name.strip("."):
|
|
2078
|
+
return None
|
|
2079
|
+
if name.split(".", 1)[0].upper() in _WINDOWS_DEVICE_NAMES:
|
|
2080
|
+
name = f"_{name}"
|
|
2081
|
+
return name
|
|
2060
2082
|
|
|
2061
2083
|
|
|
2062
2084
|
def _extract_artifacts(tests: List[LogTest], *, target: Path) -> int:
|
|
@@ -2065,6 +2087,9 @@ def _extract_artifacts(tests: List[LogTest], *, target: Path) -> int:
|
|
|
2065
2087
|
for t in tests:
|
|
2066
2088
|
if not t.artifacts:
|
|
2067
2089
|
continue
|
|
2090
|
+
# The test keeps one reference per target, its messages their own ones; those get
|
|
2091
|
+
# the result of the extraction too. The keys are taken before it changes them.
|
|
2092
|
+
by_key = {_html.artefact_key(ref): ref for ref in t.artifacts}
|
|
2068
2093
|
slug = _slugify(t.full_name)
|
|
2069
2094
|
test_dir = (target / slug).resolve()
|
|
2070
2095
|
try:
|
|
@@ -2072,6 +2097,7 @@ def _extract_artifacts(tests: List[LogTest], *, target: Path) -> int:
|
|
|
2072
2097
|
except ValueError:
|
|
2073
2098
|
for ref in t.artifacts:
|
|
2074
2099
|
ref.skipped_reason = ref.skipped_reason or "target-traversal"
|
|
2100
|
+
_share_extraction(t.body, by_key)
|
|
2075
2101
|
continue
|
|
2076
2102
|
test_dir.mkdir(parents=True, exist_ok=True)
|
|
2077
2103
|
|
|
@@ -2085,9 +2111,11 @@ def _extract_artifacts(tests: List[LogTest], *, target: Path) -> int:
|
|
|
2085
2111
|
if data is None:
|
|
2086
2112
|
ref.skipped_reason = "no-data"
|
|
2087
2113
|
continue
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2114
|
+
name = _download_file_name(_html.get_embedded_name(ref))
|
|
2115
|
+
if name is None:
|
|
2116
|
+
name = f"embedded-{seq_embedded}{_html.ext_from_media_type(ref.media_type)}"
|
|
2117
|
+
seq_embedded += 1
|
|
2118
|
+
fname = _unique_name(name, used_names)
|
|
2091
2119
|
dest = (test_dir / fname).resolve()
|
|
2092
2120
|
try:
|
|
2093
2121
|
dest.relative_to(test_dir)
|
|
@@ -2112,9 +2140,22 @@ def _extract_artifacts(tests: List[LogTest], *, target: Path) -> int:
|
|
|
2112
2140
|
shutil.copy2(src_path, dest)
|
|
2113
2141
|
ref.extracted_to = str(dest)
|
|
2114
2142
|
count += 1
|
|
2143
|
+
_share_extraction(t.body, by_key)
|
|
2115
2144
|
return count
|
|
2116
2145
|
|
|
2117
2146
|
|
|
2147
|
+
def _share_extraction(entries: Optional[List[LogEntry]], by_key: Dict[Tuple[Any, ...], ArtifactRef]) -> None:
|
|
2148
|
+
"""Give every reference in `entries` the result of extracting the
|
|
2149
|
+
test-level reference with the same key."""
|
|
2150
|
+
for entry in entries or []:
|
|
2151
|
+
for ref in entry.artifacts or []:
|
|
2152
|
+
extracted = by_key.get(_html.artefact_key(ref))
|
|
2153
|
+
if extracted is not None and extracted is not ref:
|
|
2154
|
+
ref.extracted_to = extracted.extracted_to
|
|
2155
|
+
ref.skipped_reason = extracted.skipped_reason
|
|
2156
|
+
_share_extraction(entry.body, by_key)
|
|
2157
|
+
|
|
2158
|
+
|
|
2118
2159
|
def _unique_name(name: str, used: Dict[str, int]) -> str:
|
|
2119
2160
|
if name not in used:
|
|
2120
2161
|
used[name] = 1
|
robotcode/runner/cli/robot.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import weakref
|
|
3
|
+
from dataclasses import dataclass, field
|
|
3
4
|
from pathlib import Path
|
|
4
|
-
from typing import Any, Dict, List, Optional, Tuple, Union, cast
|
|
5
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union, cast
|
|
5
6
|
|
|
6
7
|
import click
|
|
7
8
|
from robot.errors import DataError, Information
|
|
@@ -16,7 +17,7 @@ from robotcode.modifiers.metadata_modifiers import MetadataPattern
|
|
|
16
17
|
from robotcode.plugin import Application, pass_application
|
|
17
18
|
from robotcode.plugin.click_helper.aliases import AliasedCommand
|
|
18
19
|
from robotcode.plugin.click_helper.types import add_options
|
|
19
|
-
from robotcode.plugin.click_helper.wrappable import wrappable
|
|
20
|
+
from robotcode.plugin.click_helper.wrappable import invoked_command_is_wrappable, run_under_wrapper, wrappable
|
|
20
21
|
from robotcode.robot.config.loader import load_robot_config_from_path
|
|
21
22
|
from robotcode.robot.config.model import RobotBaseProfile
|
|
22
23
|
from robotcode.robot.config.utils import get_config_files
|
|
@@ -291,14 +292,36 @@ ROBOT_OPTIONS = [
|
|
|
291
292
|
]
|
|
292
293
|
|
|
293
294
|
|
|
294
|
-
|
|
295
|
-
app: Application, robot_options_and_args: Tuple[str, ...]
|
|
296
|
-
) -> Tuple[Optional[Path], RobotBaseProfile, List[str]]:
|
|
297
|
-
global _app
|
|
298
|
-
_app = app
|
|
295
|
+
_LOADED_PROFILES = "robotcode.runner.loaded_profiles"
|
|
299
296
|
|
|
300
|
-
|
|
297
|
+
_Message = Union[str, Callable[[], Any]]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@dataclass
|
|
301
|
+
class LoadedRobotProfile:
|
|
302
|
+
"""The configuration of a command, loaded once, with what loading it reported."""
|
|
303
|
+
|
|
304
|
+
root_folder: Optional[Path] = None
|
|
305
|
+
profile: Optional[RobotBaseProfile] = None
|
|
306
|
+
error: Optional[Exception] = None
|
|
307
|
+
messages: List[Tuple[Callable[[_Message], None], _Message]] = field(default_factory=list)
|
|
301
308
|
|
|
309
|
+
def show_messages(self) -> None:
|
|
310
|
+
messages, self.messages = self.messages, []
|
|
311
|
+
for show, message in messages:
|
|
312
|
+
show(message)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def load_robot_profile(app: Application, robot_options_and_args: Tuple[str, ...]) -> LoadedRobotProfile:
|
|
316
|
+
"""Load the configuration of a command once, from the paths among its arguments.
|
|
317
|
+
|
|
318
|
+
The result is kept for the whole command, with the messages and the error of the
|
|
319
|
+
loading, for later calls with the same paths, such as the one in `handle_robot_options`,
|
|
320
|
+
which shows them. A command that runs Robot Framework (`@wrappable`) is then started
|
|
321
|
+
again under its `wrapper`. Such a command calls this first, before it starts a server,
|
|
322
|
+
an interpreter or a debugger, so that they run under the `wrapper` too, while an error
|
|
323
|
+
of the configuration still appears where `handle_robot_options` raises it.
|
|
324
|
+
"""
|
|
302
325
|
robot_arguments: Optional[List[Union[str, Path]]] = None
|
|
303
326
|
|
|
304
327
|
try:
|
|
@@ -307,21 +330,68 @@ def handle_robot_options(
|
|
|
307
330
|
except (DataError, Information):
|
|
308
331
|
pass
|
|
309
332
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
root_folder=app.config.root,
|
|
314
|
-
no_vcs=app.config.no_vcs,
|
|
315
|
-
verbose_callback=app.verbose,
|
|
333
|
+
ctx = click.get_current_context(silent=True)
|
|
334
|
+
loaded: Dict[Tuple[str, ...], LoadedRobotProfile] = (
|
|
335
|
+
ctx.find_root().meta.setdefault(_LOADED_PROFILES, {}) if ctx is not None else {}
|
|
316
336
|
)
|
|
337
|
+
key = tuple(str(Path(a).absolute()) for a in robot_arguments or ())
|
|
338
|
+
if key in loaded:
|
|
339
|
+
return loaded[key]
|
|
340
|
+
|
|
341
|
+
result = LoadedRobotProfile()
|
|
342
|
+
loaded[key] = result
|
|
343
|
+
|
|
344
|
+
def verbose(message: _Message) -> None:
|
|
345
|
+
result.messages.append((app.verbose, message))
|
|
346
|
+
|
|
347
|
+
def error(message: _Message) -> None:
|
|
348
|
+
result.messages.append((app.error, message))
|
|
349
|
+
|
|
317
350
|
try:
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
351
|
+
config_files, result.root_folder, _ = get_config_files(
|
|
352
|
+
robot_arguments,
|
|
353
|
+
app.config.config_files,
|
|
354
|
+
root_folder=app.config.root,
|
|
355
|
+
no_vcs=app.config.no_vcs,
|
|
356
|
+
verbose_callback=verbose,
|
|
322
357
|
)
|
|
323
|
-
|
|
324
|
-
|
|
358
|
+
result.profile = (
|
|
359
|
+
load_robot_config_from_path(*config_files, verbose_callback=verbose)
|
|
360
|
+
.combine_profiles(*(app.config.profiles or []), verbose_callback=verbose, error_callback=error)
|
|
361
|
+
.evaluated_with_env(verbose_callback=verbose, error_callback=error)
|
|
362
|
+
)
|
|
363
|
+
except Exception as e:
|
|
364
|
+
result.error = e
|
|
365
|
+
|
|
366
|
+
if result.profile is not None and ctx is not None and invoked_command_is_wrappable(ctx):
|
|
367
|
+
try:
|
|
368
|
+
run_under_wrapper(app, result.profile.wrapper)
|
|
369
|
+
except click.ClickException:
|
|
370
|
+
# The wrapper did not start, so robotcode under it will not show them.
|
|
371
|
+
result.show_messages()
|
|
372
|
+
raise
|
|
373
|
+
|
|
374
|
+
return result
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def handle_robot_options(
|
|
378
|
+
app: Application, robot_options_and_args: Tuple[str, ...]
|
|
379
|
+
) -> Tuple[Optional[Path], RobotBaseProfile, List[str]]:
|
|
380
|
+
global _app
|
|
381
|
+
_app = app
|
|
382
|
+
|
|
383
|
+
_patch()
|
|
384
|
+
|
|
385
|
+
loaded = load_robot_profile(app, robot_options_and_args)
|
|
386
|
+
loaded.show_messages()
|
|
387
|
+
|
|
388
|
+
if loaded.error is not None:
|
|
389
|
+
if isinstance(loaded.error, (TypeError, ValueError)):
|
|
390
|
+
raise click.ClickException(str(loaded.error)) from loaded.error
|
|
391
|
+
raise loaded.error
|
|
392
|
+
|
|
393
|
+
root_folder = loaded.root_folder
|
|
394
|
+
profile = cast(RobotBaseProfile, loaded.profile)
|
|
325
395
|
|
|
326
396
|
cmd_options = profile.build_command_line()
|
|
327
397
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: robotcode-runner
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.9.0-dev.51
|
|
4
4
|
Summary: RobotCode runner for Robot Framework
|
|
5
5
|
Project-URL: Homepage, https://robotcode.io
|
|
6
6
|
Project-URL: Donate, https://opencollective.com/robotcode
|
|
@@ -26,13 +26,13 @@ Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
|
26
26
|
Classifier: Topic :: Utilities
|
|
27
27
|
Classifier: Typing :: Typed
|
|
28
28
|
Requires-Python: >=3.10
|
|
29
|
-
Requires-Dist: robotcode-modifiers==2.
|
|
30
|
-
Requires-Dist: robotcode-plugin==2.
|
|
31
|
-
Requires-Dist: robotcode-robot==2.
|
|
32
|
-
Requires-Dist: robotcode==2.
|
|
29
|
+
Requires-Dist: robotcode-modifiers==2.9.0-dev.51
|
|
30
|
+
Requires-Dist: robotcode-plugin==2.9.0-dev.51
|
|
31
|
+
Requires-Dist: robotcode-robot==2.9.0-dev.51
|
|
32
|
+
Requires-Dist: robotcode==2.9.0-dev.51
|
|
33
33
|
Requires-Dist: robotframework>=5.0.0
|
|
34
34
|
Provides-Extra: html
|
|
35
|
-
Requires-Dist: html-to-markdown>=
|
|
35
|
+
Requires-Dist: html-to-markdown>=3.18; extra == 'html'
|
|
36
36
|
Description-Content-Type: text/markdown
|
|
37
37
|
|
|
38
38
|
# robotcode-runner
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
robotcode/runner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
robotcode/runner/__version__.py,sha256=
|
|
2
|
+
robotcode/runner/__version__.py,sha256=68lI1Ud2IVxiSKwJTXlHsfwV7s6ASsCia4CPMEdJ8Ls,29
|
|
3
3
|
robotcode/runner/hooks.py,sha256=MSqEHc3wQIBgCXf9U9B0sA_zE8vbiTdoAlfaWz_3uIo,270
|
|
4
4
|
robotcode/runner/py.typed,sha256=bWew9mHgMy8LqMu7RuqQXFXLBxh2CRx0dUbSx-3wE48,27
|
|
5
5
|
robotcode/runner/cli/__init__.py,sha256=ozQtrMXxWIlJz3QKZhaGEIDjSCL0TPtF1XeP8WL8HrE,240
|
|
@@ -8,18 +8,18 @@ robotcode/runner/cli/_robot_version.py,sha256=aXpMkrA4IHtCvqi_Dv9hr2D6UqBEI7j8Pw
|
|
|
8
8
|
robotcode/runner/cli/_search.py,sha256=JhKCDAdu_Mv89ug0p6nMumxdMAAwgbtdLt2CoJEZjsI,15874
|
|
9
9
|
robotcode/runner/cli/libdoc.py,sha256=IanGjNOkm45VESVO_3pNoMpXtjpXMX9Li-J8lhPL2X8,3782
|
|
10
10
|
robotcode/runner/cli/rebot.py,sha256=QIKYJD-4w7wquv46EygylNhBWXq1hj7-jV7vZvAHuHw,4922
|
|
11
|
-
robotcode/runner/cli/robot.py,sha256=
|
|
11
|
+
robotcode/runner/cli/robot.py,sha256=fxQz5zIdSafUalYivN7L-1di7YG4aJ7MJu2ffwiHbVw,16651
|
|
12
12
|
robotcode/runner/cli/testdoc.py,sha256=UMpa1GOuyAIENGziepSprM4vHmR0QNfyBYWKN9cqd38,3756
|
|
13
13
|
robotcode/runner/cli/discover/__init__.py,sha256=CRFSvVDlOX1avV3kx-d2QUOugDvgKPy8RBApsAUq15s,55
|
|
14
14
|
robotcode/runner/cli/discover/_models.py,sha256=mkRwJTyQUWLVGbupCtYMVAHFhUouuMJ3TfR7_gz_lZI,2127
|
|
15
15
|
robotcode/runner/cli/discover/_render.py,sha256=3GYv0bNPVOLDAabg6FYXmbCDLLztjeLFrEhj-1inUvY,20091
|
|
16
16
|
robotcode/runner/cli/discover/discover.py,sha256=_YFeseR5pGPHIhltiwuNrxbHlk_uCcyB0cygyHPpOQM,46997
|
|
17
17
|
robotcode/runner/cli/results/__init__.py,sha256=aEyNen0WWvq3kF_VTRLz99_B5CMChM0aelpzWKW6Rac,52
|
|
18
|
-
robotcode/runner/cli/results/_html.py,sha256=
|
|
18
|
+
robotcode/runner/cli/results/_html.py,sha256=VxfsMXeHA-CBpcHot-PRxoXXqNDvpJyPItofTRf-J3I,26353
|
|
19
19
|
robotcode/runner/cli/results/_models.py,sha256=ESn9VKIF-mySCnE144wNoYMmYMKuniZKQW5O61lko5I,7458
|
|
20
20
|
robotcode/runner/cli/results/_render.py,sha256=9DNk1d7F01bGQBCKBgtcqw_asvmnX1pJ02-kU02ptss,32471
|
|
21
|
-
robotcode/runner/cli/results/results.py,sha256=
|
|
22
|
-
robotcode_runner-2.
|
|
23
|
-
robotcode_runner-2.
|
|
24
|
-
robotcode_runner-2.
|
|
25
|
-
robotcode_runner-2.
|
|
21
|
+
robotcode/runner/cli/results/results.py,sha256=FtSPgsHS4D-6Guvur0zn6f3lYsPZigZaMpRzNBrxAdw,75501
|
|
22
|
+
robotcode_runner-2.9.0.dev51.dist-info/METADATA,sha256=EUjtqD3jwQXKbm3SVo1IK3UGuiUPg3U9SAsdm69oO5c,3055
|
|
23
|
+
robotcode_runner-2.9.0.dev51.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
24
|
+
robotcode_runner-2.9.0.dev51.dist-info/entry_points.txt,sha256=igwjQYHlUfgCN-SohGVeQOnxOtqUzd2Jq94Ie8Se1Aw,44
|
|
25
|
+
robotcode_runner-2.9.0.dev51.dist-info/RECORD,,
|
{robotcode_runner-2.8.0.dev677.dist-info → robotcode_runner-2.9.0.dev51.dist-info}/entry_points.txt
RENAMED
|
File without changes
|