robotcode-repl 2.9.0.dev74__tar.gz → 2.9.0.dev87__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/PKG-INFO +2 -2
  2. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/pyproject.toml +1 -1
  3. robotcode_repl-2.9.0.dev87/src/robotcode/repl/__version__.py +1 -0
  4. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/_debug/__init__.py +2 -0
  5. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/_debug/controller.py +8 -12
  6. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/_debug/types.py +9 -0
  7. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/_session.py +263 -141
  8. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/session_cli.py +78 -61
  9. robotcode_repl-2.9.0.dev74/src/robotcode/repl/__version__.py +0 -1
  10. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/.gitignore +0 -0
  11. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/README.md +0 -0
  12. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/Repl/__init__.py +0 -0
  13. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/Repl/repl.py +0 -0
  14. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/__init__.py +0 -0
  15. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/_backends.py +0 -0
  16. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/_indent.py +0 -0
  17. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/_keyword_lookup.py +0 -0
  18. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/_progress.py +0 -0
  19. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/_pt/__init__.py +0 -0
  20. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/_pt/completion.py +0 -0
  21. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/_pt/components.py +0 -0
  22. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/_pt/doc_viewer.py +0 -0
  23. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/_pt/history.py +0 -0
  24. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/_pt/lexer.py +0 -0
  25. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/_session_export.py +0 -0
  26. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/base_interpreter.py +0 -0
  27. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/cli.py +0 -0
  28. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/console_interpreter.py +0 -0
  29. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/doc_cli.py +0 -0
  30. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/hooks.py +0 -0
  31. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/prompt_toolkit_interpreter.py +0 -0
  32. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/py.typed +0 -0
  33. {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev87}/src/robotcode/repl/run.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: robotcode-repl
3
- Version: 2.9.0-dev.74
3
+ Version: 2.9.0-dev.87
4
4
  Summary: RobotCode REPL for Robot Framework
5
5
  Project-URL: Homepage, https://robotcode.io
6
6
  Project-URL: Donate, https://opencollective.com/robotcode
@@ -27,7 +27,7 @@ Classifier: Topic :: Utilities
27
27
  Classifier: Typing :: Typed
28
28
  Requires-Python: >=3.10
29
29
  Requires-Dist: prompt-toolkit!=3.0.52
30
- Requires-Dist: robotcode-runner==2.9.0-dev.74
30
+ Requires-Dist: robotcode-runner==2.9.0-dev.87
31
31
  Description-Content-Type: text/markdown
32
32
 
33
33
  # robotcode-repl
@@ -29,7 +29,7 @@ classifiers = [
29
29
  ]
30
30
  dynamic = ["version"]
31
31
  dependencies = [
32
- "robotcode-runner==2.9.0-dev.74",
32
+ "robotcode-runner==2.9.0-dev.87",
33
33
  "prompt_toolkit!=3.0.52"
34
34
  ]
35
35
 
@@ -0,0 +1 @@
1
+ __version__ = "2.9.0-dev.87"
@@ -11,6 +11,7 @@ are concerns of a particular front-end. The CLI front-end is the
11
11
  from .controller import DebugController
12
12
  from .types import (
13
13
  Breakpoint,
14
+ BreakpointState,
14
15
  DebugTerminated,
15
16
  Frontend,
16
17
  ResumeAction,
@@ -23,6 +24,7 @@ from .types import (
23
24
 
24
25
  __all__ = [
25
26
  "Breakpoint",
27
+ "BreakpointState",
26
28
  "DebugController",
27
29
  "DebugTerminated",
28
30
  "Frontend",
@@ -27,6 +27,7 @@ from robotcode.robot.utils import RF_VERSION
27
27
  from ..base_interpreter import result_definition_names, result_qualified_name
28
28
  from .types import (
29
29
  Breakpoint,
30
+ BreakpointState,
30
31
  DebugTerminated,
31
32
  Frontend,
32
33
  ResumeAction,
@@ -222,21 +223,16 @@ class DebugController:
222
223
  self._breakpoints.remove(bp)
223
224
  return True
224
225
 
225
- def breakpoint_state(self) -> Dict[str, Any]:
226
- """The breakpoints and the exception breakpoints as they are, in plain values, for `restore_breakpoints` in
227
- another process."""
228
- return {
229
- "breakpoints": [dataclasses.asdict(bp) for bp in self._breakpoints],
230
- "next_id": self._next_bp_id,
231
- "exception_filters": sorted(self._exception_filters),
232
- }
226
+ def breakpoint_state(self) -> BreakpointState:
227
+ """The breakpoints and the exception breakpoints as they are, for `restore_breakpoints` in another process."""
228
+ return BreakpointState(list(self._breakpoints), self._next_bp_id, sorted(self._exception_filters))
233
229
 
234
- def restore_breakpoints(self, state: Mapping[str, Any]) -> None:
230
+ def restore_breakpoints(self, state: BreakpointState) -> None:
235
231
  """Take over the breakpoints of `breakpoint_state`, in place of those set so far. Their hit counts start at 0,
236
232
  as `.ignore` counts the hits of a run."""
237
- self._breakpoints = [dataclasses.replace(Breakpoint(**fields), hits=0) for fields in state["breakpoints"]]
238
- self._next_bp_id = state["next_id"]
239
- self._exception_filters = set(state["exception_filters"])
233
+ self._breakpoints = [dataclasses.replace(bp, hits=0) for bp in state.breakpoints]
234
+ self._next_bp_id = state.next_id
235
+ self._exception_filters = set(state.exception_filters)
240
236
 
241
237
  def set_exception_breakpoints(self, filters: Iterable[str]) -> None:
242
238
  """Arm exception breakpoints. Recognised filters: ``failed_keyword``
@@ -90,6 +90,15 @@ class Breakpoint:
90
90
  hits: int = 0
91
91
 
92
92
 
93
+ @dataclass
94
+ class BreakpointState:
95
+ """The breakpoints and the exception breakpoints of a `DebugController` as they are, for one in another process."""
96
+
97
+ breakpoints: List[Breakpoint]
98
+ next_id: int
99
+ exception_filters: List[str]
100
+
101
+
93
102
  @dataclass
94
103
  class StopEvent:
95
104
  reason: StopReason
@@ -32,20 +32,22 @@ import uuid
32
32
  import warnings
33
33
  from collections import deque
34
34
  from dataclasses import dataclass
35
- from enum import Enum, unique
35
+ from enum import Enum, auto, unique
36
36
  from multiprocessing.connection import Client, Connection, Listener
37
37
  from pathlib import Path
38
38
  from types import TracebackType
39
- from typing import Any, Callable, Deque, Dict, Iterator, List, Optional, Tuple, Type, TypeVar
39
+ from typing import Any, Callable, Deque, Dict, Iterator, List, Optional, Tuple, Type, TypeVar, Union
40
40
 
41
41
  import platformdirs
42
42
  from robot.errors import ExecutionFailed, ExecutionPassed
43
43
  from robot.running import Keyword
44
+ from typing_extensions import assert_never
44
45
 
46
+ from robotcode.core.utils.dataclasses import from_json
45
47
  from robotcode.plugin import Application
46
48
  from robotcode.robot.config.utils import get_config_files
47
49
 
48
- from ._debug import DebugController
50
+ from ._debug import BreakpointState, DebugController
49
51
  from .console_interpreter import ConsoleInterpreter
50
52
 
51
53
  if sys.platform == "win32":
@@ -59,8 +61,7 @@ if hasattr(platformdirs, "RuntimeDirWarning"):
59
61
 
60
62
 
61
63
  class _Value(str, Enum):
62
- """A string enum whose members stand for their plain values in the file of a session, on its channel and in the
63
- output."""
64
+ """A string enum whose members stand for their plain values in the file of a session and in the output."""
64
65
 
65
66
  def __str__(self) -> str:
66
67
  return str(self.value)
@@ -118,6 +119,86 @@ class AnswerStatus(_Value):
118
119
  RUNNING = "running"
119
120
 
120
121
 
122
+ @dataclass
123
+ class SessionRequest:
124
+ """What a command asks the channel of a session."""
125
+
126
+ kind: Request
127
+ # The input of a `send` request.
128
+ input: str = ""
129
+
130
+
131
+ @dataclass
132
+ class OutputAnswer:
133
+ """The output since the last answer and how the input went."""
134
+
135
+ output: str
136
+ status: AnswerStatus
137
+ # Once the REPL or the run has ended.
138
+ exit_code: Optional[int] = None
139
+
140
+
141
+ @dataclass
142
+ class StateAnswer:
143
+ """The answer to `state`."""
144
+
145
+ state: SessionState
146
+
147
+
148
+ @dataclass
149
+ class ResultAnswer:
150
+ """The answer to `result`: the exit code of the REPL or the run."""
151
+
152
+ exit_code: int
153
+
154
+
155
+ @dataclass
156
+ class InterruptedAnswer:
157
+ """The answer to `interrupt`."""
158
+
159
+
160
+ @dataclass
161
+ class StoppedAnswer:
162
+ """The answer to `stop` once the session ends, with the output since the last answer."""
163
+
164
+ output: str
165
+
166
+
167
+ @dataclass
168
+ class RestartedAnswer:
169
+ """The answer to `restart` once the new process has taken over, with the output since the last answer."""
170
+
171
+ output: str
172
+
173
+
174
+ @dataclass
175
+ class RestartFailedAnswer:
176
+ """The answer to `restart` when the new process didn't get ready, so that the session stays as it is."""
177
+
178
+ output: str
179
+ reason: str
180
+
181
+
182
+ @dataclass
183
+ class RefusedAnswer:
184
+ """The session doesn't take the request, for the reason in the message."""
185
+
186
+ message: str
187
+
188
+
189
+ @dataclass
190
+ class MovedAnswer:
191
+ """The new process of a restart has taken over the session, so that the command has to ask that process."""
192
+
193
+
194
+ # What a session answers to the requests it takes.
195
+ SessionAnswer = Union[
196
+ OutputAnswer, StateAnswer, ResultAnswer, InterruptedAnswer, StoppedAnswer, RestartedAnswer, RestartFailedAnswer
197
+ ]
198
+ # What a channel answers, also when its session doesn't take a request or has moved to another process.
199
+ ChannelAnswer = Union[SessionAnswer, RefusedAnswer, MovedAnswer]
200
+
201
+
121
202
  _ID_PATTERN = re.compile(rf"(?:{'|'.join(kind.value for kind in SessionKind)})-[0-9a-f]{{8}}")
122
203
 
123
204
  _T = TypeVar("_T")
@@ -132,16 +213,6 @@ _BACKLOG = 16
132
213
  # How long a new session process may take until it accepts requests.
133
214
  STARTUP_LIMIT = 60
134
215
 
135
- # The session process keeps running after the command that started it has returned: in a new process session on Linux
136
- # and macOS, on Windows in a new process group, which a Ctrl-C at the console of the command doesn't reach, and with a
137
- # console without a window. Console programs that the session starts use that console instead of opening windows of
138
- # their own.
139
- _DETACHED: Dict[str, Any]
140
- if sys.platform == "win32":
141
- _DETACHED = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW}
142
- else:
143
- _DETACHED = {"start_new_session": True}
144
-
145
216
 
146
217
  class SessionError(Exception):
147
218
  """A session can't start, for the reason in the message."""
@@ -217,11 +288,6 @@ class SessionEntry:
217
288
  # When the session last arrived at its prompt.
218
289
  waits_since: Optional[float] = None
219
290
 
220
- def __post_init__(self) -> None:
221
- # The file holds the plain values of the enums.
222
- self.kind = SessionKind(self.kind)
223
- self.state = SessionState(self.state)
224
-
225
291
  def time_left(self) -> Optional[float]:
226
292
  """The seconds until the session ends for being idle, None while it runs."""
227
293
  if self.state is SessionState.RUNS:
@@ -230,6 +296,17 @@ class SessionEntry:
230
296
  return max(0.0, self.idle_timeout - (time.time() - idle_since))
231
297
 
232
298
 
299
+ @dataclass
300
+ class _Handover:
301
+ """The file in which a session process hands on to the new process of a restart."""
302
+
303
+ # The number of the new process, by which it gets a channel of its own.
304
+ generation: int
305
+ # When the session started.
306
+ started: float
307
+ breakpoints: Optional[BreakpointState] = None
308
+
309
+
233
310
  def _retried(action: Callable[[], _T]) -> _T:
234
311
  """`action()`, tried again for a moment while it fails with `PermissionError`: Windows doesn't replace a file that
235
312
  another process reads at the moment, nor open one that another process replaces."""
@@ -248,17 +325,17 @@ def _temp_path(path: Path) -> Path:
248
325
  return path.with_name(f"{path.name}.tmp")
249
326
 
250
327
 
251
- def _write_json(path: Path, data: Any) -> None:
328
+ def _write_json(path: Path, data: Union[SessionEntry, _Handover]) -> None:
252
329
  """Write `data` to `path` as a whole, readable only by the user."""
253
330
  temp = _temp_path(path)
254
331
  fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
255
332
  with os.fdopen(fd, "w", encoding="utf-8") as f:
256
- json.dump(data, f)
333
+ json.dump(dataclasses.asdict(data), f)
257
334
  _retried(lambda: os.replace(temp, path))
258
335
 
259
336
 
260
337
  def _write_entry(entry: SessionEntry) -> None:
261
- _write_json(_entry_path(entry.id), dataclasses.asdict(entry))
338
+ _write_json(_entry_path(entry.id), entry)
262
339
 
263
340
 
264
341
  def remove_session_files(session_id: str) -> None:
@@ -298,7 +375,7 @@ def _channel_gone(address: str) -> bool:
298
375
  def _load_entry(session_id: str) -> Optional[SessionEntry]:
299
376
  path = _entry_path(session_id)
300
377
  try:
301
- return SessionEntry(**json.loads(_retried(lambda: path.read_text(encoding="utf-8"))))
378
+ return from_json(_retried(lambda: path.read_text(encoding="utf-8")), SessionEntry)
302
379
  except (OSError, ValueError, TypeError):
303
380
  return None
304
381
 
@@ -356,14 +433,28 @@ def start_session_process(
356
433
  ) -> "subprocess.Popen[bytes]":
357
434
  """Start a session process, detached from this one. The process writes its standard output and standard error to
358
435
  `stdout` and reads them back from `output`, the read end of the same pipe, which it inherits: a handle on
359
- Windows, and there the only one it inherits beside its standard handles."""
360
- passed: Dict[str, Any]
436
+ Windows, and there the only one it inherits beside its standard handles.
437
+
438
+ The process keeps running after the command that started it has returned: in a new process session on Linux and
439
+ macOS, on Windows in a new process group, which a Ctrl-C at the console of the command doesn't reach, and with a
440
+ console without a window. Console programs that the session starts use that console instead of opening windows of
441
+ their own."""
361
442
  if sys.platform == "win32":
362
443
  os.set_handle_inheritable(output, True)
363
- passed = {"startupinfo": subprocess.STARTUPINFO(lpAttributeList={"handle_list": [output]})}
444
+ try:
445
+ process = subprocess.Popen(
446
+ command,
447
+ stdin=subprocess.PIPE,
448
+ stdout=stdout,
449
+ stderr=subprocess.STDOUT,
450
+ env=env,
451
+ cwd=cwd,
452
+ creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW,
453
+ startupinfo=subprocess.STARTUPINFO(lpAttributeList={"handle_list": [output]}),
454
+ )
455
+ finally:
456
+ os.set_handle_inheritable(output, False)
364
457
  else:
365
- passed = {"pass_fds": (output,)}
366
- try:
367
458
  process = subprocess.Popen(
368
459
  command,
369
460
  stdin=subprocess.PIPE,
@@ -371,12 +462,9 @@ def start_session_process(
371
462
  stderr=subprocess.STDOUT,
372
463
  env=env,
373
464
  cwd=cwd,
374
- **_DETACHED,
375
- **passed,
465
+ start_new_session=True,
466
+ pass_fds=(output,),
376
467
  )
377
- finally:
378
- if sys.platform == "win32":
379
- os.set_handle_inheritable(output, False)
380
468
  # The session reads its inputs from its channel. A closed pipe as standard input makes it a session without a
381
469
  # terminal on every platform, unlike the null device, which Windows takes for a terminal.
382
470
  if process.stdin is not None:
@@ -384,14 +472,14 @@ def start_session_process(
384
472
  return process
385
473
 
386
474
 
387
- def _take_handover(session_id: str) -> Dict[str, Any]:
388
- """Read and remove the file that the previous process of the session wrote for this one at a restart; empty for
475
+ def _take_handover(session_id: str) -> Optional[_Handover]:
476
+ """Read and remove the file that the previous process of the session wrote for this one at a restart; None for
389
477
  the first process of a session."""
390
478
  path = _restart_path(session_id)
391
479
  try:
392
- handover: Dict[str, Any] = json.loads(_retried(lambda: path.read_text(encoding="utf-8")))
393
- except (OSError, ValueError):
394
- return {}
480
+ handover = from_json(_retried(lambda: path.read_text(encoding="utf-8")), _Handover)
481
+ except (OSError, ValueError, TypeError):
482
+ return None
395
483
  with contextlib.suppress(OSError):
396
484
  path.unlink()
397
485
  return handover
@@ -418,8 +506,17 @@ def _passed_or_failed(failed: bool) -> AnswerStatus:
418
506
  return AnswerStatus.FAILED if failed else AnswerStatus.PASSED
419
507
 
420
508
 
509
+ @unique
510
+ class _Prompt(Enum):
511
+ """The prompt the interpreter waits at."""
512
+
513
+ INPUT = auto()
514
+ # At a stop of the debugger.
515
+ STOP = auto()
516
+
517
+
421
518
  @dataclass
422
- class _Request:
519
+ class _QueuedInput:
423
520
  """The input of a `send` request, and its answer once it has run."""
424
521
 
425
522
  text: str
@@ -442,11 +539,11 @@ class SessionServer:
442
539
  self.session_id = session_id
443
540
  self._kind = kind
444
541
  self._condition = threading.Condition()
445
- self._requests: Deque[_Request] = deque()
446
- # The request whose input runs.
447
- self._current: Optional[_Request] = None
542
+ self._queue: Deque[_QueuedInput] = deque()
543
+ # The input that runs.
544
+ self._current: Optional[_QueuedInput] = None
448
545
  # The prompt the interpreter waits at, None while something runs.
449
- self._prompt: Optional[str] = None
546
+ self._prompt: Optional[_Prompt] = None
450
547
  # Updated each time the interpreter arrives at its prompt: how often it has arrived, whether the input failed,
451
548
  # and how much output there was then.
452
549
  self._arrivals = 0
@@ -466,7 +563,7 @@ class SessionServer:
466
563
  # The interpreter, which tells whether a signal has ended the REPL or the run.
467
564
  self._interpreter: Optional["SessionInterpreter"] = None
468
565
  # The answer to `restart`, once the new process has taken over or failed to.
469
- self._restart_answer: Optional[Dict[str, Any]] = None
566
+ self._restart_answer: Optional[Union[RestartedAnswer, RestartFailedAnswer]] = None
470
567
  # Set while a restart starts the new process, which writes the session file from then on; `_handed_over` once
471
568
  # the new process has taken over and answers the requests.
472
569
  self._handing_over = False
@@ -487,8 +584,8 @@ class SessionServer:
487
584
  self._environment = dict(os.environ)
488
585
  self._folder = os.getcwd()
489
586
  handover = _take_handover(session_id)
490
- self._generation: int = handover.get("generation", 0)
491
- self._handed_breakpoints: Optional[Dict[str, Any]] = handover.get("breakpoints")
587
+ self._generation = handover.generation if handover is not None else 0
588
+ self._handed_breakpoints = handover.breakpoints if handover is not None else None
492
589
  # The pipe the process writes its standard output and standard error to. What the session has read from it
493
590
  # stays in memory until an answer carries it.
494
591
  self._pipe = _own_pipe(output)
@@ -517,11 +614,16 @@ class SessionServer:
517
614
  address=str(self._listener.address),
518
615
  key=key.hex(),
519
616
  project=str(project),
520
- started=handover.get("started", now),
617
+ started=handover.started if handover is not None else now,
521
618
  idle_timeout=idle_timeout,
522
619
  last_request=now,
523
620
  )
524
- _write_entry(self._entry)
621
+ try:
622
+ _write_entry(self._entry)
623
+ except OSError as e:
624
+ # A sandbox can keep the process from writing there, even where `session start` could create the folder.
625
+ self._listener.close()
626
+ raise SessionError(f"Can't create the files of the session in {sessions_dir()}: {e.strerror or e}.") from e
525
627
  threading.Thread(target=self._accept, name="robotcode-session", daemon=True).start()
526
628
  # Only once the file exists: until then, `session start` reads the pipe itself if the process ends before it is
527
629
  # ready.
@@ -557,17 +659,16 @@ class SessionServer:
557
659
  """Wait at `prompt` for the next request and return its whole input; the request that ran before gets its
558
660
  answer first. While it waits, end the REPL or the run when the session has been idle too long or is asked to
559
661
  stop or restart."""
560
- prompt = prompt.strip()
561
662
  with self._condition:
562
- self._arrive(prompt)
663
+ self._arrive(_Prompt.STOP if prompt.strip() == ConsoleInterpreter.DEBUG_PROMPT.strip() else _Prompt.INPUT)
563
664
  while True:
564
- if self._requests:
565
- self._current = request = self._requests.popleft()
566
- request.started = True
665
+ if self._queue:
666
+ self._current = queued = self._queue.popleft()
667
+ queued.started = True
567
668
  self._prompt = None
568
669
  self._update_entry(state=SessionState.RUNS)
569
- return request.text
570
- if self._kind is SessionKind.DEBUG and prompt == ConsoleInterpreter.DEBUG_PROMPT.strip():
670
+ return queued.text
671
+ if self._waits_at_stop():
571
672
  if self._stop_requested or self._restart_requested:
572
673
  # A debug session ends its run as `.abort` does.
573
674
  return ".abort"
@@ -641,27 +742,27 @@ class SessionServer:
641
742
  with self._condition:
642
743
  self._handing_over = True
643
744
  breakpoints = self._controller.breakpoint_state() if self._controller is not None else None
745
+ handover = _Handover(self._generation + 1, self._entry.started, breakpoints)
644
746
  # Only one process may read the pipe; from now on it is the new one.
645
747
  self._stop_reading()
646
748
  process: "Optional[subprocess.Popen[bytes]]" = None
647
749
  try:
648
- _write_json(
649
- _restart_path(self.session_id),
650
- {"generation": self._generation + 1, "started": self._entry.started, "breakpoints": breakpoints},
651
- )
750
+ _write_json(_restart_path(self.session_id), handover)
652
751
  process = start_session_process(self._command, self._pipe_handle(), 1, self._environment, self._folder)
653
- failure = self._wait_for_new_process(process)
752
+ self._wait_for_new_process(process)
753
+ except SessionError as e:
754
+ failure = str(e)
654
755
  except OSError as e:
655
756
  failure = f"The session couldn't start again: {e.strerror or e}."
656
- if failure is None:
757
+ else:
657
758
  self._hand_over()
658
759
  return True
659
760
  self._keep_session(process, failure)
660
761
  return False
661
762
 
662
- def _wait_for_new_process(self, process: "subprocess.Popen[bytes]") -> Optional[str]:
663
- """Wait until `process` has written the session file, which it does once it accepts requests; None then,
664
- otherwise why it didn't get ready."""
763
+ def _wait_for_new_process(self, process: "subprocess.Popen[bytes]") -> None:
764
+ """Wait until `process` has written the session file, which it does once it accepts requests; `SessionError`
765
+ with the reason when it doesn't get ready."""
665
766
  # Known by its channel: on Windows, the `python.exe` of a virtual environment runs the interpreter in a process
666
767
  # of its own, whose ID isn't that of `process`.
667
768
  address = _address(self.session_id, self._generation + 1)
@@ -669,13 +770,15 @@ class SessionServer:
669
770
  while True:
670
771
  entry = _load_entry(self.session_id)
671
772
  if entry is not None and entry.address == address:
672
- return None
773
+ return
673
774
  if process.poll() is not None:
674
- return f"The session couldn't start again; its new process ended with exit code {process.returncode}."
775
+ raise SessionError(
776
+ f"The session couldn't start again; its new process ended with exit code {process.returncode}."
777
+ )
675
778
  if time.monotonic() >= ready_by:
676
779
  process.kill()
677
780
  process.wait()
678
- return (
781
+ raise SessionError(
679
782
  f"The session couldn't start again; its new process wasn't ready after {STARTUP_LIMIT} seconds "
680
783
  "and was ended."
681
784
  )
@@ -687,7 +790,7 @@ class SessionServer:
687
790
  with self._output_lock:
688
791
  size = self._read_size
689
792
  with self._condition:
690
- self._restart_answer = {"output": self._read_output(size), "restarted": True}
793
+ self._restart_answer = RestartedAnswer(self._read_output(size))
691
794
  self._handed_over = True
692
795
  self._close_channel()
693
796
  # Anything this process still writes would end up in the output of the new process.
@@ -712,11 +815,11 @@ class SessionServer:
712
815
  self._handing_over = False
713
816
  # The new process may have written the file before it ended.
714
817
  self._update_entry()
715
- self._restart_answer = {"output": self._read_output(self._output_size()), "restart_failed": failure}
818
+ self._restart_answer = RestartFailedAnswer(self._read_output(self._output_size()), failure)
716
819
  self._restart_requested = False
717
820
  self._condition.notify_all()
718
821
 
719
- def _arrive(self, prompt: str) -> None:
822
+ def _arrive(self, prompt: _Prompt) -> None:
720
823
  """The interpreter has arrived at `prompt`: give the request that ran its answer, and wake the threads that
721
824
  wait for it."""
722
825
  _flush_output()
@@ -724,11 +827,11 @@ class SessionServer:
724
827
  # No I/O up to the notification: a Ctrl-C at the prompt can interrupt this method, and the request must then
725
828
  # either have its answer or be left to the next arrival.
726
829
  failed, self._failed = self._failed, False
727
- request, self._current = self._current, None
728
- if request is not None:
729
- request.output_end = end
730
- request.failed = failed
731
- request.done = True
830
+ queued, self._current = self._current, None
831
+ if queued is not None:
832
+ queued.output_end = end
833
+ queued.failed = failed
834
+ queued.done = True
732
835
  self._arrivals += 1
733
836
  self._arrival_failed = failed
734
837
  self._arrival_end = end
@@ -743,6 +846,10 @@ class SessionServer:
743
846
  idle_since = max(self._entry.last_request, self._entry.waits_since or 0)
744
847
  return time.time() - idle_since >= self._entry.idle_timeout
745
848
 
849
+ def _waits_at_stop(self) -> bool:
850
+ """Whether the run of a debug session waits at a stop of the debugger."""
851
+ return self._kind is SessionKind.DEBUG and self._prompt is _Prompt.STOP
852
+
746
853
  # --- the requests, each on a thread of its own ---------------------------
747
854
 
748
855
  def _accept(self) -> None:
@@ -760,9 +867,7 @@ class SessionServer:
760
867
  self._serving += 1
761
868
  try:
762
869
  with connection:
763
- answer = self._answer(connection, connection.recv())
764
- if answer is not None:
765
- connection.send(answer)
870
+ connection.send(self._answer(connection, connection.recv()))
766
871
  except (EOFError, OSError):
767
872
  # The client has gone.
768
873
  pass
@@ -771,72 +876,71 @@ class SessionServer:
771
876
  self._serving -= 1
772
877
  self._condition.notify_all()
773
878
 
774
- def _answer(self, connection: Connection, message: Dict[str, Any]) -> Optional[Dict[str, Any]]:
775
- request = message.get("request")
879
+ def _answer(self, connection: Connection, request: SessionRequest) -> ChannelAnswer:
880
+ kind = request.kind
776
881
  with self._condition:
777
882
  if self._handed_over:
778
883
  # The command then asks the new process, which the session file names now.
779
- return {"moved": True}
884
+ return MovedAnswer()
780
885
  if self._closing:
781
886
  # The session has just ended, as the next command would find.
782
- return {"error": f"No session '{self.session_id}' is open."}
887
+ return RefusedAnswer(f"No session '{self.session_id}' is open.")
783
888
  self._update_entry(last_request=time.time())
784
889
  run_ended = self._run_ended
785
- if request == Request.SEND:
890
+ if kind is Request.SEND:
786
891
  if run_ended:
787
892
  return self._ended_error()
788
- return self._send(connection, str(message.get("input", "")))
789
- if request == Request.WAIT:
893
+ return self._send(connection, request.input)
894
+ if kind is Request.WAIT:
790
895
  return self._wait(connection)
791
- if request == Request.STATUS:
896
+ if kind is Request.STATUS:
792
897
  with self._condition:
793
898
  return self._status()
794
- if request == Request.STATE:
899
+ if kind is Request.STATE:
795
900
  with self._condition:
796
- return {"state": self._state()}
797
- if request == Request.RESULT:
901
+ return StateAnswer(self._state())
902
+ if kind is Request.RESULT:
798
903
  if not run_ended:
799
- return {"error": f"{self._kind.what} of session '{self.session_id}' hasn't ended yet."}
800
- return {"exit_code": self._exit_code}
801
- if request == Request.INTERRUPT:
904
+ return RefusedAnswer(f"{self._kind.what} of session '{self.session_id}' hasn't ended yet.")
905
+ return ResultAnswer(self._exit_code)
906
+ if kind is Request.INTERRUPT:
802
907
  if run_ended:
803
908
  return self._ended_error()
804
909
  with self._condition:
805
910
  self._interrupted = True
806
911
  # As Ctrl-C does at the terminal of the session.
807
912
  self._signal(signal.SIGINT)
808
- return {"interrupted": True}
809
- if request == Request.STOP:
913
+ return InterruptedAnswer()
914
+ if kind is Request.STOP:
810
915
  return self._stop(connection)
811
- if request == Request.RESTART:
916
+ if kind is Request.RESTART:
812
917
  return self._restart(connection)
813
- return {"error": f"Unknown request {request!r}."}
918
+ assert_never(kind)
814
919
 
815
- def _ended_error(self) -> Dict[str, Any]:
920
+ def _ended_error(self) -> RefusedAnswer:
816
921
  session_id = self.session_id
817
- return {
818
- "error": f"{self._kind.what} of session '{session_id}' has ended; `robotcode session restart {session_id}` "
819
- f"starts it again, and `robotcode session result {session_id}` prints its exit code."
820
- }
922
+ return RefusedAnswer(
923
+ f"{self._kind.what} of session '{session_id}' has ended; `robotcode session restart {session_id}` starts "
924
+ f"it again, and `robotcode session result {session_id}` prints its exit code."
925
+ )
821
926
 
822
- def _send(self, connection: Connection, text: str) -> Optional[Dict[str, Any]]:
927
+ def _send(self, connection: Connection, text: str) -> ChannelAnswer:
823
928
  lines = text.strip().splitlines()
824
929
  if len(lines) > 1 and lines[0].lstrip().startswith("."):
825
930
  # A dot-command is an input of its own.
826
- return {"output": "[ ERROR ] Dot-commands can only be sent on their own.\n", "status": AnswerStatus.FAILED}
827
- request = _Request(text)
931
+ return OutputAnswer("[ ERROR ] Dot-commands can only be sent on their own.\n", AnswerStatus.FAILED)
932
+ queued = _QueuedInput(text)
828
933
  with self._condition:
829
- self._requests.append(request)
934
+ self._queue.append(queued)
830
935
  self._condition.notify_all()
831
936
  return self._await(
832
937
  connection,
833
- lambda: request.done,
834
- lambda: self._make_answer(request.output_end, _passed_or_failed(request.failed)),
938
+ lambda: self._make_answer(queued.output_end, _passed_or_failed(queued.failed)) if queued.done else None,
835
939
  # An input that was still queued when the REPL or the run ended never ran.
836
- lambda: self._answer_ended() if request.started else self._ended_error(),
940
+ lambda: self._answer_ended() if queued.started else self._ended_error(),
837
941
  )
838
942
 
839
- def _wait(self, connection: Connection) -> Optional[Dict[str, Any]]:
943
+ def _wait(self, connection: Connection) -> ChannelAnswer:
840
944
  with self._condition:
841
945
  if self._run_ended:
842
946
  return self._answer_ended()
@@ -846,42 +950,43 @@ class SessionServer:
846
950
  arrivals = self._arrivals
847
951
  return self._await(
848
952
  connection,
849
- lambda: self._arrivals > arrivals,
850
- lambda: self._make_answer(self._arrival_end, _passed_or_failed(self._arrival_failed)),
953
+ lambda: (
954
+ self._make_answer(self._arrival_end, _passed_or_failed(self._arrival_failed))
955
+ if self._arrivals > arrivals
956
+ else None
957
+ ),
851
958
  self._answer_ended,
852
959
  )
853
960
 
854
- def _stop(self, connection: Connection) -> Optional[Dict[str, Any]]:
961
+ def _stop(self, connection: Connection) -> ChannelAnswer:
855
962
  with self._condition:
856
963
  self._stop_requested = True
857
964
  self._condition.notify_all()
858
965
  self._end_run()
859
- return self._await(connection, lambda: self._closing, self._stopped_answer)
966
+ return self._await(connection, lambda: self._stopped_answer() if self._closing else None)
860
967
 
861
- def _stopped_answer(self) -> Dict[str, Any]:
968
+ def _stopped_answer(self) -> Union[MovedAnswer, StoppedAnswer]:
862
969
  if self._handed_over:
863
970
  # A restart has taken over meanwhile: the command stops the new process.
864
- return {"moved": True}
865
- return {"output": self._read_output(self._output_size()), "stopped": True}
971
+ return MovedAnswer()
972
+ return StoppedAnswer(self._read_output(self._output_size()))
866
973
 
867
- def _restart(self, connection: Connection) -> Optional[Dict[str, Any]]:
974
+ def _restart(self, connection: Connection) -> ChannelAnswer:
868
975
  with self._condition:
869
976
  if self._restart_requested:
870
- return {"error": f"Session '{self.session_id}' restarts already."}
977
+ return RefusedAnswer(f"Session '{self.session_id}' restarts already.")
871
978
  self._restart_requested = True
872
979
  self._restart_answer = None
873
980
  self._condition.notify_all()
874
981
  self._end_run()
875
- return self._await(
876
- connection, lambda: self._restart_answer is not None, lambda: dict(self._restart_answer or {})
877
- )
982
+ return self._await(connection, lambda: self._restart_answer)
878
983
 
879
984
  def _end_run(self) -> None:
880
985
  """End the REPL or the run, unless it has ended, for a `stop` or a `restart` that the caller has noted."""
881
986
  with self._condition:
882
987
  if self._run_ended:
883
988
  return
884
- waits_at_stop = self._kind is SessionKind.DEBUG and self._prompt == ConsoleInterpreter.DEBUG_PROMPT.strip()
989
+ waits_at_stop = self._waits_at_stop()
885
990
  if self._kind is SessionKind.REPL:
886
991
  # As SIGTERM does.
887
992
  self._signal(signal.SIGTERM)
@@ -893,48 +998,47 @@ class SessionServer:
893
998
  def _await(
894
999
  self,
895
1000
  connection: Connection,
896
- done: Callable[[], bool],
897
- answer: Callable[[], Dict[str, Any]],
898
- at_end: Optional[Callable[[], Dict[str, Any]]] = None,
899
- ) -> Optional[Dict[str, Any]]:
900
- """Wait until `done()` and return `answer()`, or return `at_end()` once the REPL or the run has ended.
1001
+ ready: Callable[[], Optional[ChannelAnswer]],
1002
+ at_end: Optional[Callable[[], ChannelAnswer]] = None,
1003
+ ) -> ChannelAnswer:
1004
+ """Wait until `ready()` has the answer and return it, or return `at_end()` once the REPL or the run has ended.
901
1005
  Meanwhile the client can send a `status` request after its timeout: it then gets the output so far, and a
902
1006
  later `wait` gets the answer."""
903
1007
  while True:
904
1008
  with self._condition:
905
- if done():
906
- return answer()
1009
+ answer = ready()
1010
+ if answer is not None:
1011
+ return answer
907
1012
  if at_end is not None and self._run_ended:
908
1013
  return at_end()
909
1014
  self._condition.wait(0.1)
910
1015
  if connection.poll():
911
- if connection.recv().get("request") == Request.STATUS:
1016
+ if connection.recv().kind is Request.STATUS:
912
1017
  with self._condition:
913
1018
  return self._status()
914
1019
 
915
1020
  def _runs(self) -> bool:
916
- return self._prompt is None or self._current is not None or bool(self._requests)
1021
+ return self._prompt is None or self._current is not None or bool(self._queue)
917
1022
 
918
1023
  def _state(self) -> SessionState:
919
1024
  if self._run_ended:
920
1025
  return SessionState.ENDED
921
1026
  return SessionState.RUNS if self._runs() else SessionState.WAITS
922
1027
 
923
- def _status(self) -> Dict[str, Any]:
1028
+ def _status(self) -> OutputAnswer:
924
1029
  if self._run_ended:
925
1030
  return self._answer_ended()
926
1031
  if self._runs():
927
1032
  return self._make_answer(self._output_size(), AnswerStatus.RUNNING)
928
1033
  return self._make_answer(self._output_size(), _passed_or_failed(self._arrival_failed))
929
1034
 
930
- def _answer_ended(self) -> Dict[str, Any]:
1035
+ def _answer_ended(self) -> OutputAnswer:
931
1036
  """The output since the last answer, with the status of the input that ran at the end and the exit code of the
932
1037
  REPL or the run."""
933
- answer = self._make_answer(self._output_size(), _passed_or_failed(self._end_failed))
934
- return {**answer, "exit_code": self._exit_code}
1038
+ return self._make_answer(self._output_size(), _passed_or_failed(self._end_failed), self._exit_code)
935
1039
 
936
- def _make_answer(self, end: int, status: AnswerStatus) -> Dict[str, Any]:
937
- return {"output": self._read_output(end), "status": status}
1040
+ def _make_answer(self, end: int, status: AnswerStatus, exit_code: Optional[int] = None) -> OutputAnswer:
1041
+ return OutputAnswer(self._read_output(end), status, exit_code)
938
1042
 
939
1043
  def _read_output(self, end: int) -> str:
940
1044
  """Take the output from where the last answer ended up to `end` out of memory, and return it."""
@@ -1025,15 +1129,33 @@ class SessionServer:
1025
1129
  self._read_size += len(data)
1026
1130
  self._at_line_start = data.endswith(b"\n")
1027
1131
 
1028
- def _update_entry(self, **changes: Any) -> None:
1132
+ def _update_entry(
1133
+ self,
1134
+ *,
1135
+ state: Optional[SessionState] = None,
1136
+ waits_since: Optional[float] = None,
1137
+ last_request: Optional[float] = None,
1138
+ ) -> None:
1139
+ """Write the session file, with the values given changed."""
1029
1140
  if self._closing or self._handing_over:
1030
1141
  return
1031
- self._entry = dataclasses.replace(self._entry, **changes)
1142
+ if state is not None:
1143
+ self._entry.state = state
1144
+ if waits_since is not None:
1145
+ self._entry.waits_since = waits_since
1146
+ if last_request is not None:
1147
+ self._entry.last_request = last_request
1032
1148
  with contextlib.suppress(OSError):
1033
1149
  _write_entry(self._entry)
1034
1150
 
1035
1151
  def _signal(self, signum: int) -> None:
1036
- """Let the interpreter, in the main thread, handle `signum` as when the signal arrives."""
1152
+ """Let the interpreter, in the main thread, handle `signum` as when the signal arrives. Robot Framework handles
1153
+ signals only during a run; without its handlers, as in the new process of a restart that is still starting, the
1154
+ signal would end the process at once. So it waits for them, unless the REPL or the run has ended already."""
1155
+ while signal.getsignal(signum) in (signal.SIG_DFL, signal.default_int_handler):
1156
+ if self._run_ended:
1157
+ return
1158
+ time.sleep(0.01)
1037
1159
  if sys.platform == "win32":
1038
1160
  signal.raise_signal(signum)
1039
1161
  else:
@@ -1,13 +1,15 @@
1
+ import dataclasses
1
2
  import multiprocessing
2
3
  import os
3
4
  import sys
4
5
  import time
5
- from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar
6
+ from typing import Any, Callable, List, Optional, Tuple, Type, TypeVar
6
7
 
7
8
  import click
8
9
  from click.core import ParameterSource
9
10
  from robot.errors import DATA_ERROR
10
11
 
12
+ from robotcode.core.utils.dataclasses import as_dict
11
13
  from robotcode.core.utils.path import same_file
12
14
  from robotcode.plugin import Application, OutputFormat, pass_application
13
15
  from robotcode.plugin.click_helper.exit_codes import robot_exit_codes
@@ -16,9 +18,20 @@ from robotcode.plugin.click_helper.types import add_options
16
18
  from ._session import (
17
19
  STARTUP_LIMIT,
18
20
  AnswerStatus,
21
+ ChannelAnswer,
22
+ MovedAnswer,
23
+ OutputAnswer,
24
+ RefusedAnswer,
19
25
  Request,
26
+ RestartedAnswer,
27
+ RestartFailedAnswer,
28
+ ResultAnswer,
29
+ SessionAnswer,
20
30
  SessionEntry,
21
31
  SessionKind,
32
+ SessionRequest,
33
+ StateAnswer,
34
+ StoppedAnswer,
22
35
  connect,
23
36
  create_output_pipe,
24
37
  create_sessions_dir,
@@ -36,6 +49,7 @@ if sys.platform == "win32":
36
49
  import msvcrt
37
50
 
38
51
  F = TypeVar("F", bound=Callable[..., Any])
52
+ A = TypeVar("A", bound=SessionAnswer)
39
53
 
40
54
  # How long `session start` waits for a session to take commands, when `--timeout` isn't longer.
41
55
  _STARTUP_LIMIT = STARTUP_LIMIT
@@ -238,9 +252,9 @@ def _start(
238
252
  # Once the session is ready, it reads the pipe itself.
239
253
  os.close(read_end)
240
254
 
241
- answer = _request(entry, {"request": Request.WAIT}, max(0.0, deadline - time.monotonic()))
255
+ answer = _expect(OutputAnswer, entry, SessionRequest(Request.WAIT), max(0.0, deadline - time.monotonic()))
242
256
  if _structured(app):
243
- app.print_data({"id": session_id, **_answer_data(answer)})
257
+ app.print_data({"id": session_id, **as_dict(answer, remove_defaults=True)})
244
258
  else:
245
259
  app.echo(f"Started session {session_id}.")
246
260
  _echo_answer(app, entry, answer)
@@ -273,7 +287,7 @@ def send(app: Application, timeout: float, session_id: str, text: str) -> None:
273
287
  `session restart` starts it again.
274
288
  """
275
289
  entry = _entry(session_id)
276
- answer = _request(entry, {"request": Request.SEND, "input": text}, timeout)
290
+ answer = _expect(OutputAnswer, entry, SessionRequest(Request.SEND, text), timeout)
277
291
  _echo_answer(app, entry, answer)
278
292
  app.exit(_exit_code(answer))
279
293
 
@@ -291,7 +305,7 @@ def wait(app: Application, timeout: float, session_id: str) -> None:
291
305
  code that `send` would have had.
292
306
  """
293
307
  entry = _entry(session_id)
294
- answer = _request(entry, {"request": Request.WAIT}, timeout)
308
+ answer = _expect(OutputAnswer, entry, SessionRequest(Request.WAIT), timeout)
295
309
  _echo_answer(app, entry, answer)
296
310
  app.exit(_exit_code(answer))
297
311
 
@@ -305,11 +319,11 @@ def session_status(app: Application, session_id: str) -> None:
305
319
 
306
320
  It takes none of the output of the session, which `session wait` prints.
307
321
  """
308
- answer = _request(_entry(session_id), {"request": Request.STATE})
322
+ answer = _expect(StateAnswer, _entry(session_id), SessionRequest(Request.STATE))
309
323
  if _structured(app):
310
- app.print_data({"state": answer["state"]})
324
+ app.print_data(answer)
311
325
  else:
312
- app.echo(str(answer["state"]))
326
+ app.echo(str(answer.state))
313
327
 
314
328
 
315
329
  @session.command(name="result")
@@ -322,11 +336,11 @@ def session_result(app: Application, session_id: str) -> None:
322
336
  `result` itself exits with 0, as Robot Framework gives exit codes a meaning
323
337
  of its own; before the end, it says so and exits with 252.
324
338
  """
325
- answer = _request(_entry(session_id), {"request": Request.RESULT})
339
+ answer = _expect(ResultAnswer, _entry(session_id), SessionRequest(Request.RESULT))
326
340
  if _structured(app):
327
- app.print_data({"exit_code": answer["exit_code"]})
341
+ app.print_data(answer)
328
342
  else:
329
- app.echo(str(answer["exit_code"]))
343
+ app.echo(str(answer.exit_code))
330
344
 
331
345
 
332
346
  @session.command()
@@ -345,19 +359,21 @@ def restart(app: Application, timeout: float, session_id: str) -> None:
345
359
  """
346
360
  entry = _entry(session_id)
347
361
  # The old process ends the REPL or the run as `stop` does, then waits for the new process to take commands.
348
- answer = _request(entry, {"request": Request.RESTART}, 2 * _STARTUP_LIMIT)
349
- before = answer.get("output", "")
350
- if not answer.get("restarted"):
351
- if before:
352
- app.echo(before, nl=False)
353
- if "restart_failed" in answer:
354
- raise click.ClickException(str(answer["restart_failed"]))
362
+ restarted = _request(entry, SessionRequest(Request.RESTART), 2 * _STARTUP_LIMIT)
363
+ if not isinstance(restarted, RestartedAnswer):
364
+ # After its limit, `restart` gets the output so far, while the restart goes on.
365
+ if isinstance(restarted, (OutputAnswer, RestartFailedAnswer)) and restarted.output:
366
+ app.echo(restarted.output, nl=False)
367
+ if isinstance(restarted, RestartFailedAnswer):
368
+ raise click.ClickException(restarted.reason)
355
369
  raise click.ClickException(f"Session '{session_id}' hasn't restarted yet; `session status` tells when it has.")
370
+ before = restarted.output
356
371
  # The file names the new process now.
357
372
  entry = _entry(session_id)
358
- answer = _request(entry, {"request": Request.WAIT}, timeout)
373
+ answer = _expect(OutputAnswer, entry, SessionRequest(Request.WAIT), timeout)
359
374
  if _structured(app):
360
- app.print_data({"id": session_id, **_answer_data({**answer, "output": before + answer.get("output", "")})})
375
+ whole = dataclasses.replace(answer, output=before + answer.output)
376
+ app.print_data({"id": session_id, **as_dict(whole, remove_defaults=True)})
361
377
  else:
362
378
  if before:
363
379
  app.echo(before, nl=not before.endswith("\n"))
@@ -375,7 +391,7 @@ def interrupt(session_id: str) -> None:
375
391
  In a REPL, the statement that runs fails; with the debugger attached, the run
376
392
  pauses at the next keyword. `session wait` prints what follows.
377
393
  """
378
- _request(_entry(session_id), {"request": Request.INTERRUPT})
394
+ _request(_entry(session_id), SessionRequest(Request.INTERRUPT))
379
395
 
380
396
 
381
397
  @session.command()
@@ -396,8 +412,8 @@ def stop(app: Application, of_project: bool, all_sessions: bool, session_id: Opt
396
412
  raise click.UsageError("Give the ID of a session, --project or --all.")
397
413
  if session_id is not None:
398
414
  answer = _stop(_entry(session_id))
399
- if answer.get("output"):
400
- app.echo(answer["output"], nl=False)
415
+ if answer.output:
416
+ app.echo(answer.output, nl=False)
401
417
  return
402
418
 
403
419
  entries = open_sessions()
@@ -413,9 +429,9 @@ def stop(app: Application, of_project: bool, all_sessions: bool, session_id: Opt
413
429
  app.echo(f"Stopped session {entry.id}.")
414
430
 
415
431
 
416
- def _stop(entry: SessionEntry) -> Dict[str, Any]:
417
- answer = _request(entry, {"request": Request.STOP}, 60)
418
- if not answer.get("stopped"):
432
+ def _stop(entry: SessionEntry) -> StoppedAnswer:
433
+ answer = _request(entry, SessionRequest(Request.STOP), 60)
434
+ if not isinstance(answer, StoppedAnswer):
419
435
  raise click.ClickException(f"Session '{entry.id}' hasn't ended yet; a second `session stop` ends it at once.")
420
436
  return answer
421
437
 
@@ -464,35 +480,47 @@ def _entry(session_id: str) -> SessionEntry:
464
480
  return entry
465
481
 
466
482
 
467
- def _request(entry: SessionEntry, message: Dict[str, Any], timeout: Optional[float] = None) -> Dict[str, Any]:
468
- """The answer of the session `entry` to `message`; after `timeout` seconds, the output so far."""
469
- answer = _ask(entry, message, timeout)
470
- if answer.get("moved"):
483
+ def _request(entry: SessionEntry, request: SessionRequest, timeout: Optional[float] = None) -> SessionAnswer:
484
+ """The answer of the session `entry` to `request`; after `timeout` seconds, the output so far."""
485
+ answer = _ask(entry, request, timeout)
486
+ while isinstance(answer, MovedAnswer):
471
487
  # The session has just restarted in a new process, which its file names now.
472
488
  moved_to = read_entry(entry.id)
473
489
  if moved_to is None or moved_to.address == entry.address:
474
490
  raise click.ClickException(f"No session '{entry.id}' is open.")
475
- answer = _ask(moved_to, message, timeout)
476
- if "error" in answer:
477
- raise click.ClickException(str(answer["error"]))
491
+ entry = moved_to
492
+ answer = _ask(entry, request, timeout)
493
+ if isinstance(answer, RefusedAnswer):
494
+ raise click.ClickException(answer.message)
495
+ return answer
496
+
497
+
498
+ def _expect(answer_type: Type[A], entry: SessionEntry, request: SessionRequest, timeout: Optional[float] = None) -> A:
499
+ """The answer of the session `entry` to a `request` that only an `answer_type` answers."""
500
+ answer = _request(entry, request, timeout)
501
+ if not isinstance(answer, answer_type):
502
+ raise click.ClickException(f"Session '{entry.id}' gave an unexpected answer.")
478
503
  return answer
479
504
 
480
505
 
481
- def _ask(entry: SessionEntry, message: Dict[str, Any], timeout: Optional[float]) -> Dict[str, Any]:
482
- """Send `message` to the process that `entry` names and return its answer; after `timeout` seconds, the output so
506
+ def _ask(entry: SessionEntry, request: SessionRequest, timeout: Optional[float]) -> ChannelAnswer:
507
+ """Send `request` to the process that `entry` names and return its answer; after `timeout` seconds, the output so
483
508
  far."""
484
509
  try:
485
510
  with connect(entry) as connection:
486
- connection.send(message)
511
+ connection.send(request)
487
512
  if timeout is not None and not connection.poll(timeout):
488
513
  try:
489
- connection.send({"request": Request.STATUS})
514
+ connection.send(SessionRequest(Request.STATUS))
490
515
  except OSError:
491
516
  # The answer came just now, and the session has closed the connection.
492
517
  pass
493
- answer: Dict[str, Any] = connection.recv()
494
- except (OSError, EOFError, multiprocessing.AuthenticationError) as e:
495
- raise click.ClickException(f"Session '{entry.id}' doesn't answer: {e or type(e).__name__}") from e
518
+ answer: ChannelAnswer = connection.recv()
519
+ except (EOFError, ConnectionError, FileNotFoundError):
520
+ # The process has ended before it answered, as the old process of a restart does; its file tells what follows.
521
+ return MovedAnswer()
522
+ except (OSError, multiprocessing.AuthenticationError) as e:
523
+ raise click.ClickException(f"Session '{entry.id}' doesn't answer: {e}") from e
496
524
  return answer
497
525
 
498
526
 
@@ -500,31 +528,20 @@ def _structured(app: Application) -> bool:
500
528
  return app.config.output_format not in (None, OutputFormat.TEXT)
501
529
 
502
530
 
503
- def _answer_data(answer: Dict[str, Any]) -> Dict[str, Any]:
504
- data = {"output": answer.get("output", ""), "status": answer.get("status")}
505
- if "exit_code" in answer:
506
- data["exit_code"] = answer["exit_code"]
507
- return data
508
-
509
-
510
- def _echo_answer(app: Application, entry: SessionEntry, answer: Dict[str, Any]) -> None:
531
+ def _echo_answer(app: Application, entry: SessionEntry, answer: OutputAnswer) -> None:
511
532
  if _structured(app):
512
- app.print_data(_answer_data(answer))
533
+ app.print_data(answer)
513
534
  return
514
- output = answer.get("output", "")
515
- if output:
516
- app.echo(output, nl=False)
517
- if not output.endswith("\n"):
535
+ if answer.output:
536
+ app.echo(answer.output, nl=False)
537
+ if not answer.output.endswith("\n"):
518
538
  app.echo("")
519
- if "exit_code" in answer:
520
- app.echo(f"{entry.kind.what} has ended with exit code {answer['exit_code']}.", err=True)
521
- elif answer.get("status") == AnswerStatus.RUNNING:
539
+ if answer.exit_code is not None:
540
+ app.echo(f"{entry.kind.what} has ended with exit code {answer.exit_code}.", err=True)
541
+ elif answer.status is AnswerStatus.RUNNING:
522
542
  app.echo(f"It still runs; `robotcode session wait {entry.id}` prints the rest.", err=True)
523
543
 
524
544
 
525
- def _exit_code(answer: Dict[str, Any]) -> int:
545
+ def _exit_code(answer: OutputAnswer) -> int:
526
546
  """How the input went; the exit code of the REPL or the run is in the output."""
527
- status = answer.get("status")
528
- if not isinstance(status, AnswerStatus):
529
- return int(DATA_ERROR)
530
- return _EXIT_CODES[status]
547
+ return _EXIT_CODES[answer.status]
@@ -1 +0,0 @@
1
- __version__ = "2.9.0-dev.74"