robotcode-runner 2.5.0__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.
- {robotcode_runner-2.5.0 → robotcode_runner-2.6.0}/.gitignore +5 -1
- {robotcode_runner-2.5.0 → robotcode_runner-2.6.0}/PKG-INFO +7 -5
- {robotcode_runner-2.5.0 → robotcode_runner-2.6.0}/pyproject.toml +7 -4
- robotcode_runner-2.6.0/src/robotcode/runner/__version__.py +1 -0
- {robotcode_runner-2.5.0 → robotcode_runner-2.6.0}/src/robotcode/runner/cli/__init__.py +2 -1
- robotcode_runner-2.6.0/src/robotcode/runner/cli/_markdown.py +325 -0
- robotcode_runner-2.6.0/src/robotcode/runner/cli/_search.py +382 -0
- robotcode_runner-2.6.0/src/robotcode/runner/cli/discover/_models.py +68 -0
- robotcode_runner-2.6.0/src/robotcode/runner/cli/discover/_render.py +447 -0
- {robotcode_runner-2.5.0 → robotcode_runner-2.6.0}/src/robotcode/runner/cli/discover/discover.py +255 -230
- robotcode_runner-2.6.0/src/robotcode/runner/cli/results/__init__.py +3 -0
- robotcode_runner-2.6.0/src/robotcode/runner/cli/results/_html.py +540 -0
- robotcode_runner-2.6.0/src/robotcode/runner/cli/results/_models.py +235 -0
- robotcode_runner-2.6.0/src/robotcode/runner/cli/results/_render.py +908 -0
- robotcode_runner-2.6.0/src/robotcode/runner/cli/results/results.py +1975 -0
- {robotcode_runner-2.5.0 → robotcode_runner-2.6.0}/src/robotcode/runner/cli/robot.py +6 -0
- {robotcode_runner-2.5.0 → robotcode_runner-2.6.0}/src/robotcode/runner/hooks.py +2 -2
- robotcode_runner-2.5.0/src/robotcode/runner/__version__.py +0 -1
- {robotcode_runner-2.5.0 → robotcode_runner-2.6.0}/README.md +0 -0
- {robotcode_runner-2.5.0 → robotcode_runner-2.6.0}/src/robotcode/runner/__init__.py +0 -0
- {robotcode_runner-2.5.0 → robotcode_runner-2.6.0}/src/robotcode/runner/cli/discover/__init__.py +0 -0
- {robotcode_runner-2.5.0 → robotcode_runner-2.6.0}/src/robotcode/runner/cli/libdoc.py +0 -0
- {robotcode_runner-2.5.0 → robotcode_runner-2.6.0}/src/robotcode/runner/cli/rebot.py +0 -0
- {robotcode_runner-2.5.0 → robotcode_runner-2.6.0}/src/robotcode/runner/cli/testdoc.py +0 -0
- {robotcode_runner-2.5.0 → 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.
|
|
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-
|
|
30
|
-
Requires-Dist: robotcode-
|
|
31
|
-
Requires-Dist: robotcode
|
|
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
|