robotcode-repl 2.7.0__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.
@@ -37,6 +37,7 @@ from robot.api import get_tokens
37
37
  from robotcode.robot.diagnostics.semantic_analyzer.enums import TokenKind
38
38
  from robotcode.robot.diagnostics.semantic_analyzer.nodes import SemanticToken
39
39
  from robotcode.robot.diagnostics.semantic_analyzer.variable_tokenizer import (
40
+ VariableSite,
40
41
  build_variable_sub_tokens,
41
42
  )
42
43
 
@@ -145,7 +146,9 @@ def _split_variable(value: str) -> Iterable[Tuple[str, str]]:
145
146
  still renders (just without per-part colouring).
146
147
  """
147
148
  try:
148
- sub_tokens = build_variable_sub_tokens(value, line=1, col_offset=0)
149
+ # The lexer cannot tell definitions from usages, so it splits like a
150
+ # definition to show the type hints of `VAR` and `FOR` variables.
151
+ sub_tokens = build_variable_sub_tokens(value, line=1, col_offset=0, site=VariableSite.DECLARATION)
149
152
  except Exception:
150
153
  yield (_VARIABLE_FALLBACK_STYLE, value)
151
154
  return
@@ -1,19 +1,26 @@
1
1
  import abc
2
2
  import contextlib
3
+ import os
3
4
  import signal
5
+ import sys
6
+ from dataclasses import dataclass
4
7
  from datetime import datetime
5
8
  from io import StringIO
6
9
  from pathlib import Path
7
10
  from typing import TYPE_CHECKING, Any, Iterator, List, Optional, Protocol, Tuple, Union, cast
8
11
 
9
12
  from robot.api import get_model
10
- from robot.errors import ExecutionStatus
13
+ from robot.errors import ExecutionFailed, ExecutionPassed, ExecutionStatus
11
14
  from robot.output import LOGGER
12
15
  from robot.output import Message as OutputMessage
16
+ from robot.parsing.lexer.tokens import Token
17
+ from robot.parsing.model.blocks import For, If, Try, While
18
+ from robot.parsing.model.statements import Error, Statement
13
19
  from robot.result import Keyword as ResultKeyword
14
20
  from robot.running import Keyword, TestCase, TestSuite
15
21
  from robot.running.context import EXECUTION_CONTEXTS
16
22
  from robot.running.signalhandler import STOP_SIGNAL_MONITOR, _StopSignalMonitor
23
+ from robot.utils import normalize
17
24
 
18
25
  from robotcode.robot.utils import RF_VERSION
19
26
  from robotcode.robot.utils.ast import iter_nodes
@@ -54,13 +61,89 @@ def _register_signal_handler(self: Any, exsignum: Any) -> None:
54
61
  pass
55
62
 
56
63
 
64
+ # Robot Framework's own handling, kept for the signals that end a run outside a REPL session (a robot-debug run).
65
+ _robot_monitor_call = _StopSignalMonitor.__call__
66
+ _robot_stop_execution_gracefully = _StopSignalMonitor._stop_execution_gracefully
67
+
68
+ # The interpreter of this process; the patched monitor hands the signals to it.
69
+ _signal_interpreter: Optional["BaseInterpreter"] = None
70
+ # Signals received so far that end the session or the run: SIGTERM, and SIGINT in a run without the
71
+ # debugger. Counted here: Robot Framework resets its count after every REPL statement.
72
+ _ending_signal_count = 0
73
+ # Set by such a signal outside a REPL session: stopping then ends the run as Robot Framework does.
74
+ _signal_ends_run = False
75
+
76
+
57
77
  def _stop_signal_monitor_call(self: Any, signum: Any, frame: Any) -> None:
78
+ if signum == signal.SIGTERM:
79
+ _on_sigterm(self, signum, frame)
80
+ return
81
+
82
+ if _signal_interpreter is not None:
83
+ # SIGINT at a prompt ends the wait for input, as Ctrl-C does with prompt_toolkit.
84
+ _signal_interpreter._interrupt_waiting()
85
+ # With the debugger attached, SIGINT first pauses at the next keyword, as pdb does.
86
+ if _signal_interpreter._pause_on_interrupt():
87
+ return
88
+ # Without the debugger, SIGINT ends a robot-debug run as it ends a Robot Framework run.
89
+ if _signal_interpreter._interrupt_ends_run():
90
+ _count_ending_signal()
91
+ _end_run(self, signum, frame)
92
+ return
58
93
  if self._running_keyword:
94
+ # SIGINT interrupts the running keyword; the session goes on.
59
95
  self._stop_execution_gracefully()
60
96
 
61
97
 
98
+ def _count_ending_signal() -> None:
99
+ """Count a signal that ends the session or the run; the second one ends the process at once."""
100
+ global _ending_signal_count
101
+ _ending_signal_count += 1
102
+ if _ending_signal_count > 1:
103
+ try:
104
+ if sys.__stderr__ is not None:
105
+ sys.__stderr__.write("Execution forcefully stopped.\n")
106
+ finally:
107
+ os._exit(253)
108
+
109
+
110
+ def _on_sigterm(monitor: Any, signum: Any, frame: Any) -> None:
111
+ _count_ending_signal()
112
+
113
+ interpreter = _signal_interpreter
114
+ if interpreter is not None and interpreter._in_repl_run:
115
+ if interpreter._running_statement:
116
+ # As in a Robot Framework run, the next keyword that starts stops the statement;
117
+ # Robot Framework resets the count when the statement ends.
118
+ monitor._signal_count += 1
119
+ interpreter._terminate_session(monitor._running_keyword)
120
+ return
121
+
122
+ _end_run(monitor, signum, frame)
123
+
124
+
125
+ def _end_run(monitor: Any, signum: Any, frame: Any) -> None:
126
+ # A run, such as robot-debug: Robot Framework ends it, at once while a keyword runs
127
+ # (this raises), otherwise at the next keyword; the interpreter only stops waiting.
128
+ global _signal_ends_run
129
+ _signal_ends_run = True
130
+ interpreter = _signal_interpreter
131
+ if interpreter is not None:
132
+ interpreter._termination_requested = True
133
+ try:
134
+ _robot_monitor_call(monitor, signum, frame)
135
+ finally:
136
+ if interpreter is not None:
137
+ interpreter._end_waiting()
138
+
139
+
62
140
  def _stop_signal_monitor_stop_execution_gracefully(self: Any) -> None:
63
- raise ExecutionInterrupted("Execution interrupted")
141
+ if _signal_ends_run:
142
+ _robot_stop_execution_gracefully(self)
143
+ elif _ending_signal_count:
144
+ raise ExecutionInterrupted("Execution terminated by signal")
145
+ else:
146
+ raise ExecutionInterrupted("Execution interrupted")
64
147
 
65
148
 
66
149
  _patched = False
@@ -80,7 +163,14 @@ def _patch() -> None:
80
163
  if RF_VERSION >= (7, 0):
81
164
 
82
165
  def _run_keyword(kw: Keyword, context: Any) -> Any:
83
- return kw.run(context.steps[-1][1], context)
166
+ # A keyword typed at a stop becomes a child of the running step. At a stop
167
+ # where no step runs any more, such as the end of a test, it goes to the
168
+ # test, and without a test to a detached result.
169
+ if context.steps:
170
+ parent = context.steps[-1][1]
171
+ else:
172
+ parent = context.test if context.test is not None else ResultKeyword()
173
+ return kw.run(parent, context)
84
174
 
85
175
  else:
86
176
 
@@ -109,6 +199,80 @@ def result_qualified_name(result: Any) -> Optional[str]:
109
199
  return _keyword_qualified_name(result) if isinstance(result, ResultKeyword) else None
110
200
 
111
201
 
202
+ # The definition name of a keyword with embedded arguments (`Select ${animal} From List`),
203
+ # bare and qualified with its owner. Robot Framework sets it before the keyword starts, as
204
+ # `source_name` from version 7 on and as `sourcename` before; other keywords have none.
205
+ if RF_VERSION >= (7, 0):
206
+
207
+ def _keyword_definition_names(keyword: ResultKeyword) -> Tuple[str, ...]:
208
+ if not keyword.source_name:
209
+ return ()
210
+ if not keyword.owner:
211
+ return (keyword.source_name,)
212
+ return (keyword.source_name, f"{keyword.owner}.{keyword.source_name}")
213
+
214
+ else:
215
+
216
+ def _keyword_definition_names(keyword: ResultKeyword) -> Tuple[str, ...]:
217
+ if not keyword.sourcename:
218
+ return ()
219
+ if not keyword.libname:
220
+ return (keyword.sourcename,)
221
+ return (keyword.sourcename, f"{keyword.libname}.{keyword.sourcename}")
222
+
223
+
224
+ def result_definition_names(result: Any) -> Tuple[str, ...]:
225
+ return _keyword_definition_names(result) if isinstance(result, ResultKeyword) else ()
226
+
227
+
228
+ # Blocks that stay open until their END. Their branches (ELSE, EXCEPT, …) are blocks of
229
+ # the same classes with another header and never get an END of their own.
230
+ if RF_VERSION >= (7, 2):
231
+ from robot.parsing.model.blocks import Group
232
+
233
+ _END_BLOCKS: Tuple[Any, ...] = (For, While, If, Try, Group)
234
+ _BLOCK_OPENERS = frozenset({Token.FOR, Token.WHILE, Token.IF, Token.TRY, Token.GROUP})
235
+ else:
236
+ _END_BLOCKS = (For, While, If, Try)
237
+ _BLOCK_OPENERS = frozenset({Token.FOR, Token.WHILE, Token.IF, Token.TRY})
238
+
239
+ # A REPL input runs as the body of the session test, so it can't have settings: neither
240
+ # test settings nor the keyword settings that a test reports as not allowed.
241
+ _SETTING_TYPES = frozenset(
242
+ {Token.DOCUMENTATION, Token.TAGS, Token.SETUP, Token.TEARDOWN, Token.TEMPLATE, Token.TIMEOUT}
243
+ )
244
+ _SETTING_NAMES = frozenset({"documentation", "tags", "setup", "teardown", "template", "timeout", "arguments", "return"})
245
+
246
+
247
+ def _mark_settings_as_errors(test: Any) -> None:
248
+ """Mark the settings of a REPL input as errors, as Robot Framework's lexer marks a
249
+ setting a test can't have, so that Robot Framework reports them like a non-existing
250
+ setting."""
251
+ for i, node in enumerate(test.body):
252
+ if not isinstance(node, Statement):
253
+ continue
254
+ if node.type in _SETTING_TYPES:
255
+ node = test.body[i] = Error(
256
+ [Token(Token.ERROR, t.value, t.lineno, t.col_offset) if t.type == node.type else t for t in node.tokens]
257
+ )
258
+ if isinstance(node, Error):
259
+ for token in node.get_tokens(Token.ERROR):
260
+ name = token.value[1:-1].strip() if token.value.startswith("[") and token.value.endswith("]") else ""
261
+ if normalize(name) in _SETTING_NAMES:
262
+ token.error = f"Setting '{name}' is not allowed in the REPL."
263
+
264
+
265
+ @dataclass
266
+ class ReplInput:
267
+ """REPL input parsed as a test body by `BaseInterpreter.parse_input`."""
268
+
269
+ test: TestCase
270
+ # The parse errors of the input.
271
+ errors: List[str]
272
+ # True while a block of the input lacks its END, so more lines can complete it.
273
+ incomplete: bool
274
+
275
+
112
276
  if RF_VERSION < (7, 0):
113
277
 
114
278
  class InterpreterLogger:
@@ -231,9 +395,24 @@ else:
231
395
  self.interpreter._notify_end_suite(data, result)
232
396
 
233
397
 
398
+ # The interpreter whose prompt loop started last. `_maybe_trigger_repl` sets it when
399
+ # the `repl` marker keyword starts; the marker's body takes it once the loop has returned.
400
+ _active_interpreter: Optional["BaseInterpreter"] = None
401
+
402
+
403
+ def take_active_interpreter() -> Optional["BaseInterpreter"]:
404
+ """Return the interpreter whose prompt loop started last and forget it, so a later
405
+ session in the same process never sees it."""
406
+ global _active_interpreter
407
+ interpreter, _active_interpreter = _active_interpreter, None
408
+ return interpreter
409
+
410
+
234
411
  class BaseInterpreter(abc.ABC):
235
412
  def __init__(self) -> None:
236
413
  _patch()
414
+ global _signal_interpreter
415
+ _signal_interpreter = self
237
416
 
238
417
  self._logger = InterpreterLogger(self)
239
418
  LOGGER.register_logger(self._logger)
@@ -248,6 +427,17 @@ class BaseInterpreter(abc.ABC):
248
427
  # The interpreter's own start_keyword/end_keyword hooks stay the primary
249
428
  # consumer; observers run alongside them.
250
429
  self._observers: List["ExecutionObserver"] = []
430
+ # Unhandled failures of the prompt loop, recorded only while `record_failures`
431
+ # is set (by `robotcode repl`). The `repl` marker keyword fails the session test
432
+ # with them.
433
+ self.record_failures = False
434
+ self.failures: List[ExecutionFailed] = []
435
+ # The code given to `.exit`/`.quit`, None without one.
436
+ self.exit_code: Optional[int] = None
437
+ # Set by a signal that ends the session or the run.
438
+ self._termination_requested = False
439
+ # Set while a statement of the input runs: SIGTERM then stops it at its next keyword.
440
+ self._running_statement = False
251
441
 
252
442
  @property
253
443
  def curdir(self) -> Path:
@@ -269,7 +459,7 @@ class BaseInterpreter(abc.ABC):
269
459
 
270
460
  return []
271
461
 
272
- def get_test_body_from_string(self, command: str) -> Tuple[TestCase, List[str]]:
462
+ def _parse_test_body(self, command: str) -> Tuple[Any, TestCase]:
273
463
  suite_str = (
274
464
  "*** Test Cases ***\nDummyTestCase423141592653589793\n "
275
465
  + ("\n ".join(command.split("\n")) if "\n" in command else command)
@@ -278,16 +468,45 @@ class BaseInterpreter(abc.ABC):
278
468
  with StringIO(suite_str) as source:
279
469
  model = get_model(source, curdir=str(self.curdir).replace("\\", "\\\\"))
280
470
 
281
- model.source = self.source
471
+ # RF < 6.1 expects a `str` source; RF 6.1+ converts it to a `Path` itself.
472
+ model.source = str(self.source) if self.source is not None else None
473
+ _mark_settings_as_errors(model.sections[0].body[0])
282
474
 
283
475
  suite: TestSuite = TestSuite.from_model(model)
284
476
 
477
+ return model, cast(TestCase, suite.tests[0])
478
+
479
+ def get_test_body_from_string(self, command: str) -> Tuple[TestCase, List[str]]:
480
+ model, test = self._parse_test_body(command)
481
+
285
482
  errors: List[str] = []
286
483
 
287
484
  for node in iter_nodes(model):
288
485
  errors.extend(self.check_for_errors(node))
289
486
 
290
- return cast(TestCase, suite.tests[0]), errors
487
+ return test, errors
488
+
489
+ def parse_input(self, text: str) -> ReplInput:
490
+ """Parse REPL input as a test body without raising on errors.
491
+
492
+ Unlike `get_test_body_from_string`, whose `SyntaxError` for token errors
493
+ `repl_server` relies on, invalid statements are left to Robot Framework,
494
+ which fails them when execution reaches them.
495
+ """
496
+ model, test = self._parse_test_body(text)
497
+
498
+ errors: List[str] = []
499
+ incomplete = False
500
+
501
+ for node in iter_nodes(model):
502
+ try:
503
+ errors.extend(self.check_for_errors(node))
504
+ except SyntaxError as e:
505
+ errors.append(str(e))
506
+ if isinstance(node, _END_BLOCKS) and node.header.type in _BLOCK_OPENERS and node.end is None:
507
+ incomplete = True
508
+
509
+ return ReplInput(test=test, errors=errors, incomplete=incomplete)
291
510
 
292
511
  @abc.abstractmethod
293
512
  def get_input(self) -> Iterator[Optional[Keyword]]: ...
@@ -302,17 +521,50 @@ class BaseInterpreter(abc.ABC):
302
521
  except ExecutionStatus:
303
522
  raise
304
523
  except BaseException as e:
305
- self.log_message(f"{type(e)}: {e}", "ERROR", timestamp=datetime.now()) # noqa: DTZ005
524
+ message = f"{type(e)}: {e}"
525
+ self.log_message(message, "ERROR", timestamp=datetime.now()) # noqa: DTZ005
526
+ self._record_failure(ExecutionFailed(message))
527
+
528
+ def _record_failure(self, error: ExecutionFailed) -> None:
529
+ if self.record_failures:
530
+ # Split like Robot Framework's body runner does, so the combined message
531
+ # numbers each failure.
532
+ self.failures.extend(error.get_errors())
306
533
 
307
534
  def interrupt(self) -> None:
308
535
  signal.raise_signal(signal.SIGINT)
309
536
 
537
+ def _terminate_session(self, keyword_running: bool) -> None:
538
+ """End the REPL session on SIGTERM, from the signal handler: a running keyword is
539
+ interrupted, then the session ends as at the end of the input, with exit code 253."""
540
+ self._termination_requested = True
541
+ self.exit_code = 253
542
+ self._end_waiting()
543
+ if keyword_running:
544
+ raise ExecutionInterrupted("Execution terminated by signal")
545
+
546
+ def _end_waiting(self) -> None:
547
+ """Stop waiting for input, from the signal handler. Each interpreter knows how it waits."""
548
+
549
+ def _interrupt_waiting(self) -> None:
550
+ """Interrupt a wait for input after SIGINT, from the signal handler. Each interpreter knows how it waits."""
551
+
552
+ def _pause_on_interrupt(self) -> bool:
553
+ """Pause at the next keyword after SIGINT instead of interrupting the keyword that runs, from the signal
554
+ handler. Returns whether it does; only an interpreter with a debugger can pause."""
555
+ return False
556
+
557
+ def _interrupt_ends_run(self) -> bool:
558
+ """Whether SIGINT ends the run as Robot Framework ends one, from the signal handler. Only an interpreter that
559
+ runs a suite, not a session, can end a run this way."""
560
+ return False
561
+
310
562
  def run(self) -> Any:
311
563
  self._logger.enabled = True
312
564
 
313
565
  has_input = True
314
566
  try:
315
- while has_input:
567
+ while has_input and not self._termination_requested:
316
568
  try:
317
569
  self.run_input()
318
570
  except EOFError:
@@ -321,19 +573,33 @@ class BaseInterpreter(abc.ABC):
321
573
  break
322
574
  except ExecutionInterrupted as e:
323
575
  self.log_message(str(e), "ERROR", timestamp=datetime.now()) # noqa: DTZ005
324
- except ExecutionStatus:
325
- pass
576
+ self._record_failure(ExecutionFailed(str(e)))
577
+ except ExecutionPassed as e:
578
+ # Failures continued before `Pass Execution` still fail a test in Robot Framework.
579
+ if e.earlier_failures:
580
+ self._record_failure(e.earlier_failures)
581
+ except ExecutionFailed as e:
582
+ # As in Robot Framework, a skip counts as skipped even when failures
583
+ # were continued before it.
584
+ if not e.skip:
585
+ self._record_failure(e)
326
586
  except BaseException as e:
327
587
  self.log_message(str(e), "ERROR", timestamp=datetime.now()) # noqa: DTZ005
588
+ self._record_failure(ExecutionFailed(str(e)))
328
589
  finally:
329
590
  self._logger.enabled = False
330
591
 
331
592
  def run_input(self) -> None:
332
593
  for kw in self.get_input():
333
594
  with STOP_SIGNAL_MONITOR:
334
- if kw is None:
335
- break
336
- self.set_last_result(self.run_keyword(kw))
595
+ self._running_statement = True
596
+ try:
597
+ # Checked after marking the statement: a SIGTERM in between can't be missed.
598
+ if kw is None or self._termination_requested:
599
+ break
600
+ self.set_last_result(self.run_keyword(kw))
601
+ finally:
602
+ self._running_statement = False
337
603
 
338
604
  def set_last_result(self, result: Any) -> None:
339
605
  self.last_result = result
@@ -444,6 +710,8 @@ class BaseInterpreter(abc.ABC):
444
710
  """
445
711
  if self._in_repl_run or not self._is_repl_marker(result):
446
712
  return False
713
+ global _active_interpreter
714
+ _active_interpreter = self
447
715
  self._in_repl_run = True
448
716
  try:
449
717
  self.run()
robotcode/repl/cli.py CHANGED
@@ -17,7 +17,7 @@ from ._backends import BACKEND_CHOICES
17
17
  from ._debug import DebugController, DebugTerminated
18
18
  from .console_interpreter import _LINE_BREAK_RE, ConsoleInterpreter
19
19
  from .prompt_toolkit_interpreter import PromptToolkitConsoleInterpreter
20
- from .run import run_repl
20
+ from .run import ReplResult, run_repl
21
21
 
22
22
 
23
23
  def _pick_interpreter(
@@ -79,6 +79,8 @@ def _invoke_runner(
79
79
  ctx: click.Context,
80
80
  by_longname: Tuple[str, ...],
81
81
  exclude_by_longname: Tuple[str, ...],
82
+ by_test_metadata: Tuple[str, ...],
83
+ exclude_by_test_metadata: Tuple[str, ...],
82
84
  robot_options_and_args: Tuple[str, ...],
83
85
  ) -> None:
84
86
  """Run the real suite via the normal `robotcode robot` runner."""
@@ -89,6 +91,10 @@ def _invoke_runner(
89
91
  forwarded += ["--by-longname", value]
90
92
  for value in exclude_by_longname:
91
93
  forwarded += ["--exclude-by-longname", value]
94
+ for value in by_test_metadata:
95
+ forwarded += ["--by-test-metadata", value]
96
+ for value in exclude_by_test_metadata:
97
+ forwarded += ["--exclude-by-test-metadata", value]
92
98
  forwarded += list(robot_options_and_args)
93
99
 
94
100
  robot_ctx = robot_command.make_context("robot", forwarded, parent=ctx)
@@ -107,6 +113,21 @@ def _is_interactive_stdin() -> bool:
107
113
  return False
108
114
 
109
115
 
116
+ def _session_exit_code(result: ReplResult, *, statusrc: Optional[bool], interactive: bool) -> int:
117
+ """The exit code of a `repl` session.
118
+
119
+ A code given to `.exit`/`.quit` wins. Otherwise Robot's return code, which
120
+ already honors `statusrc` from the configuration, is used when `--statusrc`/
121
+ `--nostatusrc` was given or the session is not interactive; an interactive
122
+ session exits with 0.
123
+ """
124
+ if result.exit_code is not None:
125
+ return result.exit_code
126
+ if statusrc is not None or not interactive:
127
+ return result.return_code
128
+ return 0
129
+
130
+
110
131
  def _resolve_backend(plain: bool, backend: str) -> str:
111
132
  """The effective input backend.
112
133
 
@@ -235,8 +256,8 @@ REPL_BASE_OPTIONS = [
235
256
  ),
236
257
  ]
237
258
 
238
- # `repl`-only options (interactive session setup + snippet files).
239
- SHELL_OPTIONS = [
259
+ # Variables and the python path, shared by `repl` and `doc`.
260
+ VARIABLE_AND_PATH_OPTIONS = [
240
261
  click.option(
241
262
  "-v",
242
263
  "--variable",
@@ -262,6 +283,11 @@ SHELL_OPTIONS = [
262
283
  help="Additional locations where to search test libraries"
263
284
  " and other extensions when they are imported. See `robot --pythonpath` option.",
264
285
  ),
286
+ ]
287
+
288
+ # `repl`-only options (interactive session setup + snippet files).
289
+ SHELL_OPTIONS = [
290
+ *VARIABLE_AND_PATH_OPTIONS,
265
291
  click.option(
266
292
  "-k",
267
293
  "--show-keywords",
@@ -316,11 +342,20 @@ SHELL_OPTIONS = [
316
342
  "--source",
317
343
  type=click.Path(path_type=Path),
318
344
  metavar="FILE",
319
- help="Use the parent directory of FILE as the REPL's working directory. "
320
- "Relative paths inside `Import Resource`, `Import Library`, "
321
- "file-based variables, etc. resolve against that directory. "
322
- "The file itself is never read or written, so the path doesn't "
323
- "need to exist.",
345
+ help="Run the session as if its keywords were written in FILE: `${CURDIR}` is the "
346
+ "directory of FILE, `${SUITE SOURCE}` is FILE and, on Robot Framework 7.4 and newer, "
347
+ "relative paths in `Import Resource`, `Import Library`, and `Import Variables` resolve "
348
+ "against that directory. A relative FILE is resolved against the directory the command "
349
+ "is started from. The file itself is never read or written, so the path doesn't need to exist.",
350
+ ),
351
+ click.option(
352
+ "--statusrc/--nostatusrc",
353
+ default=None,
354
+ help="Exit with 1 if a statement failed without being handled, otherwise 0, in any session "
355
+ "(`--statusrc`), or exit with 0 regardless of failures (`--nostatusrc`). Both override `no-status-rc` "
356
+ "from the configuration. Without either option only non-interactive sessions (piped input, or FILES "
357
+ "without `--inspect`) exit with 1 after such a failure, unless the configuration switches this off. "
358
+ "A code given to `.exit` takes precedence.",
324
359
  ),
325
360
  click.argument(
326
361
  "files",
@@ -350,7 +385,8 @@ DEBUG_OPTIONS = [
350
385
  "break_at",
351
386
  metavar="LOCATION",
352
387
  multiple=True,
353
- help="Break at LOCATION — a `file:line` or a keyword name. Repeatable.",
388
+ help="Break at LOCATION — a `file:line`, or a keyword name or glob pattern "
389
+ "matched like Robot Framework. Repeatable.",
354
390
  ),
355
391
  click.option(
356
392
  "--break-on-exception/--no-break-on-exception",
@@ -403,6 +439,7 @@ def repl(
403
439
  log: Optional[str],
404
440
  xunit: Optional[str],
405
441
  source: Optional[Path],
442
+ statusrc: Optional[bool],
406
443
  files: Tuple[Path, ...],
407
444
  debugger_attached: Optional[bool],
408
445
  break_at: Tuple[str, ...],
@@ -441,6 +478,9 @@ def repl(
441
478
  no_history=no_history,
442
479
  backend=backend,
443
480
  )
481
+ # Only `repl` fails its session test on unhandled failures — not `robot-debug`,
482
+ # which builds its interpreter the same way.
483
+ interpreter.record_failures = True
444
484
  # The debugger stays wired for the whole interactive session and is torn down
445
485
  # with the interpreter when the session ends — so, unlike `robot`, there is no
446
486
  # per-run unregister here. `attached` decides whether it actually pauses.
@@ -454,7 +494,7 @@ def repl(
454
494
  break_on_failed_suite=break_on_failed_suite,
455
495
  )
456
496
 
457
- run_repl(
497
+ result = run_repl(
458
498
  interpreter=interpreter,
459
499
  app=app,
460
500
  variable=variable,
@@ -467,8 +507,13 @@ def repl(
467
507
  xunit=xunit,
468
508
  source=source,
469
509
  files=files,
510
+ statusrc=statusrc,
470
511
  )
471
512
 
513
+ # Script files without `--inspect` end on their own, like piped input.
514
+ interactive = _is_interactive_stdin() and (not files or inspect)
515
+ app.exit(_session_exit_code(result, statusrc=statusrc, interactive=interactive))
516
+
472
517
 
473
518
  @wrappable
474
519
  @click.command(
@@ -479,13 +524,13 @@ def repl(
479
524
  epilog="Use `-- --help` to see `robot` help.",
480
525
  )
481
526
  @add_options(*REPL_BASE_OPTIONS)
482
- @add_options(*(ROBOT_OPTIONS - ROBOT_VERSION_OPTIONS))
527
+ @add_options(*(option for option in ROBOT_OPTIONS if option not in ROBOT_VERSION_OPTIONS))
483
528
  @add_options(*DEBUG_OPTIONS)
484
529
  @click.option(
485
530
  "--stop-on-entry",
486
531
  is_flag=True,
487
532
  default=False,
488
- help="Break at the very first keyword.",
533
+ help="Break at the very first step.",
489
534
  )
490
535
  @add_options(*ROBOT_VERSION_OPTIONS)
491
536
  @pass_application
@@ -495,6 +540,8 @@ def robot_debug(
495
540
  app: Application,
496
541
  by_longname: Tuple[str, ...],
497
542
  exclude_by_longname: Tuple[str, ...],
543
+ by_test_metadata: Tuple[str, ...],
544
+ exclude_by_test_metadata: Tuple[str, ...],
498
545
  robot_options_and_args: Tuple[str, ...],
499
546
  no_history: bool,
500
547
  backend: str,
@@ -540,7 +587,14 @@ def robot_debug(
540
587
  # default (or the user's `--console`), not forced.
541
588
  try:
542
589
  with interpreter.forward_events(echo_messages=False):
543
- _invoke_runner(ctx, by_longname, exclude_by_longname, robot_options_and_args)
590
+ _invoke_runner(
591
+ ctx,
592
+ by_longname,
593
+ exclude_by_longname,
594
+ by_test_metadata,
595
+ exclude_by_test_metadata,
596
+ robot_options_and_args,
597
+ )
544
598
  except DebugTerminated:
545
599
  app.echo("Debugger: run terminated.")
546
600
  finally: