robotcode-runner 2.5.1__tar.gz → 2.6.0__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 (25) hide show
  1. {robotcode_runner-2.5.1 → robotcode_runner-2.6.0}/.gitignore +5 -1
  2. {robotcode_runner-2.5.1 → robotcode_runner-2.6.0}/PKG-INFO +7 -5
  3. {robotcode_runner-2.5.1 → robotcode_runner-2.6.0}/pyproject.toml +7 -4
  4. robotcode_runner-2.6.0/src/robotcode/runner/__version__.py +1 -0
  5. {robotcode_runner-2.5.1 → robotcode_runner-2.6.0}/src/robotcode/runner/cli/__init__.py +2 -1
  6. robotcode_runner-2.6.0/src/robotcode/runner/cli/_markdown.py +325 -0
  7. robotcode_runner-2.6.0/src/robotcode/runner/cli/_search.py +382 -0
  8. robotcode_runner-2.6.0/src/robotcode/runner/cli/discover/_models.py +68 -0
  9. robotcode_runner-2.6.0/src/robotcode/runner/cli/discover/_render.py +447 -0
  10. {robotcode_runner-2.5.1 → robotcode_runner-2.6.0}/src/robotcode/runner/cli/discover/discover.py +255 -230
  11. robotcode_runner-2.6.0/src/robotcode/runner/cli/results/__init__.py +3 -0
  12. robotcode_runner-2.6.0/src/robotcode/runner/cli/results/_html.py +540 -0
  13. robotcode_runner-2.6.0/src/robotcode/runner/cli/results/_models.py +235 -0
  14. robotcode_runner-2.6.0/src/robotcode/runner/cli/results/_render.py +908 -0
  15. robotcode_runner-2.6.0/src/robotcode/runner/cli/results/results.py +1975 -0
  16. {robotcode_runner-2.5.1 → robotcode_runner-2.6.0}/src/robotcode/runner/cli/robot.py +6 -0
  17. {robotcode_runner-2.5.1 → robotcode_runner-2.6.0}/src/robotcode/runner/hooks.py +2 -2
  18. robotcode_runner-2.5.1/src/robotcode/runner/__version__.py +0 -1
  19. {robotcode_runner-2.5.1 → robotcode_runner-2.6.0}/README.md +0 -0
  20. {robotcode_runner-2.5.1 → robotcode_runner-2.6.0}/src/robotcode/runner/__init__.py +0 -0
  21. {robotcode_runner-2.5.1 → robotcode_runner-2.6.0}/src/robotcode/runner/cli/discover/__init__.py +0 -0
  22. {robotcode_runner-2.5.1 → robotcode_runner-2.6.0}/src/robotcode/runner/cli/libdoc.py +0 -0
  23. {robotcode_runner-2.5.1 → robotcode_runner-2.6.0}/src/robotcode/runner/cli/rebot.py +0 -0
  24. {robotcode_runner-2.5.1 → robotcode_runner-2.6.0}/src/robotcode/runner/cli/testdoc.py +0 -0
  25. {robotcode_runner-2.5.1 → robotcode_runner-2.6.0}/src/robotcode/runner/py.typed +0 -0
@@ -331,7 +331,7 @@ output.xml
331
331
  bundled/libs
332
332
 
333
333
  # robotframework
334
- results/
334
+ /results/
335
335
 
336
336
  # kilocode
337
337
  .kilocode/
@@ -339,3 +339,7 @@ results/
339
339
  # .agents
340
340
  .agents/
341
341
  skills-lock.json
342
+ .claude
343
+
344
+ # sarif files
345
+ /**/*.sarif.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: robotcode-runner
3
- Version: 2.5.1
3
+ Version: 2.6.0
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
@@ -25,11 +25,13 @@ Classifier: Programming Language :: Python :: Implementation :: PyPy
25
25
  Classifier: Topic :: Utilities
26
26
  Classifier: Typing :: Typed
27
27
  Requires-Python: >=3.10
28
- Requires-Dist: robotcode
29
- Requires-Dist: robotcode-modifiers
30
- Requires-Dist: robotcode-plugin
31
- Requires-Dist: robotcode-robot
28
+ Requires-Dist: robotcode-modifiers==2.6.0
29
+ Requires-Dist: robotcode-plugin==2.6.0
30
+ Requires-Dist: robotcode-robot==2.6.0
31
+ Requires-Dist: robotcode==2.6.0
32
32
  Requires-Dist: robotframework>=5.0.0
33
+ Provides-Extra: html
34
+ Requires-Dist: html-to-markdown>=2.0; extra == 'html'
33
35
  Description-Content-Type: text/markdown
34
36
 
35
37
  # robotcode-runner
@@ -29,12 +29,15 @@ classifiers = [
29
29
  dynamic = ["version"]
30
30
  dependencies = [
31
31
  "robotframework>=5.0.0",
32
- "robotcode-robot",
33
- "robotcode-modifiers",
34
- "robotcode-plugin",
35
- "robotcode",
32
+ "robotcode-robot==2.6.0",
33
+ "robotcode-modifiers==2.6.0",
34
+ "robotcode-plugin==2.6.0",
35
+ "robotcode==2.6.0",
36
36
  ]
37
37
 
38
+ [project.optional-dependencies]
39
+ html = ["html-to-markdown>=2.0"]
40
+
38
41
  [project.entry-points.robotcode]
39
42
  runner = "robotcode.runner.hooks"
40
43
 
@@ -0,0 +1 @@
1
+ __version__ = "2.6.0"
@@ -1,7 +1,8 @@
1
1
  from .discover import discover
2
2
  from .libdoc import libdoc
3
3
  from .rebot import rebot
4
+ from .results import results
4
5
  from .robot import robot
5
6
  from .testdoc import testdoc
6
7
 
7
- __all__ = ["discover", "libdoc", "rebot", "robot", "testdoc"]
8
+ __all__ = ["discover", "libdoc", "rebot", "results", "robot", "testdoc"]
@@ -0,0 +1,325 @@
1
+ """Markdown rendering helpers shared by `robotcode` CLI subcommands.
2
+
3
+ Every CLI command that emits human-readable TEXT output (`results`,
4
+ `discover`, …) routes through `app.echo_as_markdown(...)`:
5
+
6
+ - on a coloured TTY, `rich` renders the markdown to themed ANSI and
7
+ pages if needed,
8
+ - in a pipe (or with `--no-color`), the raw markdown is emitted
9
+ verbatim — pipe-friendly, LLM-friendly, pastable.
10
+
11
+ These helpers are the building blocks: escape rules, table layout,
12
+ status badges with display-width-aware padding, path/line references
13
+ wrapped as inline code, search highlighters that mark matches with
14
+ inline-code spans, and the small set of time/elapsed formatters that
15
+ recur across renderers.
16
+
17
+ Style conventions (kept consistent across commands):
18
+
19
+ - **bold** is reserved for *entities*: test names, suite names, tag
20
+ names, status words (`**FAIL**`).
21
+ - *italic* marks *labels* / metadata: `_Tags:_`, `_Total:_`,
22
+ `_Started:_`, `_Extracted:_`, user-defined metadata keys.
23
+ - `` `code` `` spans wrap file paths and `path:line` references,
24
+ argument values, and search matches — code tokens, not prose.
25
+ """
26
+
27
+ import re
28
+ from typing import Callable, Dict, List, Optional
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Time / size formatters
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ def fmt_elapsed(seconds: Optional[float]) -> str:
36
+ if seconds is None:
37
+ return "n/a"
38
+ if seconds < 1:
39
+ return f"{seconds * 1000:.0f} ms"
40
+ if seconds < 60:
41
+ return f"{seconds:.2f} s"
42
+ minutes, sec = divmod(seconds, 60)
43
+ return f"{int(minutes)} min {sec:.1f} s"
44
+
45
+
46
+ def fmt_timestamp(iso: Optional[str]) -> str:
47
+ """ISO 8601 timestamp rendered as `YYYY-MM-DD HH:MM:SS`."""
48
+ if not iso:
49
+ return ""
50
+ try:
51
+ from datetime import datetime
52
+
53
+ return datetime.fromisoformat(iso).strftime("%Y-%m-%d %H:%M:%S")
54
+ except (TypeError, ValueError):
55
+ return iso
56
+
57
+
58
+ def fmt_time_only(iso: Optional[str]) -> str:
59
+ """Only the `HH:MM:SS` part of an ISO 8601 timestamp."""
60
+ if not iso:
61
+ return ""
62
+ try:
63
+ from datetime import datetime
64
+
65
+ return datetime.fromisoformat(iso).strftime("%H:%M:%S")
66
+ except (TypeError, ValueError):
67
+ return iso
68
+
69
+
70
+ def fmt_bytes(n: int) -> str:
71
+ for unit in ("B", "kB", "MB", "GB"):
72
+ if abs(n) < 1024:
73
+ return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
74
+ n = int(n / 1024)
75
+ return f"{n:.1f} TB"
76
+
77
+
78
+ def format_filters(filters: Dict[str, List[str]]) -> str:
79
+ """Render an applied-filter dict as `key=v1, v2; key2=v3`."""
80
+ return "; ".join(f"{k}={', '.join(v)}" for k, v in filters.items() if v)
81
+
82
+
83
+ def filters_footer_md(filters: Optional[Dict[str, List[str]]]) -> Optional[str]:
84
+ """Render the standard ``_Filters: key=v1, v2; …_`` footer line, or
85
+ ``None`` when no filters are applied. Returning `None` lets the
86
+ caller skip the surrounding blank-line padding."""
87
+ if not filters or not any(v for v in filters.values()):
88
+ return None
89
+ return f"_Filters: {md_escape(format_filters(filters))}_"
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Markdown text helpers
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ # Characters that have markdown meaning anywhere in a line — escape them
98
+ # in arbitrary user content (test names, tags, messages). We deliberately
99
+ # leave alone:
100
+ # - `#`, `+`, `-`, `!`: only special at the start of a line, and the
101
+ # renderer never places user data there.
102
+ # - `|`: escaped separately by `md_pipe` (only matters inside tables).
103
+ # - `_`: CommonMark disables intraword underscore emphasis, so identifiers
104
+ # like `KW_DOC_TOKEN_beta` or `${var_name}` stay literal without escapes.
105
+ MD_ESCAPE_RE = re.compile(r"([\\`*\[\]])")
106
+
107
+
108
+ def md_escape(text: str) -> str:
109
+ """Escape markdown metacharacters in arbitrary user content (test
110
+ names, tags, paths). Safe to apply even when the content has no
111
+ specials."""
112
+ return MD_ESCAPE_RE.sub(r"\\\1", text)
113
+
114
+
115
+ def md_pipe(text: str) -> str:
116
+ """Escape only the table-cell separator for use inside markdown tables."""
117
+ return text.replace("|", "\\|")
118
+
119
+
120
+ def path_paren(
121
+ *,
122
+ source: Optional[str],
123
+ rel_source: Optional[str],
124
+ lineno: Optional[int],
125
+ full_paths: bool,
126
+ ) -> str:
127
+ """Trailing `` (`path:line`) `` suffix (or `` (`path`) `` when
128
+ `lineno` is `None`) — VS Code's terminal link detector picks up the
129
+ `path:line` string inside the code span. Empty string when no path
130
+ is available."""
131
+ path = source if full_paths else (rel_source or source)
132
+ if not path:
133
+ return ""
134
+ if lineno is None:
135
+ return f" (`{path}`)"
136
+ return f" (`{path}:{lineno}`)"
137
+
138
+
139
+ def timing_suffix(
140
+ elapsed_seconds: Optional[float],
141
+ start_time: Optional[str],
142
+ *,
143
+ show_timing: bool,
144
+ ) -> str:
145
+ """Italic `` _(13:34:23 · 12 ms)_ `` suffix or an empty string."""
146
+ parts: List[str] = []
147
+ if show_timing and start_time:
148
+ parts.append(fmt_time_only(start_time))
149
+ if elapsed_seconds is not None:
150
+ parts.append(fmt_elapsed(elapsed_seconds))
151
+ if not parts:
152
+ return ""
153
+ return f" _({' · '.join(parts)})_"
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Status icons + bold word
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ STATUS_ICONS = {
162
+ "PASS": "✅",
163
+ "FAIL": "❌",
164
+ "SKIP": "⏭",
165
+ "NOT RUN": "⏸",
166
+ "NOT SET": "⚪",
167
+ }
168
+
169
+
170
+ # Display-width adjustment for the (small, fixed) emoji set the renderer
171
+ # emits. Most emoji codepoints render as two terminal cells but `len()`
172
+ # counts them as one — so naive padding under-pads any row containing an
173
+ # emoji. We explicitly track the icons we use rather than a regex range,
174
+ # because the misc-technical block (U+2300..U+23FF) holds both
175
+ # double-width emoji (`⏭`, `⏸`) and single-width geometric shapes —
176
+ # a range-based heuristic would either miss our icons or misclassify
177
+ # unrelated content.
178
+ DOUBLE_WIDTH_ICONS: "frozenset[str]" = frozenset(STATUS_ICONS.values())
179
+
180
+
181
+ def status_icon(status: str) -> str:
182
+ return STATUS_ICONS.get(status.upper(), "")
183
+
184
+
185
+ def bold_status(status: str, *, icon: bool = True) -> str:
186
+ """Icon + bold status word: ``❌ **FAIL**``. The icon gives a scanable
187
+ visual marker for terminal / Slack / GitHub paste; the bold word keeps
188
+ the status readable for screen readers and stays meaningful in
189
+ rendering targets that don't paint emoji. Pass ``icon=False`` to
190
+ suppress the emoji (e.g. when the cell is followed by a separator or
191
+ you want to stay strictly ASCII)."""
192
+ word = f"**{status.upper()}**"
193
+ if icon:
194
+ ic = status_icon(status)
195
+ if ic:
196
+ return f"{ic} {word}"
197
+ return word
198
+
199
+
200
+ def display_width(text: str) -> int:
201
+ """Source length plus one extra column per known double-width emoji."""
202
+ return len(text) + sum(1 for ch in text if ch in DOUBLE_WIDTH_ICONS)
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # Field/value bullet list — replaces 2-column markdown tables where a
207
+ # generic "Field | Value" header would just be noise (info blocks,
208
+ # statistics counts, summary metrics). The label gets the italic-label
209
+ # treatment (`_label:_`) consistent with the project-wide convention;
210
+ # the value is passed through verbatim so callers can include bold
211
+ # status words, inline-code paths, etc.
212
+ # ---------------------------------------------------------------------------
213
+
214
+
215
+ def field_list_md(rows: List[List[str]], *, empty_text: str = "") -> str:
216
+ """Render `[[label, value], …]` as ``- _label:_ value`` bullets.
217
+
218
+ Returns ``empty_text`` (default: empty string) when ``rows`` is
219
+ empty — pass ``empty_text="_(none)_"`` or similar for an explicit
220
+ placeholder."""
221
+ if not rows:
222
+ return empty_text
223
+ return "\n".join(f"- _{label}:_ {value}" for label, value in rows)
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Markdown table with display-width-aware column padding
228
+ # ---------------------------------------------------------------------------
229
+
230
+
231
+ def md_table(headers: List[str], rows: List[List[str]], *, aligns: Optional[List[str]] = None) -> str:
232
+ """Render a markdown table with per-column padding.
233
+
234
+ Cells are space-padded to the widest entry in each column (header
235
+ included) so the raw markdown source lines up in a fixed-width
236
+ terminal — the form that goes through on `--no-color` or in a pipe.
237
+ Markdown renderers like `rich` strip the whitespace and lay the
238
+ table out themselves, so the padding costs nothing there.
239
+
240
+ `aligns` is per-column: ``"left"``, ``"right"``, or ``"center"``.
241
+ """
242
+ if aligns is None:
243
+ aligns = ["left"] * len(headers)
244
+
245
+ escaped_rows = [[md_pipe(cell) for cell in r] for r in rows]
246
+ # Per-column visual width = max(header, all cells). Display-width-aware
247
+ # so emoji cells (counted as 2 terminal columns) don't under-pad the
248
+ # column. Floor at 3 so the `---` separator stays valid for narrow
249
+ # columns like a one-digit count.
250
+ widths: List[int] = []
251
+ for col, header in enumerate(headers):
252
+ w = display_width(header)
253
+ for r in escaped_rows:
254
+ if col < len(r):
255
+ w = max(w, display_width(r[col]))
256
+ widths.append(max(w, 3))
257
+
258
+ def _pad(text: str, width: int, align: str) -> str:
259
+ extra = width - display_width(text)
260
+ if extra <= 0:
261
+ return text
262
+ if align == "right":
263
+ return " " * extra + text
264
+ if align == "center":
265
+ left = extra // 2
266
+ return " " * left + text + " " * (extra - left)
267
+ return text + " " * extra
268
+
269
+ def _sep(width: int, align: str) -> str:
270
+ if align == "right":
271
+ return "-" * (width - 1) + ":"
272
+ if align == "center":
273
+ return ":" + "-" * (width - 2) + ":"
274
+ return "-" * width
275
+
276
+ lines = [
277
+ "| " + " | ".join(_pad(headers[i], widths[i], aligns[i]) for i in range(len(headers))) + " |",
278
+ "| " + " | ".join(_sep(widths[i], aligns[i]) for i in range(len(headers))) + " |",
279
+ ]
280
+ for r in escaped_rows:
281
+ lines.append("| " + " | ".join(_pad(r[i], widths[i], aligns[i]) for i in range(len(headers))) + " |")
282
+ return "\n".join(lines)
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # Search-match highlighter (inline-code span around each match)
287
+ # ---------------------------------------------------------------------------
288
+
289
+
290
+ def highlight_md(text: str, highlight: Optional[Callable[[str], str]]) -> str:
291
+ """Apply the search-highlight callback if any; the renderer wraps
292
+ matches in inline-code spans (`` `match` ``) — visually distinct in
293
+ rich, portable in raw markdown."""
294
+ return highlight(text) if highlight else text
295
+
296
+
297
+ def make_md_highlighter(search_substring: Optional[str], search_regex: Optional[str]) -> Optional[Callable[[str], str]]:
298
+ """Build a `text -> highlighted text` callback that wraps each
299
+ regex/substring match in an inline-code span. Returns `None` when
300
+ no pattern is supplied or the pattern fails to compile."""
301
+ if not search_substring and not search_regex:
302
+ return None
303
+ if search_regex:
304
+ flags = 0
305
+ raw = search_regex
306
+ else:
307
+ flags = re.IGNORECASE
308
+ raw = re.escape(search_substring or "")
309
+ try:
310
+ rx = re.compile(raw, flags)
311
+ except re.error:
312
+ return None
313
+
314
+ def wrap_match(m: "re.Match[str]") -> str:
315
+ # Backticks inside the match would break the inline-code span;
316
+ # markdown-escape them so the surrounding `` … `` still renders.
317
+ inner = m.group(0).replace("`", "\\`")
318
+ return "`" + inner + "`"
319
+
320
+ def highlight(text: str) -> str:
321
+ if not text:
322
+ return text
323
+ return rx.sub(wrap_match, text)
324
+
325
+ return highlight