robotcode-repl 2.9.0.dev51__py3-none-any.whl → 2.9.0.dev74__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.
@@ -597,13 +597,19 @@ class BaseInterpreter(abc.ABC):
597
597
  # Checked after marking the statement: a SIGTERM in between can't be missed.
598
598
  if kw is None or self._termination_requested:
599
599
  break
600
- self.set_last_result(self.run_keyword(kw))
600
+ result = self.run_keyword(kw)
601
+ self.set_last_result(result)
602
+ self.show_result(kw, result)
601
603
  finally:
602
604
  self._running_statement = False
603
605
 
604
606
  def set_last_result(self, result: Any) -> None:
605
607
  self.last_result = result
606
608
 
609
+ def show_result(self, statement: Keyword, result: Any) -> None:
610
+ """Show the result of a statement of the input after it has run. Each interpreter knows how;
611
+ the REPL server returns results through its protocol."""
612
+
607
613
  @abc.abstractmethod
608
614
  def log_message(
609
615
  self, message: str, level: str, html: Union[str, bool] = False, timestamp: Union[datetime, str, None] = None
robotcode/repl/cli.py CHANGED
@@ -1,10 +1,12 @@
1
+ import contextlib
1
2
  import os
2
3
  import sys
3
4
  from pathlib import Path
4
- from typing import Dict, List, Optional, Tuple
5
+ from typing import ContextManager, Dict, List, Optional, Tuple
5
6
 
6
7
  import click
7
8
 
9
+ from robotcode.core.utils.cli import show_hidden_arguments
8
10
  from robotcode.plugin import Application, pass_application
9
11
  from robotcode.plugin._agent_detection import is_running_in_ai_agent
10
12
  from robotcode.plugin.click_helper.aliases import AliasedCommand
@@ -15,6 +17,7 @@ from robotcode.runner.cli.robot import ROBOT_OPTIONS, ROBOT_VERSION_OPTIONS, loa
15
17
  from .__version__ import __version__
16
18
  from ._backends import BACKEND_CHOICES
17
19
  from ._debug import DebugController, DebugTerminated
20
+ from ._session import SessionError, SessionInterpreter, SessionKind, SessionServer, project_folder
18
21
  from .console_interpreter import _LINE_BREAK_RE, ConsoleInterpreter
19
22
  from .prompt_toolkit_interpreter import PromptToolkitConsoleInterpreter
20
23
  from .run import ReplResult, run_repl
@@ -28,6 +31,7 @@ def _pick_interpreter(
28
31
  inspect: bool,
29
32
  no_history: bool,
30
33
  backend: str,
34
+ show_prompt: bool,
31
35
  ) -> ConsoleInterpreter:
32
36
  """Construct the interpreter that matches ``backend``.
33
37
 
@@ -43,6 +47,7 @@ def _pick_interpreter(
43
47
  show_keywords=show_keywords,
44
48
  inspect=inspect,
45
49
  no_history=no_history,
50
+ show_prompt=show_prompt,
46
51
  )
47
52
  if backend not in ("auto", "prompt-toolkit"):
48
53
  raise ValueError(f"Unknown backend: {backend!r}. Choose from {BACKEND_CHOICES}.")
@@ -52,6 +57,7 @@ def _pick_interpreter(
52
57
  show_keywords=show_keywords,
53
58
  inspect=inspect,
54
59
  no_history=no_history,
60
+ show_prompt=show_prompt,
55
61
  )
56
62
 
57
63
 
@@ -153,6 +159,20 @@ def _resolve_backend(plain: bool, backend: str) -> str:
153
159
  return backend
154
160
 
155
161
 
162
+ def _resolve_show_prompt(show_prompt: Optional[bool]) -> bool:
163
+ """Whether `repl` shows its `>>> ` / `... ` prompt.
164
+
165
+ Always on an interactive terminal. Otherwise `--show-prompt` / `--no-show-prompt`
166
+ (or `ROBOTCODE_REPL_SHOW_PROMPT`) decides, and without either a recognised AI
167
+ agent gets the prompt: it marks where the output of a statement ends.
168
+ """
169
+ if _is_interactive_stdin():
170
+ return True
171
+ if show_prompt is not None:
172
+ return show_prompt
173
+ return is_running_in_ai_agent()
174
+
175
+
156
176
  def _resolve_attached(
157
177
  debugger_attached: Optional[bool],
158
178
  *,
@@ -302,6 +322,15 @@ SHELL_OPTIONS = [
302
322
  default=False,
303
323
  help="Activate inspection mode. This forces a prompt to appear after the REPL script is executed.",
304
324
  ),
325
+ click.option(
326
+ "--show-prompt/--no-show-prompt",
327
+ default=None,
328
+ envvar="ROBOTCODE_REPL_SHOW_PROMPT",
329
+ help="Show (`--show-prompt`) or hide (`--no-show-prompt`) the `>>>` prompt when the input is not a "
330
+ "terminal, such as piped input. Without either option it shows there only in the session of a "
331
+ "recognised AI agent, where it marks the end of each statement's output. On a terminal the prompt "
332
+ "always shows.",
333
+ ),
305
334
  click.option(
306
335
  "-d",
307
336
  "--outputdir",
@@ -365,6 +394,52 @@ SHELL_OPTIONS = [
365
394
  ),
366
395
  ]
367
396
 
397
+ # The session that `robotcode session start` runs `repl` or `robot-debug` as.
398
+ SESSION_OPTIONS = [
399
+ click.option(
400
+ "--serve-session",
401
+ metavar="ID",
402
+ hidden=show_hidden_arguments(),
403
+ help="Serve the session ID that `robotcode session start` started. This is an internal option.",
404
+ ),
405
+ click.option(
406
+ "--serve-output",
407
+ type=int,
408
+ metavar="FD",
409
+ hidden=show_hidden_arguments(),
410
+ help="Read the output of the session from the pipe FD, a handle on Windows, into memory. "
411
+ "This is an internal option.",
412
+ ),
413
+ click.option(
414
+ "--serve-idle-timeout",
415
+ type=float,
416
+ default=15,
417
+ metavar="MINUTES",
418
+ hidden=show_hidden_arguments(),
419
+ help="End the session after it has waited at its prompt for MINUTES without a request. "
420
+ "This is an internal option.",
421
+ ),
422
+ ]
423
+
424
+
425
+ def _session_server(
426
+ app: Application,
427
+ serve_session: Optional[str],
428
+ serve_output: Optional[int],
429
+ kind: SessionKind,
430
+ idle_timeout: float,
431
+ ) -> Optional[SessionServer]:
432
+ if serve_session is None:
433
+ return None
434
+ if serve_output is None:
435
+ raise click.UsageError("--serve-session needs --serve-output.")
436
+ try:
437
+ # The folder `session start` ran in decides the project, also for a debug run of a suite elsewhere.
438
+ return SessionServer(serve_session, kind, project_folder(app), idle_timeout * 60, serve_output)
439
+ except SessionError as e:
440
+ raise click.ClickException(str(e)) from e
441
+
442
+
368
443
  # Debugger options — shared by `repl` and `robot-debug`. `--debugger-attached`
369
444
  # is the master switch (does anything pause at all?); the `--break*` flags are
370
445
  # the individual pause triggers. (`robot-debug` adds `--stop-on-entry`, which
@@ -421,6 +496,7 @@ DEBUG_OPTIONS = [
421
496
  @add_options(*REPL_BASE_OPTIONS)
422
497
  @add_options(*SHELL_OPTIONS)
423
498
  @add_options(*DEBUG_OPTIONS)
499
+ @add_options(*SESSION_OPTIONS)
424
500
  @click.version_option(version=__version__, prog_name="RobotCode REPL")
425
501
  @pass_application
426
502
  def repl(
@@ -430,6 +506,7 @@ def repl(
430
506
  pythonpath: Tuple[str, ...],
431
507
  show_keywords: bool,
432
508
  inspect: bool,
509
+ show_prompt: Optional[bool],
433
510
  no_history: bool,
434
511
  backend: str,
435
512
  plain: bool,
@@ -447,6 +524,9 @@ def repl(
447
524
  break_on_all_exceptions: bool,
448
525
  break_on_failed_test: bool,
449
526
  break_on_failed_suite: bool,
527
+ serve_session: Optional[str],
528
+ serve_output: Optional[int],
529
+ serve_idle_timeout: float,
450
530
  ) -> None:
451
531
  """Run Robot Framework interactively (alias `shell`).
452
532
 
@@ -473,13 +553,19 @@ def repl(
473
553
 
474
554
  backend = _resolve_backend(plain, backend)
475
555
 
476
- interpreter = _pick_interpreter(
477
- app=app,
478
- files=list(files),
479
- show_keywords=show_keywords,
480
- inspect=inspect,
481
- no_history=no_history,
482
- backend=backend,
556
+ server = _session_server(app, serve_session, serve_output, SessionKind.REPL, serve_idle_timeout)
557
+ interpreter = (
558
+ SessionInterpreter(server, app=app, files=list(files), show_keywords=show_keywords, inspect=inspect)
559
+ if server is not None
560
+ else _pick_interpreter(
561
+ app=app,
562
+ files=list(files),
563
+ show_keywords=show_keywords,
564
+ inspect=inspect,
565
+ no_history=no_history,
566
+ backend=backend,
567
+ show_prompt=_resolve_show_prompt(show_prompt),
568
+ )
483
569
  )
484
570
  # Only `repl` fails its session test on unhandled failures — not `robot-debug`,
485
571
  # which builds its interpreter the same way.
@@ -487,7 +573,7 @@ def repl(
487
573
  # The debugger stays wired for the whole interactive session and is torn down
488
574
  # with the interpreter when the session ends — so, unlike `robot`, there is no
489
575
  # per-run unregister here. `attached` decides whether it actually pauses.
490
- _attach_debugger(
576
+ controller = _attach_debugger(
491
577
  interpreter,
492
578
  break_at=break_at,
493
579
  attached=attached,
@@ -496,26 +582,31 @@ def repl(
496
582
  break_on_failed_test=break_on_failed_test,
497
583
  break_on_failed_suite=break_on_failed_suite,
498
584
  )
585
+ if server is not None:
586
+ server.keep_breakpoints(controller)
587
+
588
+ # A session records the exit code of the command when the REPL exits, and stays open.
589
+ session: ContextManager[object] = server if server is not None else contextlib.nullcontext()
590
+ with session:
591
+ result = run_repl(
592
+ interpreter=interpreter,
593
+ app=app,
594
+ variable=variable,
595
+ variablefile=variablefile,
596
+ pythonpath=pythonpath,
597
+ outputdir=outputdir,
598
+ output=output,
599
+ report=report,
600
+ log=log,
601
+ xunit=xunit,
602
+ source=source,
603
+ files=files,
604
+ statusrc=statusrc,
605
+ )
499
606
 
500
- result = run_repl(
501
- interpreter=interpreter,
502
- app=app,
503
- variable=variable,
504
- variablefile=variablefile,
505
- pythonpath=pythonpath,
506
- outputdir=outputdir,
507
- output=output,
508
- report=report,
509
- log=log,
510
- xunit=xunit,
511
- source=source,
512
- files=files,
513
- statusrc=statusrc,
514
- )
515
-
516
- # Script files without `--inspect` end on their own, like piped input.
517
- interactive = _is_interactive_stdin() and (not files or inspect)
518
- app.exit(_session_exit_code(result, statusrc=statusrc, interactive=interactive))
607
+ # Script files without `--inspect` end on their own, like piped input.
608
+ interactive = _is_interactive_stdin() and (not files or inspect)
609
+ app.exit(_session_exit_code(result, statusrc=statusrc, interactive=interactive))
519
610
 
520
611
 
521
612
  @wrappable
@@ -529,6 +620,7 @@ def repl(
529
620
  @add_options(*REPL_BASE_OPTIONS)
530
621
  @add_options(*(option for option in ROBOT_OPTIONS if option not in ROBOT_VERSION_OPTIONS))
531
622
  @add_options(*DEBUG_OPTIONS)
623
+ @add_options(*SESSION_OPTIONS)
532
624
  @click.option(
533
625
  "--stop-on-entry",
534
626
  is_flag=True,
@@ -556,6 +648,9 @@ def robot_debug(
556
648
  break_on_all_exceptions: bool,
557
649
  break_on_failed_test: bool,
558
650
  break_on_failed_suite: bool,
651
+ serve_session: Optional[str],
652
+ serve_output: Optional[int],
653
+ serve_idle_timeout: float,
559
654
  ) -> None:
560
655
  """Run a real Robot Framework suite with the debugger attached (alias `run-debug`).
561
656
 
@@ -572,8 +667,20 @@ def robot_debug(
572
667
 
573
668
  backend = _resolve_backend(plain, backend)
574
669
 
575
- interpreter = _pick_interpreter(
576
- app=app, files=[], show_keywords=False, inspect=False, no_history=no_history, backend=backend
670
+ server = _session_server(app, serve_session, serve_output, SessionKind.DEBUG, serve_idle_timeout)
671
+ # A run stops at `(rdb) `, which always shows; `robot-debug` has no option for `>>> `.
672
+ interpreter = (
673
+ SessionInterpreter(server, app=app, files=[], show_keywords=False, inspect=False)
674
+ if server is not None
675
+ else _pick_interpreter(
676
+ app=app,
677
+ files=[],
678
+ show_keywords=False,
679
+ inspect=False,
680
+ no_history=no_history,
681
+ backend=backend,
682
+ show_prompt=_is_interactive_stdin(),
683
+ )
577
684
  )
578
685
  controller = _attach_debugger(
579
686
  interpreter,
@@ -585,14 +692,18 @@ def robot_debug(
585
692
  break_on_failed_test=break_on_failed_test,
586
693
  break_on_failed_suite=break_on_failed_suite,
587
694
  )
695
+ if server is not None:
696
+ server.keep_breakpoints(controller)
588
697
 
589
698
  # `robot-debug` produces the same console output as `robotcode robot` — the run
590
699
  # is fully visible. At a stop the debug prompt is interleaved with Robot's live
591
700
  # console; `_render_stop` breaks off any in-progress status line, and the run's
592
701
  # output resumes on continue/step/detach. The console mode is left to Robot's
593
702
  # default (or the user's `--console`), not forced.
703
+ # A session records the exit code of the run when the run ends, and stays open.
704
+ session: ContextManager[object] = server if server is not None else contextlib.nullcontext()
594
705
  try:
595
- with interpreter.forward_events(echo_messages=False):
706
+ with session, interpreter.forward_events(echo_messages=False):
596
707
  _invoke_runner(
597
708
  ctx,
598
709
  by_longname,
@@ -28,7 +28,7 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional,
28
28
 
29
29
  import click
30
30
  from robot import result, running
31
- from robot.errors import ExecutionFailed, ExecutionPassed
31
+ from robot.errors import ExecutionFailed, ExecutionPassed, ExecutionStatus
32
32
  from robot.running import Keyword
33
33
  from robot.running.context import EXECUTION_CONTEXTS
34
34
  from robot.utils import normalize
@@ -334,6 +334,7 @@ class ConsoleInterpreter(BaseInterpreter):
334
334
  """
335
335
 
336
336
  # The prompt shown while paused at a debug stop (vs the `>>> ` REPL prompt).
337
+ PROMPT = ">>> "
337
338
  DEBUG_PROMPT = "(rdb) "
338
339
 
339
340
  # Whether `show_doc` opens a scrollable fullscreen viewer (prompt_toolkit) vs
@@ -348,6 +349,7 @@ class ConsoleInterpreter(BaseInterpreter):
348
349
  show_keywords: bool = False,
349
350
  inspect: Optional[bool] = False,
350
351
  no_history: bool = False,
352
+ show_prompt: bool = False,
351
353
  ) -> None:
352
354
  del no_history # plain mode has no history file to honour the flag against
353
355
  super().__init__()
@@ -356,6 +358,8 @@ class ConsoleInterpreter(BaseInterpreter):
356
358
  self.files = files
357
359
  self.show_keywords = show_keywords
358
360
  self.inspect = inspect
361
+ # Whether `>>> ` and `... ` show; `robotcode repl` decides it (`_resolve_show_prompt`).
362
+ self.show_prompt = show_prompt
359
363
 
360
364
  self.executed_files: List[Path] = []
361
365
  # REPL inputs that parsed cleanly — `.save` exports them as a
@@ -368,6 +372,8 @@ class ConsoleInterpreter(BaseInterpreter):
368
372
  # runs, then the session ends without reading again — on a terminal, a
369
373
  # read after Ctrl-D waits for more input instead of ending.
370
374
  self._input_ended = False
375
+ # Set while the statements of a script file run: like the body of a test, they show no results.
376
+ self._input_from_file = False
371
377
  # Set while a prompt waits for input: SIGTERM then ends the wait (`_end_waiting`).
372
378
  self._reading = False
373
379
 
@@ -575,7 +581,8 @@ class ConsoleInterpreter(BaseInterpreter):
575
581
  pager ignores them.
576
582
  """
577
583
  del scroll_to, outline # only the scrollable viewer (prompt_toolkit) uses them
578
- body = f"{title}\n{'=' * len(title)}\n\n{markdown}"
584
+ # Documentation can end with empty lines of its markdown; the output ends with the text.
585
+ body = f"{title}\n{'=' * len(title)}\n\n{markdown}".rstrip()
579
586
  if self.app is not None:
580
587
  self.app.echo_via_pager(body, color=False)
581
588
 
@@ -686,6 +693,10 @@ class ConsoleInterpreter(BaseInterpreter):
686
693
  self._dispatch_dot_command(line)
687
694
  else:
688
695
  self._evaluate_at_stop(line)
696
+ except ExecutionStatus:
697
+ # Robot Framework has reported the failure of the keyword, or its `Pass Execution`, as a message
698
+ # already; the REPL prompt adds nothing to it either.
699
+ pass
689
700
  except Exception as e: # a throwing command must not eject the debug prompt
690
701
  # `.abort` (SystemExit), Ctrl-C, and DebugTerminated are
691
702
  # BaseException, not Exception, so they still propagate.
@@ -761,7 +772,7 @@ class ConsoleInterpreter(BaseInterpreter):
761
772
  return f"{path}:{frame.line}" if frame.line is not None else path
762
773
 
763
774
  def _render_stop(self, stop: StopEvent) -> None:
764
- self._echo("") # break off any in-progress runner console status line
775
+ self._end_console_line()
765
776
  parts = [f"* {stop.reason.value}", stop.frame.name]
766
777
  loc = self._location(stop.frame)
767
778
  if loc:
@@ -772,6 +783,11 @@ class ConsoleInterpreter(BaseInterpreter):
772
783
  self._echo(line)
773
784
  self._show_displays()
774
785
 
786
+ def _end_console_line(self) -> None:
787
+ """Break off the line of the run's console that a stop comes in, such as the name of a test that Robot
788
+ Framework has written before its status."""
789
+ self._echo("")
790
+
775
791
  def _show_displays(self) -> None:
776
792
  """Auto-evaluate the `.display` expressions in the selected frame at a stop."""
777
793
  if not self._display_exprs or self._controller is None:
@@ -837,8 +853,8 @@ class ConsoleInterpreter(BaseInterpreter):
837
853
  width = max(len(f.name) for f in stack)
838
854
  self._echo(self._frame_line(self._frame_no, self._selected_frame(), width))
839
855
 
840
- def _evaluate_at_stop(self, line: str) -> None:
841
- """Run `line` as Robot keyword(s) in the paused context.
856
+ def _evaluate_at_stop(self, line: str) -> bool:
857
+ """Run `line` as Robot keyword(s) in the paused context; False when it can't run.
842
858
 
843
859
  Wrapped in `suppress_pausing()` so the evaluated keyword's own events
844
860
  don't re-trigger a stop, and in `forward_events(echo_messages=True)` so
@@ -848,18 +864,18 @@ class ConsoleInterpreter(BaseInterpreter):
848
864
  # output.xml when we stop; a keyword written after it makes the file unreadable.
849
865
  if self._stop is not None and self._stop.reason == StopReason.EXCEPTION and self._stop.frame.type == "SUITE":
850
866
  self._echo("! Keywords can't run at the end of a suite: its output is already closed.")
851
- return
867
+ return False
852
868
  try:
853
869
  test, errors = self.get_test_body_from_string(line)
854
870
  except Exception as e: # surface any parse failure to the prompt
855
871
  self._echo(f"! {e}")
856
- return
872
+ return False
857
873
  if errors:
858
874
  for err in errors:
859
875
  self._echo(f"! {err}")
860
- return
876
+ return False
861
877
  if not test.body:
862
- return
878
+ return True
863
879
  # Keywords evaluated at a stop don't count for the session status.
864
880
  record_failures, self.record_failures = self.record_failures, False
865
881
  try:
@@ -868,7 +884,8 @@ class ConsoleInterpreter(BaseInterpreter):
868
884
  self.set_last_result(self.run_keyword(kw))
869
885
  finally:
870
886
  self.record_failures = record_failures
871
- self._echo(f"=> {_repr.repr(self.last_result)}")
887
+ self._echo_result(test.body[-1], self.last_result)
888
+ return True
872
889
 
873
890
  # ------------------------------------------------------------------
874
891
  # Robot integration
@@ -892,6 +909,18 @@ class ConsoleInterpreter(BaseInterpreter):
892
909
  # than crash the REPL.
893
910
  pass
894
911
 
912
+ def show_result(self, statement: Keyword, result: Any) -> None:
913
+ if not self._input_from_file:
914
+ self._echo_result(statement, result)
915
+
916
+ def _echo_result(self, statement: Any, result: Any) -> None:
917
+ """Print the result of `statement` as `=> <value>`, at the `>>>` and the `(rdb)` prompt alike.
918
+ `None` shows nothing, and neither does an assignment: the message that sets the variable
919
+ names the value."""
920
+ if result is None or getattr(statement, "assign", None):
921
+ return
922
+ self._echo(f"=> {_repr.repr(result)}")
923
+
895
924
  def _alias_setting_imports(self, test: "running.TestCase") -> None:
896
925
  """REPL-only: rewrite top-level ``Library`` / ``Resource`` / ``Variables``
897
926
  keyword calls into the BuiltIn ``Import …`` keywords.
@@ -929,8 +958,10 @@ class ConsoleInterpreter(BaseInterpreter):
929
958
  text = file.read_text(encoding="utf-8")
930
959
 
931
960
  # Run like a test body: an invalid statement fails when execution reaches it.
961
+ self._input_from_file = True
932
962
  yield from self._body_to_run(self.parse_input(text))
933
963
  else:
964
+ self._input_from_file = False
934
965
  if self._input_ended:
935
966
  raise EOFError
936
967
 
@@ -938,8 +969,8 @@ class ConsoleInterpreter(BaseInterpreter):
938
969
  last_one = False
939
970
  while True:
940
971
  prompt = ""
941
- if sys.stdin.isatty():
942
- prompt = ">>> " if not lines else "... "
972
+ if self.show_prompt:
973
+ prompt = self.PROMPT if not lines else "... "
943
974
 
944
975
  prefill = compute_indent(lines) if lines else ""
945
976
  try:
robotcode/repl/hooks.py CHANGED
@@ -6,8 +6,9 @@ from robotcode.plugin import hookimpl
6
6
 
7
7
  from .cli import repl, robot_debug
8
8
  from .doc_cli import doc
9
+ from .session_cli import session
9
10
 
10
11
 
11
12
  @hookimpl
12
13
  def register_cli_commands() -> List[click.Command]:
13
- return [repl, robot_debug, doc]
14
+ return [repl, robot_debug, doc, session]
@@ -67,6 +67,7 @@ class PromptToolkitConsoleInterpreter(ConsoleInterpreter):
67
67
  show_keywords: bool = False,
68
68
  inspect: Optional[bool] = False,
69
69
  no_history: bool = False,
70
+ show_prompt: bool = False,
70
71
  ) -> None:
71
72
  super().__init__(
72
73
  app=app,
@@ -74,6 +75,7 @@ class PromptToolkitConsoleInterpreter(ConsoleInterpreter):
74
75
  show_keywords=show_keywords,
75
76
  inspect=inspect,
76
77
  no_history=no_history,
78
+ show_prompt=show_prompt,
77
79
  )
78
80
 
79
81
  self._history_store: History