robotcode-runner 2.8.0.dev677__tar.gz → 2.9.0.dev68__tar.gz

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.
Files changed (26) hide show
  1. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/PKG-INFO +6 -6
  2. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/pyproject.toml +5 -5
  3. robotcode_runner-2.9.0.dev68/src/robotcode/runner/__version__.py +1 -0
  4. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/cli/results/_html.py +228 -64
  5. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/cli/results/results.py +46 -5
  6. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/cli/robot.py +90 -20
  7. robotcode_runner-2.8.0.dev677/src/robotcode/runner/__version__.py +0 -1
  8. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/.gitignore +0 -0
  9. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/README.md +0 -0
  10. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/__init__.py +0 -0
  11. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/cli/__init__.py +0 -0
  12. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/cli/_markdown.py +0 -0
  13. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/cli/_robot_version.py +0 -0
  14. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/cli/_search.py +0 -0
  15. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/cli/discover/__init__.py +0 -0
  16. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/cli/discover/_models.py +0 -0
  17. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/cli/discover/_render.py +0 -0
  18. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/cli/discover/discover.py +0 -0
  19. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/cli/libdoc.py +0 -0
  20. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/cli/rebot.py +0 -0
  21. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/cli/results/__init__.py +0 -0
  22. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/cli/results/_models.py +0 -0
  23. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/cli/results/_render.py +0 -0
  24. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/cli/testdoc.py +0 -0
  25. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/hooks.py +0 -0
  26. {robotcode_runner-2.8.0.dev677 → robotcode_runner-2.9.0.dev68}/src/robotcode/runner/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: robotcode-runner
3
- Version: 2.8.0-dev.677
3
+ Version: 2.9.0-dev.68
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.8.0-dev.677
30
- Requires-Dist: robotcode-plugin==2.8.0-dev.677
31
- Requires-Dist: robotcode-robot==2.8.0-dev.677
32
- Requires-Dist: robotcode==2.8.0-dev.677
29
+ Requires-Dist: robotcode-modifiers==2.9.0-dev.68
30
+ Requires-Dist: robotcode-plugin==2.9.0-dev.68
31
+ Requires-Dist: robotcode-robot==2.9.0-dev.68
32
+ Requires-Dist: robotcode==2.9.0-dev.68
33
33
  Requires-Dist: robotframework>=5.0.0
34
34
  Provides-Extra: html
35
- Requires-Dist: html-to-markdown>=2.0; extra == 'html'
35
+ Requires-Dist: html-to-markdown>=3.18; extra == 'html'
36
36
  Description-Content-Type: text/markdown
37
37
 
38
38
  # robotcode-runner
@@ -30,14 +30,14 @@ classifiers = [
30
30
  dynamic = ["version"]
31
31
  dependencies = [
32
32
  "robotframework>=5.0.0",
33
- "robotcode-robot==2.8.0-dev.677",
34
- "robotcode-modifiers==2.8.0-dev.677",
35
- "robotcode-plugin==2.8.0-dev.677",
36
- "robotcode==2.8.0-dev.677",
33
+ "robotcode-robot==2.9.0-dev.68",
34
+ "robotcode-modifiers==2.9.0-dev.68",
35
+ "robotcode-plugin==2.9.0-dev.68",
36
+ "robotcode==2.9.0-dev.68",
37
37
  ]
38
38
 
39
39
  [project.optional-dependencies]
40
- html = ["html-to-markdown>=2.0"]
40
+ html = ["html-to-markdown>=3.18"]
41
41
 
42
42
  [project.entry-points.robotcode]
43
43
  runner = "robotcode.runner.hooks"
@@ -0,0 +1 @@
1
+ __version__ = "2.9.0-dev.68"
@@ -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 — better quality,
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">`) are checked for path-traversal and resolved against `base_dir`,
12
- emitting absolute `file://` URLs for clickable terminal links (OSC 8).
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
- _HAS_EXT = True
34
- except ImportError:
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: Tuple[Any, ...] = (ref.resolved_path or ref.src, ref.embedded, ref.skipped_reason)
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
- _MD_IMG_RE = re.compile(r"!\[(?P<alt>[^\]]*)\]\((?P<src>[^)\s]+)(?:\s+\"[^\"]*\")?\)")
93
- _MD_LINK_RE = re.compile(r"(?<!\!)\[(?P<text>[^\]]+)\]\((?P<href>[^)\s]+)(?:\s+\"[^\"]*\")?\)")
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 _resolve_under(base_dir: Path, src: str) -> tuple[Optional[Path], Optional[str]]:
150
- """Return (resolved_path, skipped_reason). resolved_path is None if traversal."""
151
- if not src or src.startswith(("#", "?")):
152
- return None, "empty-or-fragment"
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
- candidate = (base_dir / src).resolve()
155
- base_resolved = base_dir.resolve()
156
- except (OSError, RuntimeError):
157
- return None, "resolve-failed"
211
+ return path.exists()
212
+ except (OSError, ValueError):
213
+ return False
214
+
215
+
216
+ def _is_dir(path: Path) -> bool:
158
217
  try:
159
- candidate.relative_to(base_resolved)
160
- except ValueError:
161
- return None, "path-traversal"
162
- return candidate, None
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 a markdown-safe URL (file:// or None) or the
169
- original href for external URLs. Records an ArtifactRef when the href points
170
- into the filesystem."""
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 resolved is None:
177
- ref.skipped_reason = skipped
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
- ref.rel_path = str(resolved.relative_to(base_dir.resolve()))
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
- ref.rel_path = href
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(uri: str, kind: str, on_artifact: Callable[[ArtifactRef], None]) -> ArtifactRef:
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(md, base_dir=base_dir, on_artifact=on_artifact, already_recorded=embedded_urls)
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"![{alt}]({new})" if new else f"_(missing image: {src})_"
400
+ return f"![{alt}]({_md_url(new)})" if new else f"_(missing image: {src})_"
265
401
 
266
- def _replace_link(m: re.Match[str]) -> str:
267
- text = m.group("text")
268
- href = m.group("href")
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
- return f"_(embedded {text or 'data'} — see artefacts)_"
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
- return m.group(0)
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
- md = _MD_IMG_RE.sub(_replace_img, md)
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
- self._link_href: List[Optional[str]] = []
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
- url = self._handle_href(href, kind="file")
364
- self._link_href.append(url)
365
- self._emit("[")
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._link_href.pop() if self._link_href else None
410
- self._emit("]")
411
- if href:
412
- self._emit(f"({href})")
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"![{alt}]({src})")
630
+ self._emit(f"![{alt}]({_md_url(src)})")
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"![{alt}]({url})")
634
+ self._emit(f"![{alt}]({_md_url(url)})")
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
- slug = re.sub(r"[^\w.-]+", "_", s).strip("_")[:200]
2059
- return slug or "_test"
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
- ext = _html.ext_from_media_type(ref.media_type)
2089
- fname = _unique_name(f"embedded-{seq_embedded}{ext}", used_names)
2090
- seq_embedded += 1
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
@@ -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
- def handle_robot_options(
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
- _patch()
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
- config_files, root_folder, _ = get_config_files(
311
- robot_arguments,
312
- app.config.config_files,
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
- profile = (
319
- load_robot_config_from_path(*config_files, verbose_callback=app.verbose)
320
- .combine_profiles(*(app.config.profiles or []), verbose_callback=app.verbose, error_callback=app.error)
321
- .evaluated_with_env(verbose_callback=app.verbose, error_callback=app.error)
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
- except (TypeError, ValueError) as e:
324
- raise click.ClickException(str(e)) from e
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 +0,0 @@
1
- __version__ = "2.8.0-dev.677"