robotcode-repl 2.6.2__py3-none-any.whl → 2.8.0.dev548__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.
@@ -24,28 +24,33 @@ import shlex
24
24
  import sys
25
25
  from datetime import datetime
26
26
  from pathlib import Path
27
- from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Set, Tuple, TypeVar, Union
27
+ from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Set, Tuple, TypeVar, Union, cast
28
28
 
29
29
  import click
30
30
  from robot import result, running
31
+ from robot.errors import ExecutionFailed, ExecutionPassed
31
32
  from robot.running import Keyword
32
33
  from robot.running.context import EXECUTION_CONTEXTS
34
+ from robot.utils import normalize
33
35
 
34
36
  from robotcode.plugin import Application
35
37
  from robotcode.robot.diagnostics.library_doc import (
38
+ MARKDOWN_DOC_FORMAT,
36
39
  REST_DOC_FORMAT,
37
40
  ROBOT_DOC_FORMAT,
38
41
  LibraryDoc,
39
42
  convert_from_rest,
43
+ get_docstring_info,
40
44
  get_library_doc_from_library,
41
45
  get_resource_doc_from_resource,
42
46
  )
43
- from robotcode.robot.utils import get_robot_version_str
47
+ from robotcode.robot.utils import RF_VERSION, get_robot_version_str
48
+ from robotcode.robot.utils.markdown_docs import LinkResolver, anchor_link_resolver, normalize_markdown_doc
44
49
  from robotcode.robot.utils.markdownformatter import MarkDownFormatter
45
50
 
46
51
  from .__version__ import __version__
47
- from ._debug.types import Breakpoint, ResumeAction, StackFrame, StopEvent
48
- from ._indent import compute_indent
52
+ from ._debug.types import Breakpoint, ResumeAction, StackFrame, StopEvent, StopReason
53
+ from ._indent import compute_indent, starts_with_continuation
49
54
  from ._keyword_lookup import (
50
55
  _LIB_KEYWORDS_ATTR,
51
56
  iter_keyword_owners,
@@ -54,7 +59,7 @@ from ._keyword_lookup import (
54
59
  lookup_resource,
55
60
  )
56
61
  from ._session_export import render_robot_file
57
- from .base_interpreter import BaseInterpreter, is_true
62
+ from .base_interpreter import BaseInterpreter, ReplInput, is_true
58
63
 
59
64
  if TYPE_CHECKING:
60
65
  from ._debug.controller import DebugController
@@ -91,6 +96,19 @@ _SETTING_IMPORT_ALIASES = {
91
96
  "variables": "Import Variables",
92
97
  }
93
98
 
99
+ # Robot Framework 5.0 fails an invalid TRY without logging why; later versions log it.
100
+ if RF_VERSION < (6, 0):
101
+ from robot.running.model import Try as _RunningTry
102
+
103
+ def _unlogged_error(item: Any) -> Optional[str]:
104
+ return cast(Optional[str], item.error) if isinstance(item, _RunningTry) else None
105
+
106
+ else:
107
+
108
+ def _unlogged_error(item: Any) -> Optional[str]:
109
+ return None
110
+
111
+
94
112
  # `.help` groups, in display order. A command's group is set on `@dot_command`.
95
113
  _GROUP_ORDER = ("Session", "Debugger")
96
114
 
@@ -120,36 +138,58 @@ def dot_command(name: str, *aliases: str, group: str = "Session") -> Callable[[F
120
138
  return decorator
121
139
 
122
140
 
123
- # Variables that Robot itself sets in every suite — filtered out by
124
- # `.vars --user` so the listing focuses on what the user assigned.
125
- # Robot's built-in variables, matched by exact name or a `<PREFIX>_` / `<PREFIX> `
126
- # boundary (see `_is_robot_internal`) so a user variable like `${TESTDATA}` is NOT
127
- # mistaken for an internal one just because it starts with `TEST`.
128
- _ROBOT_INTERNAL_PREFIXES = (
129
- "CURDIR",
130
- "DEBUG_FILE",
131
- "EXECDIR",
132
- "FAILED",
133
- "KEYWORD",
134
- "LOG",
135
- "OPTIONS",
136
- "OUTPUT",
137
- "PASSED",
138
- "PREV",
139
- "REPORT",
140
- "ROBOT",
141
- "SPACE",
142
- "SUITE",
143
- "TASK",
144
- "TEMPDIR",
145
- "TEST",
146
- "TIMEOUT",
141
+ # The variables Robot Framework sets itself, normalized the way Robot compares
142
+ # variable names (case, spaces and underscores ignored). `.vars --user` hides
143
+ # them so the listing focuses on what the session or the suite defined.
144
+ # `${\n}` is a backslash followed by `n`.
145
+ _ROBOT_BUILTIN_VARIABLES = frozenset(
146
+ normalize(name, ignore="_")
147
+ for name in (
148
+ "/",
149
+ ":",
150
+ "\\n",
151
+ "SPACE",
152
+ "True",
153
+ "False",
154
+ "None",
155
+ "null",
156
+ "TEMPDIR",
157
+ "EXECDIR",
158
+ "OPTIONS",
159
+ "OUTPUT_DIR",
160
+ "OUTPUT_FILE",
161
+ "REPORT_FILE",
162
+ "LOG_FILE",
163
+ "DEBUG_FILE",
164
+ "LOG_LEVEL",
165
+ "PREV_TEST_NAME",
166
+ "PREV_TEST_STATUS",
167
+ "PREV_TEST_MESSAGE",
168
+ "SUITE_NAME",
169
+ "SUITE_SOURCE",
170
+ "SUITE_DOCUMENTATION",
171
+ "SUITE_METADATA",
172
+ "SUITE_STATUS",
173
+ "SUITE_MESSAGE",
174
+ "TEST_NAME",
175
+ "TEST_TAGS",
176
+ "TEST_DOCUMENTATION",
177
+ "TEST_METADATA",
178
+ "TEST_STATUS",
179
+ "TEST_MESSAGE",
180
+ "KEYWORD_STATUS",
181
+ "KEYWORD_MESSAGE",
182
+ )
147
183
  )
148
184
 
149
185
 
150
- def _is_robot_internal(bare: str) -> bool:
151
- """Whether a bare variable name is one of Robot's built-ins (boundary match)."""
152
- return any(bare == p or bare.startswith((p + "_", p + " ")) for p in _ROBOT_INTERNAL_PREFIXES)
186
+ def _is_robot_internal(name: str) -> bool:
187
+ """Whether a variable such as `${SUITE_NAME}` is one that Robot Framework sets itself,
188
+ or the REPL's result variable `${_}` (see `set_last_result`)."""
189
+ bare = name[2:-1] if len(name) > 2 and name[0] in "$@&%" and name[1] == "{" and name[-1] == "}" else name
190
+ normalized = normalize(bare, ignore="_")
191
+ # `${_}` normalizes to an empty name.
192
+ return not normalized or normalized in _ROBOT_BUILTIN_VARIABLES
153
193
 
154
194
 
155
195
  # Destructive commands that must never be reachable via prefix abbreviation — only
@@ -167,6 +207,8 @@ def _format_doc_to_md(text: str, doc_format: str) -> str:
167
207
  return MarkDownFormatter().format(text)
168
208
  if doc_format == REST_DOC_FORMAT:
169
209
  return convert_from_rest(text)
210
+ if doc_format == MARKDOWN_DOC_FORMAT:
211
+ return normalize_markdown_doc(text)
170
212
  return text
171
213
 
172
214
 
@@ -194,13 +236,18 @@ def _diagnostics_keyword_doc(owner: Any, is_resource: bool, kw_name: str) -> Opt
194
236
  return matches[0] if matches else None
195
237
 
196
238
 
197
- def _render_runtime_keyword_md(kw: Any, kw_name: str) -> str:
239
+ def _render_runtime_keyword_md(kw: Any, kw_name: str, is_resource: bool = False) -> str:
198
240
  """Hand-built fallback keyword page for resources / dynamic libs.
199
241
 
200
242
  Used only when the diagnostics loader can't surface a `KeywordDoc`
201
243
  for this keyword. Renders name + signature + tags + doc + source
202
244
  in a shape close to what `KeywordDoc.to_markdown` produces.
203
245
  """
246
+ try:
247
+ # splits the `Tags:` and, with Robot Framework 7.5, the `Args:`/`Returns:`/`Raises:` sections off
248
+ info = get_docstring_info(kw, kw, is_resource)
249
+ except Exception:
250
+ info = None
204
251
  md: List[str] = [f"### {kw_name}", ""]
205
252
 
206
253
  spec = getattr(kw, "args", None) or getattr(kw, "arguments", None)
@@ -213,12 +260,22 @@ def _render_runtime_keyword_md(kw: Any, kw_name: str) -> str:
213
260
  md.append(f"`{kw_name} {sig}`")
214
261
  md.append("")
215
262
 
216
- tags = list(getattr(kw, "tags", None) or [])
263
+ if info is not None:
264
+ for name, description in (info.argument_docs or {}).items():
265
+ md.append(f"- `{name}`: {description}")
266
+ if info.return_doc:
267
+ md.extend(["", f"**Returns**: {info.return_doc}"])
268
+ for name, description in info.raises or []:
269
+ md.extend(["", f"**Raises** `{name}`: {description}"])
270
+ if info.argument_docs or info.return_doc or info.raises:
271
+ md.append("")
272
+
273
+ tags = info.tags if info is not None else list(getattr(kw, "tags", None) or [])
217
274
  if tags:
218
275
  md.append("_Tags: " + ", ".join(str(t) for t in tags) + "_")
219
276
  md.append("")
220
277
 
221
- doc = getattr(kw, "doc", None) or ""
278
+ doc = (info.doc if info is not None else getattr(kw, "doc", None)) or ""
222
279
  if doc:
223
280
  md.append(_format_doc_to_md(doc, getattr(kw, "doc_format", ROBOT_DOC_FORMAT)))
224
281
 
@@ -257,6 +314,7 @@ def _build_save_parser() -> argparse.ArgumentParser:
257
314
  p = argparse.ArgumentParser(prog=".save", add_help=False, exit_on_error=False)
258
315
  p.add_argument("-a", "--append", action="store_true", help="Append to FILENAME instead of overwriting.")
259
316
  p.add_argument("-t", "--test-name", default="", help="Override the generated test-case name.")
317
+ p.add_argument("--keep-failed", action="store_true", help="Also export inputs whose run failed.")
260
318
  p.add_argument("filename")
261
319
  return p
262
320
 
@@ -303,6 +361,15 @@ class ConsoleInterpreter(BaseInterpreter):
303
361
  # REPL inputs that parsed cleanly — `.save` exports them as a
304
362
  # runnable `.robot` file. Each entry may be multi-line.
305
363
  self._session_lines: List[str] = []
364
+ # Indexes into `_session_lines` of inputs whose run failed; `.save`
365
+ # leaves them out unless `--keep-failed` is given.
366
+ self._failed_session_lines: Set[int] = set()
367
+ # Set when the input ended inside an unfinished block: that block still
368
+ # runs, then the session ends without reading again — on a terminal, a
369
+ # read after Ctrl-D waits for more input instead of ending.
370
+ self._input_ended = False
371
+ # Set while a prompt waits for input: SIGTERM then ends the wait (`_end_waiting`).
372
+ self._reading = False
306
373
 
307
374
  # Debug-frontend state. `_controller`/`_debug_completer` are wired by
308
375
  # `set_controller` when a debugger is attached; `_stop`/`_frame_no`/
@@ -416,6 +483,78 @@ class ConsoleInterpreter(BaseInterpreter):
416
483
  del multiline_continuation, prefill, completer # no editor/prefill/completion in plain mode
417
484
  return input(prompt)
418
485
 
486
+ def _read(self, prompt: str, **kwargs: Any) -> str:
487
+ """`read_line` that SIGTERM can end: it raises `EOFError` once the session or run
488
+ is to end, also while it waits."""
489
+ self._reading = True
490
+ try:
491
+ # Checked after marking the read: a SIGTERM in between can't be missed.
492
+ if self._termination_requested:
493
+ raise EOFError
494
+ return self.read_line(prompt, **kwargs)
495
+ finally:
496
+ self._reading = False
497
+
498
+ def _end_waiting(self) -> None:
499
+ # Nothing pauses any more, so a robot-debug run goes on to its end.
500
+ if self._controller is not None:
501
+ self._controller.set_attached(False)
502
+ if self._reading:
503
+ self._end_read()
504
+
505
+ def _end_read(self) -> None:
506
+ """End the read that waits for input, from the signal handler."""
507
+ raise EOFError
508
+
509
+ def _interrupt_waiting(self) -> None:
510
+ # The plain backend reads with `input()`, which sees Ctrl-C only as this signal; the prompts expect
511
+ # KeyboardInterrupt from Ctrl-C.
512
+ if self._reading:
513
+ raise KeyboardInterrupt
514
+
515
+ def _pause_on_interrupt(self) -> bool:
516
+ controller = self._controller
517
+ if (
518
+ controller is None
519
+ or not controller.attached
520
+ or controller.pause_requested # a second Ctrl-C interrupts the keyword that runs
521
+ or self._stop is not None # at a stop, Ctrl-C interrupts a keyword typed there
522
+ # The debugger sees the keywords of a robot-debug run, and in a REPL session those of a statement.
523
+ or not self._logger.enabled
524
+ or (self._in_repl_run and not self._running_statement)
525
+ ):
526
+ return False
527
+ controller.request_pause()
528
+ if self.app is not None:
529
+ try:
530
+ # To the console, past Robot Framework's capture of what a running keyword prints, and on a line of
531
+ # its own, as the console output of the run can be in the middle of one.
532
+ self.app.echo(
533
+ "\nPausing at the next keyword (Ctrl-C again interrupts the keyword that runs).",
534
+ file=sys.__stdout__,
535
+ )
536
+ except Exception:
537
+ # The signal can interrupt a write to the same stream, which then refuses this one; the pause stays
538
+ # requested.
539
+ pass
540
+ return True
541
+
542
+ def _interrupt_ends_run(self) -> bool:
543
+ # A robot-debug run (events forwarded outside a REPL session) without the debugger ends as a robot run
544
+ # does; REPL sessions, also while they start, keep their handling.
545
+ return (
546
+ self._logger.enabled
547
+ and not self._in_repl_run
548
+ and (self._controller is None or not self._controller.attached)
549
+ )
550
+
551
+ def _report_interrupt(self, prompt: str) -> None:
552
+ """Report Ctrl-C at a prompt as Python's REPL does. `input()` leaves the cursor after the prompt, so the
553
+ line of a shown prompt is ended first."""
554
+ if prompt:
555
+ self._echo("")
556
+ self._echo("KeyboardInterrupt")
557
+
419
558
  def make_completer(
420
559
  self, command_names: List[str], context_provider: Callable[[], Tuple[Any, Any]]
421
560
  ) -> Optional[Any]:
@@ -424,17 +563,18 @@ class ConsoleInterpreter(BaseInterpreter):
424
563
  del command_names, context_provider
425
564
  return None
426
565
 
427
- def show_doc(self, title: str, markdown: str, *, scroll_to: Optional[str] = None) -> None:
566
+ def show_doc(self, title: str, markdown: str, *, scroll_to: Optional[str] = None, outline: bool = False) -> None:
428
567
  """Display markdown to the user.
429
568
 
430
569
  Plain mode pages the raw markdown source with colour off — the
431
570
  backend choice signals "low-fi terminal", so we don't surface
432
571
  rich-rendered markdown the user explicitly opted out of. The
433
572
  prompt_toolkit interpreter overrides this with the scrollable
434
- doc-viewer Float. ``scroll_to`` is honoured only by that viewer;
435
- the pager ignores it.
573
+ doc-viewer Float. ``scroll_to`` and ``outline`` (a sidebar with the
574
+ headings of a library page) are honoured only by that viewer; the
575
+ pager ignores them.
436
576
  """
437
- del scroll_to # only the scrollable viewer (prompt_toolkit) uses it
577
+ del scroll_to, outline # only the scrollable viewer (prompt_toolkit) uses them
438
578
  body = f"{title}\n{'=' * len(title)}\n\n{markdown}"
439
579
  if self.app is not None:
440
580
  self.app.echo_via_pager(body, color=False)
@@ -464,6 +604,25 @@ class ConsoleInterpreter(BaseInterpreter):
464
604
  self.set_last_result(None)
465
605
  return super().run()
466
606
 
607
+ def run_input(self) -> None:
608
+ recorded = len(self._session_lines)
609
+ try:
610
+ super().run_input()
611
+ except ExecutionPassed as e:
612
+ # `Pass Execution` ends the input successfully, unless failures were
613
+ # continued before it — Robot Framework fails the test then.
614
+ if e.earlier_failures:
615
+ self._failed_session_lines.update(range(recorded, len(self._session_lines)))
616
+ raise
617
+ except BaseException:
618
+ # A failure, `Skip` or an interrupt: the input didn't run through.
619
+ self._failed_session_lines.update(range(recorded, len(self._session_lines)))
620
+ raise
621
+ finally:
622
+ # A pause that Ctrl-C asked for and the input didn't reach must not stop the next input.
623
+ if self._controller is not None:
624
+ self._controller.cancel_pause()
625
+
467
626
  # ------------------------------------------------------------------
468
627
  # Debug frontend — the interpreter *is* the debug core's `Frontend`.
469
628
  # A pause calls `wait_at_stop`, which renders the stop and runs a
@@ -488,15 +647,17 @@ class ConsoleInterpreter(BaseInterpreter):
488
647
  Dot-commands route through the shared dispatcher; the debugger
489
648
  commands (`.continue`/`.step`/…) set `_pending_action`, which ends
490
649
  the loop. Anything else is evaluated as a keyword in the paused
491
- context. EOF / Ctrl-C at the prompt continues the run.
650
+ context. EOF (Ctrl-D) at the prompt continues the run; Ctrl-C drops
651
+ the typed input and prompts again, as in pdb.
492
652
  """
493
653
  self._stop = stop
494
654
  self._frame_no = 0 # innermost frame selected
495
655
  self._pending_action = None
496
656
  # A breakpoint with attached `.commands` replays them at the hit; a
497
- # leading `silent` suppresses the stop banner (pdb semantics).
657
+ # leading `silent` suppresses the stop banner (pdb semantics), except
658
+ # when it carries a condition error (a breakpoint stop's only description).
498
659
  bp = stop.breakpoint
499
- silent = bool(bp is not None and bp.commands and bp.commands[0] == "silent")
660
+ silent = bool(bp is not None and bp.commands and bp.commands[0] == "silent" and not stop.description)
500
661
  if not silent:
501
662
  self._render_stop(stop)
502
663
  # try/finally so the stop state is always cleared, even if `.abort`
@@ -508,8 +669,12 @@ class ConsoleInterpreter(BaseInterpreter):
508
669
  self._replay_commands(bp.commands)
509
670
  while self._pending_action is None:
510
671
  try:
511
- line = self.read_line(self.DEBUG_PROMPT, completer=self._debug_completer)
512
- except (EOFError, KeyboardInterrupt):
672
+ line = self._read(self.DEBUG_PROMPT, completer=self._debug_completer)
673
+ except KeyboardInterrupt:
674
+ # Ctrl-C drops what was typed, as in pdb; the run stays paused.
675
+ self._report_interrupt(self.DEBUG_PROMPT)
676
+ continue
677
+ except EOFError:
513
678
  self._echo("")
514
679
  self._pending_action = ResumeAction.CONTINUE
515
680
  break
@@ -679,6 +844,11 @@ class ConsoleInterpreter(BaseInterpreter):
679
844
  don't re-trigger a stop, and in `forward_events(echo_messages=True)` so
680
845
  its log output surfaces at the prompt (suite output is muted otherwise).
681
846
  """
847
+ # At the end of a suite Robot Framework has already closed the suite in
848
+ # output.xml when we stop; a keyword written after it makes the file unreadable.
849
+ if self._stop is not None and self._stop.reason == StopReason.EXCEPTION and self._stop.frame.type == "SUITE":
850
+ self._echo("! Keywords can't run at the end of a suite: its output is already closed.")
851
+ return
682
852
  try:
683
853
  test, errors = self.get_test_body_from_string(line)
684
854
  except Exception as e: # surface any parse failure to the prompt
@@ -690,9 +860,14 @@ class ConsoleInterpreter(BaseInterpreter):
690
860
  return
691
861
  if not test.body:
692
862
  return
693
- with self._controller.suppress_pausing(), self.forward_events(echo_messages=True): # type: ignore[union-attr]
694
- for kw in test.body:
695
- self.set_last_result(self.run_keyword(kw))
863
+ # Keywords evaluated at a stop don't count for the session status.
864
+ record_failures, self.record_failures = self.record_failures, False
865
+ try:
866
+ with self._controller.suppress_pausing(), self.forward_events(echo_messages=True): # type: ignore[union-attr]
867
+ for kw in test.body:
868
+ self.set_last_result(self.run_keyword(kw))
869
+ finally:
870
+ self.record_failures = record_failures
696
871
  self._echo(f"=> {_repr.repr(self.last_result)}")
697
872
 
698
873
  # ------------------------------------------------------------------
@@ -733,6 +908,15 @@ class ConsoleInterpreter(BaseInterpreter):
733
908
  if target is not None:
734
909
  item.name = target
735
910
 
911
+ def _body_to_run(self, parsed: ReplInput) -> Iterator[Keyword]:
912
+ """The statements of `parsed` to run, with the setting-import aliases applied."""
913
+ self._alias_setting_imports(parsed.test)
914
+ for item in parsed.test.body:
915
+ error = _unlogged_error(item)
916
+ if error:
917
+ self.log_message(error, "FAIL")
918
+ yield item
919
+
736
920
  def get_input(self) -> Iterator[Optional[Keyword]]:
737
921
  if self.executed_files and not self.files and not self.inspect:
738
922
  raise EOFError
@@ -744,14 +928,12 @@ class ConsoleInterpreter(BaseInterpreter):
744
928
 
745
929
  text = file.read_text(encoding="utf-8")
746
930
 
747
- test, errors = self.get_test_body_from_string(text)
748
- if errors:
749
- return
750
-
751
- self._alias_setting_imports(test)
752
- for kw in test.body:
753
- yield kw
931
+ # Run like a test body: an invalid statement fails when execution reaches it.
932
+ yield from self._body_to_run(self.parse_input(text))
754
933
  else:
934
+ if self._input_ended:
935
+ raise EOFError
936
+
755
937
  lines: List[str] = []
756
938
  last_one = False
757
939
  while True:
@@ -761,7 +943,7 @@ class ConsoleInterpreter(BaseInterpreter):
761
943
 
762
944
  prefill = compute_indent(lines) if lines else ""
763
945
  try:
764
- text = self.read_line(
946
+ text = self._read(
765
947
  prompt,
766
948
  multiline_continuation=bool(lines),
767
949
  prefill=prefill,
@@ -775,31 +957,47 @@ class ConsoleInterpreter(BaseInterpreter):
775
957
  if self._dispatch_dot_command(text):
776
958
  return
777
959
  except KeyboardInterrupt:
778
- if len(lines) > 0:
779
- lines = []
780
- last_one = False
781
- continue
782
- raise
960
+ # Ctrl-C drops what was typed, as in Python's REPL; the session goes on.
961
+ self._report_interrupt(prompt)
962
+ lines = []
963
+ last_one = False
964
+ continue
965
+ except EOFError:
966
+ if not lines or self._termination_requested:
967
+ raise
968
+ # Handle the unfinished block like after a final empty line, so
969
+ # Robot Framework reports it, and end the session afterwards.
970
+ self._input_ended = True
971
+ text = ""
972
+ # The report starts on a line of its own, not after the prompt.
973
+ if prompt:
974
+ self._echo("")
975
+
976
+ if not lines and starts_with_continuation(text):
977
+ # At the start of an input there is nothing left to continue.
978
+ message = "The line starting with '...' does not continue a statement, it starts a new input."
979
+ self.log_message(message, "FAIL")
980
+ raise ExecutionFailed(message)
783
981
 
784
982
  lines.append(text)
785
983
 
786
- test, errors = self.get_test_body_from_string("\n".join(lines))
984
+ parsed = self.parse_input("\n".join(lines))
787
985
 
788
986
  if len(lines) > 1 and lines[-1] == "" and text == "":
789
987
  last_one = True
790
988
 
791
- if errors:
792
- if not last_one:
793
- continue
989
+ # Only an unfinished block waits for more lines; complete input runs
990
+ # even when it is invalid, so Robot Framework reports it.
991
+ if parsed.incomplete and not last_one:
992
+ continue
794
993
 
795
- # Record cleanly-parsed inputs for `.save`. Error-only
796
- # inputs are skipped so the exported file stays runnable.
797
- if test.body:
994
+ # Record cleanly-parsed inputs for `.save` (`run_input` marks the
995
+ # ones whose run fails). Inputs with errors are skipped so the
996
+ # exported file stays runnable.
997
+ if parsed.test.body and not parsed.errors:
798
998
  self._session_lines.append("\n".join(lines))
799
999
 
800
- self._alias_setting_imports(test)
801
- for kw in test.body:
802
- yield kw
1000
+ yield from self._body_to_run(parsed)
803
1001
 
804
1002
  break
805
1003
 
@@ -973,28 +1171,26 @@ class ConsoleInterpreter(BaseInterpreter):
973
1171
 
974
1172
  Options:
975
1173
  --user Hide Robot's built-in variables (`${SUITE_NAME}`,
976
- `${OUTPUT_DIR}`, `${TEMPDIR}`, …) so the listing focuses
977
- on what the user assigned in the session. Has no effect at a
978
- debug stop, where variables are grouped by scope instead.
1174
+ `${OUTPUT_DIR}`, `${TEMPDIR}`, `${True}`, …) and the
1175
+ result variable `${_}` so the listing focuses on what the
1176
+ session or the suite defined. At a debug stop it filters
1177
+ every scope of the listing.
979
1178
  """
980
1179
  if self.app is None:
981
1180
  return
1181
+ only_user = "--user" in arg.split()
982
1182
  if self._stop is not None:
983
- self._show_frame_scopes()
1183
+ self._show_frame_scopes(only_user)
984
1184
  return
985
1185
  ctx = EXECUTION_CONTEXTS.current
986
1186
  if ctx is None:
987
1187
  self.app.echo("(no active context)")
988
1188
  return
989
- only_user = "--user" in arg.split()
990
1189
 
991
1190
  rows: List[Tuple[str, str]] = []
992
1191
  for decorated, value in ctx.variables.as_dict().items():
993
1192
  name = str(decorated)
994
- bare = name
995
- if len(name) > 2 and name[0] in "$@&%" and name[1] == "{" and name[-1] == "}":
996
- bare = name[2:-1]
997
- if only_user and _is_robot_internal(bare):
1193
+ if only_user and _is_robot_internal(name):
998
1194
  continue
999
1195
  try:
1000
1196
  rep = repr(value)
@@ -1011,7 +1207,7 @@ class ConsoleInterpreter(BaseInterpreter):
1011
1207
  for name, rep in rows:
1012
1208
  self.app.echo(f" {name:<{name_w}} {rep}")
1013
1209
 
1014
- def _show_frame_scopes(self) -> None:
1210
+ def _show_frame_scopes(self, only_user: bool) -> None:
1015
1211
  """`.vars` at a debug stop — the selected frame's scopes, via the controller."""
1016
1212
  scopes = self._controller.get_scopes(self._selected_frame()) if self._controller is not None else []
1017
1213
  if not scopes:
@@ -1019,9 +1215,10 @@ class ConsoleInterpreter(BaseInterpreter):
1019
1215
  return
1020
1216
  for scope in scopes:
1021
1217
  self._echo(f"{scope.name}:")
1022
- if not scope.variables:
1218
+ variables = [v for v in scope.variables if not (only_user and _is_robot_internal(v.name))]
1219
+ if not variables:
1023
1220
  self._echo(" (none)")
1024
- for var in scope.variables:
1221
+ for var in variables:
1025
1222
  self._echo(f" {var.name} = {var.value}")
1026
1223
 
1027
1224
  @dot_command("kw")
@@ -1076,8 +1273,16 @@ class ConsoleInterpreter(BaseInterpreter):
1076
1273
  kw_name = getattr(runtime_kw, "name", name)
1077
1274
  diag_kw = _diagnostics_keyword_doc(owner, is_resource, kw_name)
1078
1275
  if diag_kw is not None:
1079
- return kw_name, diag_kw.to_markdown(header_level=1)
1080
- return kw_name, _render_runtime_keyword_md(runtime_kw, kw_name)
1276
+ link_resolver = self._keyword_link_resolver(str(getattr(owner, "name", "")))
1277
+ return kw_name, diag_kw.to_markdown(header_level=1, link_resolver=link_resolver)
1278
+ return kw_name, _render_runtime_keyword_md(runtime_kw, kw_name, is_resource)
1279
+
1280
+ def _keyword_link_resolver(self, owner_name: str) -> Optional[LinkResolver]:
1281
+ """Where references in a keyword page go. Nothing can be followed here,
1282
+ so they stay inline code; the prompt_toolkit backend overrides this to
1283
+ link to the pages of other keywords."""
1284
+ del owner_name
1285
+ return None
1081
1286
 
1082
1287
  def _list_keywords(self, pattern: Optional[str]) -> None:
1083
1288
  """List loaded keywords grouped by owner, optionally filtered by a
@@ -1149,7 +1354,11 @@ class ConsoleInterpreter(BaseInterpreter):
1149
1354
  self.app.echo(error or f"Could not load {arg!r}.")
1150
1355
  return
1151
1356
 
1152
- self.show_doc(lib_doc.name, lib_doc.to_markdown(only_doc=False, header_level=1))
1357
+ self.show_doc(
1358
+ lib_doc.name,
1359
+ lib_doc.to_markdown(only_doc=False, header_level=0, link_resolver=anchor_link_resolver),
1360
+ outline=True,
1361
+ )
1153
1362
 
1154
1363
  def _resolve_doc_target(self, arg: str) -> Tuple[Optional[LibraryDoc], Optional[str]]:
1155
1364
  """Build the `LibraryDoc` for an imported library or resource.
@@ -1193,9 +1402,11 @@ class ConsoleInterpreter(BaseInterpreter):
1193
1402
  def _cwd(self, arg: str) -> None:
1194
1403
  """Show the current working directory.
1195
1404
 
1196
- Print the current working directory — the base path that relative
1197
- `Import Resource`, `Import Library`, and file-based variable
1198
- references resolve against.
1405
+ Print the working directory of the REPL process — the project root.
1406
+ Relative file paths passed to keywords such as `File Should Exist`
1407
+ resolve against it. `${CURDIR}` and, on Robot Framework 7.4 and newer,
1408
+ relative imports use the directory of `--source` instead, or the
1409
+ directory the REPL was started from if `--source` isn't given.
1199
1410
  """
1200
1411
  del arg
1201
1412
  if self.app is None:
@@ -1207,32 +1418,48 @@ class ConsoleInterpreter(BaseInterpreter):
1207
1418
 
1208
1419
  @dot_command("exit", "quit")
1209
1420
  def _exit(self, arg: str) -> None:
1210
- """Exit the REPL.
1421
+ """Exit the REPL: .exit [CODE]
1211
1422
 
1212
- Leave the REPL. Equivalent to pressing Ctrl-D on an empty prompt.
1423
+ Leave the REPL. Without CODE this is the same as pressing Ctrl-D on
1424
+ an empty prompt. With CODE, `robotcode repl` exits with that exit code
1425
+ instead of the one it derives from the session status.
1213
1426
  `.exit` and `.quit` are aliases. At a debug stop "exit" is ambiguous,
1214
1427
  so it points you at the resume/abort commands instead of leaving.
1428
+
1429
+ Usage:
1430
+ .exit [CODE]
1431
+
1432
+ Examples:
1433
+ .exit
1434
+ .exit 3
1215
1435
  """
1216
- del arg
1217
1436
  if self._stop is not None:
1218
1437
  self._echo("at a debug stop — use .continue or .detach to resume, or .abort to quit the run")
1219
1438
  return
1439
+ if arg:
1440
+ try:
1441
+ self.exit_code = int(arg)
1442
+ except ValueError:
1443
+ self._echo("Usage: .exit [CODE]")
1444
+ return
1220
1445
  raise EOFError
1221
1446
 
1222
1447
  @dot_command("save")
1223
1448
  def _save(self, arg: str) -> None:
1224
- """Save session as a .robot file: .save [-a] [-t NAME] FILENAME
1449
+ """Save session as a .robot file: .save [-a] [-t NAME] [--keep-failed] FILENAME
1225
1450
 
1226
- Save the current REPL session as a runnable `.robot` file. Only
1227
- lines Robot could parse are exported; lines that failed are
1228
- skipped so the result stays runnable with `robot <filename>`.
1451
+ Save the current REPL session as a runnable `.robot` file. Inputs
1452
+ with syntax errors are never exported. Inputs whose run failed or
1453
+ was skipped are left out too, so the result runs with
1454
+ `robot <filename>` the way the session did; `--keep-failed` keeps
1455
+ them.
1229
1456
 
1230
1457
  Imports become a `*** Settings ***` section (so
1231
1458
  `Import Library Collections` becomes `Library Collections`),
1232
1459
  and everything else goes into a single `*** Test Cases ***` block.
1233
1460
 
1234
1461
  Usage:
1235
- .save [-a] [-t NAME] FILENAME
1462
+ .save [-a] [-t NAME] [--keep-failed] FILENAME
1236
1463
 
1237
1464
  Options:
1238
1465
  -a, --append Append to FILENAME instead of overwriting.
@@ -1243,6 +1470,8 @@ class ConsoleInterpreter(BaseInterpreter):
1243
1470
  Override the default test-case name. By
1244
1471
  default the test is called
1245
1472
  `REPL Session <ISO-timestamp>`.
1473
+ --keep-failed Also export inputs whose run failed,
1474
+ e.g. to reproduce a failing check.
1246
1475
 
1247
1476
  Examples:
1248
1477
  .save scratch.robot
@@ -1255,13 +1484,17 @@ class ConsoleInterpreter(BaseInterpreter):
1255
1484
  try:
1256
1485
  opts = parser.parse_args(_split_command_args(arg))
1257
1486
  except (argparse.ArgumentError, SystemExit, ValueError):
1258
- self.app.echo("Usage: .save [-a] [-t NAME] FILENAME")
1487
+ self.app.echo("Usage: .save [-a] [-t NAME] [--keep-failed] FILENAME")
1259
1488
  return
1260
1489
 
1261
- lines: List[str] = list(self._session_lines or [])
1262
- if not lines:
1490
+ recorded: List[str] = list(self._session_lines or [])
1491
+ if not recorded:
1263
1492
  self.app.echo("Nothing to save — the session has no recorded inputs yet.")
1264
1493
  return
1494
+ lines = [line for i, line in enumerate(recorded) if opts.keep_failed or i not in self._failed_session_lines]
1495
+ if not lines:
1496
+ self.app.echo("Nothing to save — every recorded input failed; `--keep-failed` exports them anyway.")
1497
+ return
1265
1498
 
1266
1499
  content = render_robot_file(lines, test_name=opts.test_name)
1267
1500
  path = Path(opts.filename)
@@ -1280,7 +1513,11 @@ class ConsoleInterpreter(BaseInterpreter):
1280
1513
  self.app.echo(f"Could not write {opts.filename}: {e}")
1281
1514
  return
1282
1515
 
1283
- self.app.echo(f"Wrote {path} ({len(lines)} entries)")
1516
+ summary = f"{len(lines)} {'entry' if len(lines) == 1 else 'entries'}"
1517
+ left_out = len(recorded) - len(lines)
1518
+ if left_out:
1519
+ summary += f"; {left_out} failed {'input' if left_out == 1 else 'inputs'} left out, see --keep-failed"
1520
+ self.app.echo(f"Wrote {path} ({summary})")
1284
1521
 
1285
1522
  # ------------------------------------------------------------------
1286
1523
  # Debugger dot-commands — active while paused at a stop (`group=
@@ -1520,13 +1757,16 @@ class ConsoleInterpreter(BaseInterpreter):
1520
1757
 
1521
1758
  @dot_command("set", group="Debugger")
1522
1759
  def _set(self, arg: str) -> None:
1523
- """Set a scalar variable in the selected frame — `.set ${x} <value>`.
1524
-
1525
- The value is variable-substituted and stored as-is (like `Set Variable`):
1526
- `.set ${name} hello` sets the string; use `${{ ... }}` for Python. Only
1527
- whole scalar variables (`${...}`) are supported — not list/dict variables
1528
- (`@{...}`/`&{...}`) or item access (`${x}[0]`). The assignment lands in
1529
- the selected frame's local scope.
1760
+ """Change an existing scalar variable in the selected frame — `.set ${x} <value>`.
1761
+
1762
+ Only variables that already exist can be changed; create a new one with
1763
+ `${x}= Set Variable …` at the prompt. The value is
1764
+ variable-substituted and stored as a string: `.set ${name} hello` sets
1765
+ `hello`, and `.set ${count} 7` sets the string `7`. For other types,
1766
+ assign at the prompt, e.g. `${items}= Evaluate [1, 2]`. Only whole
1767
+ scalar variables (`${...}`) are supported — not list/dict variables
1768
+ (`@{...}`/`&{...}`) or item access (`${x}[0]`). The value lands in the
1769
+ selected frame's local scope.
1530
1770
  """
1531
1771
  if not self._require_stop():
1532
1772
  return
@@ -1574,7 +1814,7 @@ class ConsoleInterpreter(BaseInterpreter):
1574
1814
 
1575
1815
  @dot_command("break", "b", group="Debugger")
1576
1816
  def _break(self, arg: str) -> None:
1577
- """Add a breakpoint — `.break file:line` / `.break Keyword Name` / `.break <loc>, <condition>`."""
1817
+ """Add a breakpoint — `.break file:line` / `.break Keyword Name or pattern` / `.break <loc>, <condition>`."""
1578
1818
  self._add_breakpoint(arg, temporary=False)
1579
1819
 
1580
1820
  @dot_command("tbreak", group="Debugger")
@@ -1605,7 +1845,7 @@ class ConsoleInterpreter(BaseInterpreter):
1605
1845
  if bp.log_message is not None:
1606
1846
  flags.append("logpoint")
1607
1847
  if bp.commands:
1608
- flags.append(f"{len(bp.commands)} cmds")
1848
+ flags.append(f"{len(bp.commands)} {'cmd' if len(bp.commands) == 1 else 'cmds'}")
1609
1849
  suffix = (" " + ", ".join(flags)) if flags else ""
1610
1850
  self._echo(f" #{bp.id} {bp.kind:<7} {loc}{suffix}")
1611
1851
  filters = controller.exception_filters
@@ -1735,8 +1975,13 @@ class ConsoleInterpreter(BaseInterpreter):
1735
1975
  collected: List[str] = []
1736
1976
  while True:
1737
1977
  try:
1738
- line = self.read_line("(com) ")
1739
- except (EOFError, KeyboardInterrupt):
1978
+ line = self._read("(com) ")
1979
+ except KeyboardInterrupt:
1980
+ # Ctrl-C aborts the entry, as in pdb; the breakpoint keeps its commands.
1981
+ self._report_interrupt("(com) ")
1982
+ self._echo(f"breakpoint {bp.id}: commands unchanged")
1983
+ return
1984
+ except EOFError:
1740
1985
  break
1741
1986
  line = line.strip()
1742
1987
  if line == "end":