robotcode-repl 2.9.0.dev68__py3-none-any.whl → 2.9.0.dev79__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.
@@ -0,0 +1,1230 @@
1
+ """Sessions of `robotcode repl` and `robotcode robot-debug` that run in the background.
2
+
3
+ `robotcode session start` runs `repl` or `robot-debug` with `--serve-session` in a
4
+ process of its own. The interpreter of that process reads its inputs from a local
5
+ channel instead of standard input, one whole input per request, and answers each
6
+ request with the output written since the last answer and the status of the input.
7
+
8
+ The process writes its standard output and standard error to a pipe, which the session
9
+ reads into memory. So the output of Robot Framework, of libraries and of child
10
+ processes is part of the answers, and none of it is written to a file.
11
+
12
+ Each open session has a file in a folder of the user, by which the `robotcode session`
13
+ commands find it. A session stays open after its REPL or run has ended, until it is
14
+ stopped or has been idle too long. A restart starts a new process, which takes over the
15
+ ID, the file and the pipe of the session.
16
+ """
17
+
18
+ import codecs
19
+ import contextlib
20
+ import dataclasses
21
+ import json
22
+ import os
23
+ import re
24
+ import select
25
+ import signal
26
+ import socket
27
+ import subprocess
28
+ import sys
29
+ import threading
30
+ import time
31
+ import uuid
32
+ import warnings
33
+ from collections import deque
34
+ from dataclasses import dataclass
35
+ from enum import Enum, auto, unique
36
+ from multiprocessing.connection import Client, Connection, Listener
37
+ from pathlib import Path
38
+ from types import TracebackType
39
+ from typing import Any, Callable, Deque, Dict, Iterator, List, Optional, Tuple, Type, TypeVar, Union
40
+
41
+ import platformdirs
42
+ from robot.errors import ExecutionFailed, ExecutionPassed
43
+ from robot.running import Keyword
44
+ from typing_extensions import assert_never
45
+
46
+ from robotcode.core.utils.dataclasses import from_json
47
+ from robotcode.plugin import Application
48
+ from robotcode.robot.config.utils import get_config_files
49
+
50
+ from ._debug import BreakpointState, DebugController
51
+ from .console_interpreter import ConsoleInterpreter
52
+
53
+ if sys.platform == "win32":
54
+ import _winapi
55
+ import msvcrt
56
+
57
+ # Without `$XDG_RUNTIME_DIR`, as in a container, platformdirs falls back to another folder, which works for the sessions
58
+ # too, and warns about it at every call. Older versions of platformdirs don't warn.
59
+ if hasattr(platformdirs, "RuntimeDirWarning"):
60
+ warnings.filterwarnings("ignore", category=platformdirs.RuntimeDirWarning)
61
+
62
+
63
+ class _Value(str, Enum):
64
+ """A string enum whose members stand for their plain values in the file of a session and in the output."""
65
+
66
+ def __str__(self) -> str:
67
+ return str(self.value)
68
+
69
+
70
+ @unique
71
+ class SessionKind(_Value):
72
+ """What a session runs."""
73
+
74
+ REPL = "repl"
75
+ DEBUG = "debug"
76
+
77
+ @property
78
+ def command(self) -> str:
79
+ """The `robotcode` command that runs a session of this kind."""
80
+ return "repl" if self is SessionKind.REPL else "robot-debug"
81
+
82
+ @property
83
+ def what(self) -> str:
84
+ """`The REPL` or `The run`: what a session of this kind runs, as the messages name it."""
85
+ return "The REPL" if self is SessionKind.REPL else "The run"
86
+
87
+
88
+ @unique
89
+ class SessionState(_Value):
90
+ """Whether a session waits at its prompt, runs something, or its REPL or run has ended."""
91
+
92
+ WAITS = "waits"
93
+ RUNS = "runs"
94
+ ENDED = "ended"
95
+
96
+
97
+ @unique
98
+ class Request(_Value):
99
+ """The requests that the channel of a session takes."""
100
+
101
+ SEND = "send"
102
+ WAIT = "wait"
103
+ # Sent by a command after its `--timeout`: the output so far.
104
+ STATUS = "status"
105
+ INTERRUPT = "interrupt"
106
+ STOP = "stop"
107
+ # Whether the session runs, waits or its REPL or run has ended; takes none of the output.
108
+ STATE = "state"
109
+ RESULT = "result"
110
+ RESTART = "restart"
111
+
112
+
113
+ @unique
114
+ class AnswerStatus(_Value):
115
+ """How the input of a request went."""
116
+
117
+ PASSED = "passed"
118
+ FAILED = "failed"
119
+ RUNNING = "running"
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
+
202
+ _ID_PATTERN = re.compile(rf"(?:{'|'.join(kind.value for kind in SessionKind)})-[0-9a-f]{{8}}")
203
+
204
+ _T = TypeVar("_T")
205
+
206
+ # A Unix socket in the folder of the sessions on Linux and macOS, a named pipe on Windows.
207
+ _FAMILY = "AF_PIPE" if sys.platform == "win32" else "AF_UNIX"
208
+
209
+ # How many connections the channel keeps waiting; `multiprocessing` keeps one. Each command connects twice, to check the
210
+ # channel and for its request, and macOS refuses a connection to a full queue at once, where Linux lets it wait.
211
+ _BACKLOG = 16
212
+
213
+ # How long a new session process may take until it accepts requests.
214
+ STARTUP_LIMIT = 60
215
+
216
+
217
+ class SessionError(Exception):
218
+ """A session can't start, for the reason in the message."""
219
+
220
+
221
+ def sessions_dir() -> Path:
222
+ """The folder of the files of the open sessions."""
223
+ return Path(platformdirs.user_runtime_dir("robotcode", appauthor=False), "sessions")
224
+
225
+
226
+ def create_sessions_dir() -> Path:
227
+ """Create the folder of the session files, open only to the user, and return it. Only a session that starts
228
+ writes to it; the other `session` commands only read it, so that they also work where they can't write, as in a
229
+ sandbox."""
230
+ path = sessions_dir()
231
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
232
+ if sys.platform != "win32":
233
+ # `mkdir` doesn't change the mode of a folder that exists already.
234
+ os.chmod(path, 0o700)
235
+ return path
236
+
237
+
238
+ def project_folder(app: Application) -> Path:
239
+ """The project the current folder belongs to: the folder RobotCode finds for its `robot.toml` from there, or the
240
+ one given with `--root`."""
241
+ _, root, _ = get_config_files(None, app.config.config_files, root_folder=app.config.root, no_vcs=app.config.no_vcs)
242
+ return root or Path.cwd()
243
+
244
+
245
+ def _entry_path(session_id: str) -> Path:
246
+ return sessions_dir() / f"{session_id}.json"
247
+
248
+
249
+ def _address(session_id: str, generation: int = 0) -> str:
250
+ """The address of the channel of a session process. After a restart, each new process has a `generation` of its
251
+ own, and with it an address of its own."""
252
+ name = f"{session_id}-{generation}" if generation else session_id
253
+ if sys.platform == "win32":
254
+ return rf"\\.\pipe\robotcode-{name}"
255
+ return str(sessions_dir() / f"{name}.sock")
256
+
257
+
258
+ def _restart_path(session_id: str) -> Path:
259
+ """The file in which a session process hands its breakpoints, its start time and the next generation to the new
260
+ process of a restart."""
261
+ return sessions_dir() / f"{session_id}.restart"
262
+
263
+
264
+ def new_session_id(kind: SessionKind) -> str:
265
+ """A new ID for a session of `kind`, such as `repl-3f9a2c1e`, that no open session has."""
266
+ while True:
267
+ session_id = f"{kind}-{uuid.uuid4().hex[:8]}"
268
+ if not _entry_path(session_id).exists():
269
+ return session_id
270
+
271
+
272
+ @dataclass
273
+ class SessionEntry:
274
+ """The file of an open session."""
275
+
276
+ id: str
277
+ kind: SessionKind
278
+ pid: int
279
+ address: str
280
+ # The key of the channel, in hexadecimal.
281
+ key: str
282
+ project: str
283
+ started: float
284
+ # In seconds.
285
+ idle_timeout: float
286
+ last_request: float
287
+ state: SessionState = SessionState.RUNS
288
+ # When the session last arrived at its prompt.
289
+ waits_since: Optional[float] = None
290
+
291
+ def time_left(self) -> Optional[float]:
292
+ """The seconds until the session ends for being idle, None while it runs."""
293
+ if self.state is SessionState.RUNS:
294
+ return None
295
+ idle_since = max(self.last_request, self.waits_since or 0)
296
+ return max(0.0, self.idle_timeout - (time.time() - idle_since))
297
+
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
+
310
+ def _retried(action: Callable[[], _T]) -> _T:
311
+ """`action()`, tried again for a moment while it fails with `PermissionError`: Windows doesn't replace a file that
312
+ another process reads at the moment, nor open one that another process replaces."""
313
+ attempt = 0
314
+ while True:
315
+ try:
316
+ return action()
317
+ except PermissionError:
318
+ attempt += 1
319
+ if attempt == 50:
320
+ raise
321
+ time.sleep(0.01)
322
+
323
+
324
+ def _temp_path(path: Path) -> Path:
325
+ return path.with_name(f"{path.name}.tmp")
326
+
327
+
328
+ def _write_json(path: Path, data: Union[SessionEntry, _Handover]) -> None:
329
+ """Write `data` to `path` as a whole, readable only by the user."""
330
+ temp = _temp_path(path)
331
+ fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
332
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
333
+ json.dump(dataclasses.asdict(data), f)
334
+ _retried(lambda: os.replace(temp, path))
335
+
336
+
337
+ def _write_entry(entry: SessionEntry) -> None:
338
+ _write_json(_entry_path(entry.id), entry)
339
+
340
+
341
+ def remove_session_files(session_id: str) -> None:
342
+ """Remove the files of the session `session_id`: its file, the one a session leaves when it ends while it writes
343
+ its file, the file of a restart, and the sockets of all its processes."""
344
+ folder = sessions_dir()
345
+ entry = _entry_path(session_id)
346
+ restart = _restart_path(session_id)
347
+ sockets = [folder / f"{session_id}.sock", *folder.glob(f"{session_id}-*.sock")]
348
+ for path in (entry, _temp_path(entry), restart, _temp_path(restart), *sockets):
349
+ with contextlib.suppress(OSError):
350
+ path.unlink()
351
+
352
+
353
+ def _channel_gone(address: str) -> bool:
354
+ """Whether nothing listens on the channel at `address` any more, because its session process has ended.
355
+
356
+ The process ID can't tell in a sandbox with processes of its own: a command inside doesn't see the processes
357
+ outside, and the ID of a session that ended inside can belong to another process outside. A channel that can't be
358
+ reached for another reason, such as a sandbox that forbids the connection, counts as open.
359
+ """
360
+ try:
361
+ if sys.platform == "win32":
362
+ # Waits for an instance of the pipe without connecting to it, and fails at once without the pipe.
363
+ _winapi.WaitNamedPipe(address, 1)
364
+ else:
365
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
366
+ probe.settimeout(1)
367
+ probe.connect(address)
368
+ except (FileNotFoundError, ConnectionRefusedError):
369
+ return True
370
+ except OSError:
371
+ return False
372
+ return False
373
+
374
+
375
+ def _load_entry(session_id: str) -> Optional[SessionEntry]:
376
+ path = _entry_path(session_id)
377
+ try:
378
+ return from_json(_retried(lambda: path.read_text(encoding="utf-8")), SessionEntry)
379
+ except (OSError, ValueError, TypeError):
380
+ return None
381
+
382
+
383
+ def read_entry(session_id: str) -> Optional[SessionEntry]:
384
+ """The file of the open session `session_id`, None without one. The files of a session whose channel nobody
385
+ listens on any more, because its process no longer runs, are removed."""
386
+ if not _ID_PATTERN.fullmatch(session_id):
387
+ return None
388
+ entry = _load_entry(session_id)
389
+ while entry is not None and _channel_gone(entry.address):
390
+ # At a restart, the new process names its channel in the file before the old process closes its own.
391
+ again = _load_entry(session_id)
392
+ if again is None or again.address == entry.address:
393
+ remove_session_files(session_id)
394
+ return None
395
+ entry = again
396
+ return entry
397
+
398
+
399
+ def open_sessions() -> List[SessionEntry]:
400
+ """The open sessions of the user, the oldest first. The files of sessions that no longer run are removed."""
401
+ entries = [entry for path in sessions_dir().glob("*.json") if (entry := read_entry(path.stem)) is not None]
402
+ return sorted(entries, key=lambda entry: entry.started)
403
+
404
+
405
+ def connect(entry: SessionEntry) -> Connection:
406
+ """A connection to the channel of the session `entry`."""
407
+ return Client(entry.address, family=_FAMILY, authkey=bytes.fromhex(entry.key))
408
+
409
+
410
+ # By default a pipe on Windows holds only a few kilobytes; this holds what a session writes before it starts reading the
411
+ # pipe.
412
+ _PIPE_SIZE = 1 << 20
413
+
414
+
415
+ def create_output_pipe() -> Tuple[int, int]:
416
+ """The read end and the write end of a new pipe for the output of a session."""
417
+ if sys.platform == "win32":
418
+ read_handle, write_handle = _winapi.CreatePipe(None, _PIPE_SIZE)
419
+ return msvcrt.open_osfhandle(read_handle, 0), msvcrt.open_osfhandle(write_handle, 0)
420
+ return os.pipe()
421
+
422
+
423
+ def read_to_end(fd: int) -> str:
424
+ """What the pipe `fd` holds up to its end, once nothing writes to it any more."""
425
+ chunks = []
426
+ while data := os.read(fd, 65536):
427
+ chunks.append(data)
428
+ return b"".join(chunks).decode("utf-8", errors="replace").replace("\r\n", "\n")
429
+
430
+
431
+ def start_session_process(
432
+ command: List[str], output: int, stdout: int, env: Dict[str, str], cwd: Optional[str] = None
433
+ ) -> "subprocess.Popen[bytes]":
434
+ """Start a session process, detached from this one. The process writes its standard output and standard error to
435
+ `stdout` and reads them back from `output`, the read end of the same pipe, which it inherits: a handle on
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."""
442
+ if sys.platform == "win32":
443
+ os.set_handle_inheritable(output, True)
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)
457
+ else:
458
+ process = subprocess.Popen(
459
+ command,
460
+ stdin=subprocess.PIPE,
461
+ stdout=stdout,
462
+ stderr=subprocess.STDOUT,
463
+ env=env,
464
+ cwd=cwd,
465
+ start_new_session=True,
466
+ pass_fds=(output,),
467
+ )
468
+ # The session reads its inputs from its channel. A closed pipe as standard input makes it a session without a
469
+ # terminal on every platform, unlike the null device, which Windows takes for a terminal.
470
+ if process.stdin is not None:
471
+ process.stdin.close()
472
+ return process
473
+
474
+
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
477
+ the first process of a session."""
478
+ path = _restart_path(session_id)
479
+ try:
480
+ handover = from_json(_retried(lambda: path.read_text(encoding="utf-8")), _Handover)
481
+ except (OSError, ValueError, TypeError):
482
+ return None
483
+ with contextlib.suppress(OSError):
484
+ path.unlink()
485
+ return handover
486
+
487
+
488
+ def _own_pipe(output: int) -> int:
489
+ """The file descriptor of the read end `output` that `session start` passed down, a handle on Windows, which the
490
+ processes that the session starts don't inherit."""
491
+ if sys.platform == "win32":
492
+ os.set_handle_inheritable(output, False)
493
+ return msvcrt.open_osfhandle(output, 0)
494
+ os.set_inheritable(output, False)
495
+ os.set_blocking(output, False)
496
+ return output
497
+
498
+
499
+ def _flush_output() -> None:
500
+ for stream in {id(s): s for s in (sys.stdout, sys.stderr, sys.__stdout__, sys.__stderr__) if s}.values():
501
+ with contextlib.suppress(Exception):
502
+ stream.flush()
503
+
504
+
505
+ def _passed_or_failed(failed: bool) -> AnswerStatus:
506
+ return AnswerStatus.FAILED if failed else AnswerStatus.PASSED
507
+
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
+
518
+ @dataclass
519
+ class _QueuedInput:
520
+ """The input of a `send` request, and its answer once it has run."""
521
+
522
+ text: str
523
+ started: bool = False
524
+ done: bool = False
525
+ output_end: int = 0
526
+ failed: bool = False
527
+
528
+
529
+ class SessionServer:
530
+ """The channel of a session, in the session process.
531
+
532
+ The interpreter takes its inputs from `next_input`, in the main thread. Each request is answered on a thread of its
533
+ own, so that a request to interrupt or stop the session, or one for the output so far, is answered while a statement
534
+ runs; the inputs of the `send` requests run one after the other. When the REPL or the run has ended, `end` keeps the
535
+ session open until it is stopped or has been idle too long, or starts it again in a new process for a restart.
536
+ """
537
+
538
+ def __init__(self, session_id: str, kind: SessionKind, project: Path, idle_timeout: float, output: int) -> None:
539
+ self.session_id = session_id
540
+ self._kind = kind
541
+ self._condition = threading.Condition()
542
+ self._queue: Deque[_QueuedInput] = deque()
543
+ # The input that runs.
544
+ self._current: Optional[_QueuedInput] = None
545
+ # The prompt the interpreter waits at, None while something runs.
546
+ self._prompt: Optional[_Prompt] = None
547
+ # Updated each time the interpreter arrives at its prompt: how often it has arrived, whether the input failed,
548
+ # and how much output there was then.
549
+ self._arrivals = 0
550
+ self._arrival_failed = False
551
+ self._arrival_end = 0
552
+ # Whether the input that runs has failed; reset when the interpreter arrives at its prompt.
553
+ self._failed = False
554
+ # Once the REPL or the run has ended: its exit code, and whether the input that ran at the end failed.
555
+ self._run_ended = False
556
+ self._exit_code = 0
557
+ self._end_failed = False
558
+ # The REPL or the run ended because the session was idle too long, which ends the session too.
559
+ self._idle_ended = False
560
+ self._stop_requested = False
561
+ self._restart_requested = False
562
+ self._interrupted = False
563
+ # The interpreter, which tells whether a signal has ended the REPL or the run.
564
+ self._interpreter: Optional["SessionInterpreter"] = None
565
+ # The answer to `restart`, once the new process has taken over or failed to.
566
+ self._restart_answer: Optional[Union[RestartedAnswer, RestartFailedAnswer]] = None
567
+ # Set while a restart starts the new process, which writes the session file from then on; `_handed_over` once
568
+ # the new process has taken over and answers the requests.
569
+ self._handing_over = False
570
+ self._handed_over = False
571
+ # Set when the channel starts to close, so that the requests that wait send their last answers, and once it has
572
+ # closed.
573
+ self._closing = False
574
+ self._closed = False
575
+ # Requests that are being answered.
576
+ self._serving = 0
577
+ # The debugger, whose breakpoints a restart hands on.
578
+ self._controller: Optional[DebugController] = None
579
+ # A restart starts the new process the way this one was started, with the environment and the current folder
580
+ # from before the run could change them. With `sys.executable`, not `sys.orig_argv[0]`: on Windows, the
581
+ # `python.exe` of a virtual environment runs the base interpreter, which `sys.orig_argv[0]` names and which
582
+ # doesn't find the packages of the environment.
583
+ self._command = [sys.executable, *sys.orig_argv[1:]]
584
+ self._environment = dict(os.environ)
585
+ self._folder = os.getcwd()
586
+ handover = _take_handover(session_id)
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
589
+ # The pipe the process writes its standard output and standard error to. What the session has read from it
590
+ # stays in memory until an answer carries it.
591
+ self._pipe = _own_pipe(output)
592
+ self._output = bytearray()
593
+ self._output_lock = threading.Lock()
594
+ # Whether this process reads the pipe; at a restart, the new process takes the reading over.
595
+ self._reading = True
596
+ # How much the session has read from the pipe, and how much of it the answers have carried.
597
+ self._read_size = 0
598
+ self._position = 0
599
+ # Whether what the process has written so far ends with a line break, or there is nothing yet.
600
+ self._at_line_start = True
601
+ self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
602
+ key = os.urandom(32)
603
+ create_sessions_dir()
604
+ address = _address(session_id, self._generation)
605
+ try:
606
+ self._listener = Listener(address, family=_FAMILY, backlog=_BACKLOG, authkey=key)
607
+ except OSError as e:
608
+ raise SessionError(f"Can't open the channel of the session at {address}: {e.strerror or e}.") from e
609
+ now = time.time()
610
+ self._entry = SessionEntry(
611
+ id=session_id,
612
+ kind=kind,
613
+ pid=os.getpid(),
614
+ address=str(self._listener.address),
615
+ key=key.hex(),
616
+ project=str(project),
617
+ started=handover.started if handover is not None else now,
618
+ idle_timeout=idle_timeout,
619
+ last_request=now,
620
+ )
621
+ _write_entry(self._entry)
622
+ threading.Thread(target=self._accept, name="robotcode-session", daemon=True).start()
623
+ # Only once the file exists: until then, `session start` reads the pipe itself if the process ends before it is
624
+ # ready.
625
+ self._reader = self._start_reading()
626
+
627
+ def __enter__(self) -> "SessionServer":
628
+ return self
629
+
630
+ def __exit__(
631
+ self, exc_type: Optional[Type[BaseException]], exc: Optional[BaseException], tb: Optional[TracebackType]
632
+ ) -> None:
633
+ if isinstance(exc, SystemExit):
634
+ code = exc.code if isinstance(exc.code, int) else 0 if exc.code is None else 1
635
+ else:
636
+ code = 0 if exc is None else 1
637
+ self.end(code)
638
+
639
+ def watch(self, interpreter: "SessionInterpreter") -> None:
640
+ """Remember `interpreter`, which tells at the end whether a signal, such as SIGTERM at a shutdown, has ended
641
+ its REPL or run."""
642
+ self._interpreter = interpreter
643
+
644
+ def keep_breakpoints(self, controller: DebugController) -> None:
645
+ """Remember `controller`, whose breakpoints a restart hands to the new process, and give it the breakpoints
646
+ of the process before when a restart has started this one."""
647
+ self._controller = controller
648
+ if self._handed_breakpoints is not None:
649
+ controller.restore_breakpoints(self._handed_breakpoints)
650
+
651
+ # --- the interpreter, in the main thread ---------------------------------
652
+
653
+ def next_input(self, prompt: str) -> str:
654
+ """Wait at `prompt` for the next request and return its whole input; the request that ran before gets its
655
+ answer first. While it waits, end the REPL or the run when the session has been idle too long or is asked to
656
+ stop or restart."""
657
+ with self._condition:
658
+ self._arrive(_Prompt.STOP if prompt.strip() == ConsoleInterpreter.DEBUG_PROMPT.strip() else _Prompt.INPUT)
659
+ while True:
660
+ if self._queue:
661
+ self._current = queued = self._queue.popleft()
662
+ queued.started = True
663
+ self._prompt = None
664
+ self._update_entry(state=SessionState.RUNS)
665
+ return queued.text
666
+ if self._waits_at_stop():
667
+ if self._stop_requested or self._restart_requested:
668
+ # A debug session ends its run as `.abort` does.
669
+ return ".abort"
670
+ if self._idle():
671
+ self._idle_ended = True
672
+ return ".abort"
673
+ elif self._kind is SessionKind.REPL and self._idle():
674
+ # Ends the REPL as the end of its input does.
675
+ self._idle_ended = True
676
+ raise EOFError
677
+ # In short steps, so that a signal handler gets to run soon: on Windows, a signal doesn't interrupt the
678
+ # wait.
679
+ self._condition.wait(0.1)
680
+
681
+ def line_failed(self) -> None:
682
+ """The input that runs has failed."""
683
+ self._failed = True
684
+
685
+ def end(self, exit_code: int) -> None:
686
+ """The REPL or the run has ended with `exit_code`. The session stays open until it is stopped or idle too
687
+ long, or until a restart has started it again in a new process."""
688
+ _flush_output()
689
+ with self._condition:
690
+ if self._run_ended:
691
+ return
692
+ self._end_failed, self._failed = self._failed, False
693
+ self._run_ended = True
694
+ self._exit_code = exit_code
695
+ self._prompt = None
696
+ self._update_entry(state=SessionState.ENDED, waits_since=time.time())
697
+ self._condition.notify_all()
698
+ # A signal from outside, such as SIGTERM at a shutdown, ends the session with its REPL or run.
699
+ ended_from_outside = (
700
+ self._interpreter is not None
701
+ and self._interpreter.termination_requested
702
+ and not (self._stop_requested or self._restart_requested or self._interrupted)
703
+ )
704
+ if not (self._idle_ended or ended_from_outside):
705
+ while self._wait_after_end():
706
+ if self._start_again():
707
+ return
708
+ with self._condition:
709
+ # Before the last answers go out, so that a `session` command right after one doesn't find the session.
710
+ _entry_path(self.session_id).unlink(missing_ok=True)
711
+ self._close_channel()
712
+
713
+ def _wait_after_end(self) -> bool:
714
+ """Wait after the end of the REPL or the run until the session is asked to stop or restart, or has been idle
715
+ too long; True for a restart."""
716
+ with self._condition:
717
+ while not (self._stop_requested or self._restart_requested or self._idle()):
718
+ self._condition.wait(0.1)
719
+ return self._restart_requested and not self._stop_requested
720
+
721
+ def _close_channel(self) -> None:
722
+ """Give the requests that wait a moment for their last answers, then close the channel. The caller holds
723
+ `_condition`."""
724
+ self._closing = True
725
+ self._condition.notify_all()
726
+ deadline = time.monotonic() + 5
727
+ while self._serving and time.monotonic() < deadline:
728
+ self._condition.wait(0.1)
729
+ self._closed = True
730
+ with contextlib.suppress(OSError):
731
+ self._listener.close()
732
+
733
+ def _start_again(self) -> bool:
734
+ """Start the session again in a new process, which takes over the session file and the pipe and opens a
735
+ channel of its own; True once it has taken over. When the new process doesn't get ready, the session stays as
736
+ it is."""
737
+ with self._condition:
738
+ self._handing_over = True
739
+ breakpoints = self._controller.breakpoint_state() if self._controller is not None else None
740
+ handover = _Handover(self._generation + 1, self._entry.started, breakpoints)
741
+ # Only one process may read the pipe; from now on it is the new one.
742
+ self._stop_reading()
743
+ process: "Optional[subprocess.Popen[bytes]]" = None
744
+ try:
745
+ _write_json(_restart_path(self.session_id), handover)
746
+ process = start_session_process(self._command, self._pipe_handle(), 1, self._environment, self._folder)
747
+ self._wait_for_new_process(process)
748
+ except SessionError as e:
749
+ failure = str(e)
750
+ except OSError as e:
751
+ failure = f"The session couldn't start again: {e.strerror or e}."
752
+ else:
753
+ self._hand_over()
754
+ return True
755
+ self._keep_session(process, failure)
756
+ return False
757
+
758
+ def _wait_for_new_process(self, process: "subprocess.Popen[bytes]") -> None:
759
+ """Wait until `process` has written the session file, which it does once it accepts requests; `SessionError`
760
+ with the reason when it doesn't get ready."""
761
+ # Known by its channel: on Windows, the `python.exe` of a virtual environment runs the interpreter in a process
762
+ # of its own, whose ID isn't that of `process`.
763
+ address = _address(self.session_id, self._generation + 1)
764
+ ready_by = time.monotonic() + STARTUP_LIMIT
765
+ while True:
766
+ entry = _load_entry(self.session_id)
767
+ if entry is not None and entry.address == address:
768
+ return
769
+ if process.poll() is not None:
770
+ raise SessionError(
771
+ f"The session couldn't start again; its new process ended with exit code {process.returncode}."
772
+ )
773
+ if time.monotonic() >= ready_by:
774
+ process.kill()
775
+ process.wait()
776
+ raise SessionError(
777
+ f"The session couldn't start again; its new process wasn't ready after {STARTUP_LIMIT} seconds "
778
+ "and was ended."
779
+ )
780
+ time.sleep(0.05)
781
+
782
+ def _hand_over(self) -> None:
783
+ """Answer `restart` with the output since the last command, and close the channel, now that the new process
784
+ has taken over the session file and the pipe."""
785
+ with self._output_lock:
786
+ size = self._read_size
787
+ with self._condition:
788
+ self._restart_answer = RestartedAnswer(self._read_output(size))
789
+ self._handed_over = True
790
+ self._close_channel()
791
+ # Anything this process still writes would end up in the output of the new process.
792
+ devnull = os.open(os.devnull, os.O_WRONLY)
793
+ os.dup2(devnull, 1)
794
+ os.dup2(devnull, 2)
795
+ os.close(devnull)
796
+
797
+ def _keep_session(self, process: "Optional[subprocess.Popen[bytes]]", failure: str) -> None:
798
+ """After a restart that failed: end the new process, take what it wrote, and keep the session with its REPL or
799
+ run ended."""
800
+ if process is not None and process.poll() is None:
801
+ process.kill()
802
+ process.wait()
803
+ with contextlib.suppress(OSError):
804
+ _restart_path(self.session_id).unlink()
805
+ with self._output_lock:
806
+ # What the new process wrote, so that the answer carries it, before the reading goes on.
807
+ self._take_output()
808
+ self._reader = self._start_reading()
809
+ with self._condition:
810
+ self._handing_over = False
811
+ # The new process may have written the file before it ended.
812
+ self._update_entry()
813
+ self._restart_answer = RestartFailedAnswer(self._read_output(self._output_size()), failure)
814
+ self._restart_requested = False
815
+ self._condition.notify_all()
816
+
817
+ def _arrive(self, prompt: _Prompt) -> None:
818
+ """The interpreter has arrived at `prompt`: give the request that ran its answer, and wake the threads that
819
+ wait for it."""
820
+ _flush_output()
821
+ end = self._output_size()
822
+ # No I/O up to the notification: a Ctrl-C at the prompt can interrupt this method, and the request must then
823
+ # either have its answer or be left to the next arrival.
824
+ failed, self._failed = self._failed, False
825
+ queued, self._current = self._current, None
826
+ if queued is not None:
827
+ queued.output_end = end
828
+ queued.failed = failed
829
+ queued.done = True
830
+ self._arrivals += 1
831
+ self._arrival_failed = failed
832
+ self._arrival_end = end
833
+ self._prompt = prompt
834
+ # A pending `interrupt` has done its work; a signal that ends the REPL or the run from now on doesn't come from
835
+ # it.
836
+ self._interrupted = False
837
+ self._condition.notify_all()
838
+ self._update_entry(state=SessionState.WAITS, waits_since=time.time())
839
+
840
+ def _idle(self) -> bool:
841
+ idle_since = max(self._entry.last_request, self._entry.waits_since or 0)
842
+ return time.time() - idle_since >= self._entry.idle_timeout
843
+
844
+ def _waits_at_stop(self) -> bool:
845
+ """Whether the run of a debug session waits at a stop of the debugger."""
846
+ return self._kind is SessionKind.DEBUG and self._prompt is _Prompt.STOP
847
+
848
+ # --- the requests, each on a thread of its own ---------------------------
849
+
850
+ def _accept(self) -> None:
851
+ while not self._closed:
852
+ try:
853
+ connection = self._listener.accept()
854
+ except Exception:
855
+ # A client without the key, or the channel has closed at the end.
856
+ time.sleep(0.01)
857
+ continue
858
+ threading.Thread(target=self._serve, args=(connection,), daemon=True).start()
859
+
860
+ def _serve(self, connection: Connection) -> None:
861
+ with self._condition:
862
+ self._serving += 1
863
+ try:
864
+ with connection:
865
+ connection.send(self._answer(connection, connection.recv()))
866
+ except (EOFError, OSError):
867
+ # The client has gone.
868
+ pass
869
+ finally:
870
+ with self._condition:
871
+ self._serving -= 1
872
+ self._condition.notify_all()
873
+
874
+ def _answer(self, connection: Connection, request: SessionRequest) -> ChannelAnswer:
875
+ kind = request.kind
876
+ with self._condition:
877
+ if self._handed_over:
878
+ # The command then asks the new process, which the session file names now.
879
+ return MovedAnswer()
880
+ if self._closing:
881
+ # The session has just ended, as the next command would find.
882
+ return RefusedAnswer(f"No session '{self.session_id}' is open.")
883
+ self._update_entry(last_request=time.time())
884
+ run_ended = self._run_ended
885
+ if kind is Request.SEND:
886
+ if run_ended:
887
+ return self._ended_error()
888
+ return self._send(connection, request.input)
889
+ if kind is Request.WAIT:
890
+ return self._wait(connection)
891
+ if kind is Request.STATUS:
892
+ with self._condition:
893
+ return self._status()
894
+ if kind is Request.STATE:
895
+ with self._condition:
896
+ return StateAnswer(self._state())
897
+ if kind is Request.RESULT:
898
+ if not run_ended:
899
+ return RefusedAnswer(f"{self._kind.what} of session '{self.session_id}' hasn't ended yet.")
900
+ return ResultAnswer(self._exit_code)
901
+ if kind is Request.INTERRUPT:
902
+ if run_ended:
903
+ return self._ended_error()
904
+ with self._condition:
905
+ self._interrupted = True
906
+ # As Ctrl-C does at the terminal of the session.
907
+ self._signal(signal.SIGINT)
908
+ return InterruptedAnswer()
909
+ if kind is Request.STOP:
910
+ return self._stop(connection)
911
+ if kind is Request.RESTART:
912
+ return self._restart(connection)
913
+ assert_never(kind)
914
+
915
+ def _ended_error(self) -> RefusedAnswer:
916
+ session_id = self.session_id
917
+ return RefusedAnswer(
918
+ f"{self._kind.what} of session '{session_id}' has ended; `robotcode session restart {session_id}` starts "
919
+ f"it again, and `robotcode session result {session_id}` prints its exit code."
920
+ )
921
+
922
+ def _send(self, connection: Connection, text: str) -> ChannelAnswer:
923
+ lines = text.strip().splitlines()
924
+ if len(lines) > 1 and lines[0].lstrip().startswith("."):
925
+ # A dot-command is an input of its own.
926
+ return OutputAnswer("[ ERROR ] Dot-commands can only be sent on their own.\n", AnswerStatus.FAILED)
927
+ queued = _QueuedInput(text)
928
+ with self._condition:
929
+ self._queue.append(queued)
930
+ self._condition.notify_all()
931
+ return self._await(
932
+ connection,
933
+ lambda: self._make_answer(queued.output_end, _passed_or_failed(queued.failed)) if queued.done else None,
934
+ # An input that was still queued when the REPL or the run ended never ran.
935
+ lambda: self._answer_ended() if queued.started else self._ended_error(),
936
+ )
937
+
938
+ def _wait(self, connection: Connection) -> ChannelAnswer:
939
+ with self._condition:
940
+ if self._run_ended:
941
+ return self._answer_ended()
942
+ if not self._runs():
943
+ # The session waits at its prompt already: what was written since the last answer.
944
+ return self._status()
945
+ arrivals = self._arrivals
946
+ return self._await(
947
+ connection,
948
+ lambda: (
949
+ self._make_answer(self._arrival_end, _passed_or_failed(self._arrival_failed))
950
+ if self._arrivals > arrivals
951
+ else None
952
+ ),
953
+ self._answer_ended,
954
+ )
955
+
956
+ def _stop(self, connection: Connection) -> ChannelAnswer:
957
+ with self._condition:
958
+ self._stop_requested = True
959
+ self._condition.notify_all()
960
+ self._end_run()
961
+ return self._await(connection, lambda: self._stopped_answer() if self._closing else None)
962
+
963
+ def _stopped_answer(self) -> Union[MovedAnswer, StoppedAnswer]:
964
+ if self._handed_over:
965
+ # A restart has taken over meanwhile: the command stops the new process.
966
+ return MovedAnswer()
967
+ return StoppedAnswer(self._read_output(self._output_size()))
968
+
969
+ def _restart(self, connection: Connection) -> ChannelAnswer:
970
+ with self._condition:
971
+ if self._restart_requested:
972
+ return RefusedAnswer(f"Session '{self.session_id}' restarts already.")
973
+ self._restart_requested = True
974
+ self._restart_answer = None
975
+ self._condition.notify_all()
976
+ self._end_run()
977
+ return self._await(connection, lambda: self._restart_answer)
978
+
979
+ def _end_run(self) -> None:
980
+ """End the REPL or the run, unless it has ended, for a `stop` or a `restart` that the caller has noted."""
981
+ with self._condition:
982
+ if self._run_ended:
983
+ return
984
+ waits_at_stop = self._waits_at_stop()
985
+ if self._kind is SessionKind.REPL:
986
+ # As SIGTERM does.
987
+ self._signal(signal.SIGTERM)
988
+ elif not waits_at_stop:
989
+ # The run pauses at the next keyword, where `next_input` aborts it; without the debugger, it ends as on
990
+ # Ctrl-C.
991
+ self._signal(signal.SIGINT)
992
+
993
+ def _await(
994
+ self,
995
+ connection: Connection,
996
+ ready: Callable[[], Optional[ChannelAnswer]],
997
+ at_end: Optional[Callable[[], ChannelAnswer]] = None,
998
+ ) -> ChannelAnswer:
999
+ """Wait until `ready()` has the answer and return it, or return `at_end()` once the REPL or the run has ended.
1000
+ Meanwhile the client can send a `status` request after its timeout: it then gets the output so far, and a
1001
+ later `wait` gets the answer."""
1002
+ while True:
1003
+ with self._condition:
1004
+ answer = ready()
1005
+ if answer is not None:
1006
+ return answer
1007
+ if at_end is not None and self._run_ended:
1008
+ return at_end()
1009
+ self._condition.wait(0.1)
1010
+ if connection.poll():
1011
+ if connection.recv().kind is Request.STATUS:
1012
+ with self._condition:
1013
+ return self._status()
1014
+
1015
+ def _runs(self) -> bool:
1016
+ return self._prompt is None or self._current is not None or bool(self._queue)
1017
+
1018
+ def _state(self) -> SessionState:
1019
+ if self._run_ended:
1020
+ return SessionState.ENDED
1021
+ return SessionState.RUNS if self._runs() else SessionState.WAITS
1022
+
1023
+ def _status(self) -> OutputAnswer:
1024
+ if self._run_ended:
1025
+ return self._answer_ended()
1026
+ if self._runs():
1027
+ return self._make_answer(self._output_size(), AnswerStatus.RUNNING)
1028
+ return self._make_answer(self._output_size(), _passed_or_failed(self._arrival_failed))
1029
+
1030
+ def _answer_ended(self) -> OutputAnswer:
1031
+ """The output since the last answer, with the status of the input that ran at the end and the exit code of the
1032
+ REPL or the run."""
1033
+ return self._make_answer(self._output_size(), _passed_or_failed(self._end_failed), self._exit_code)
1034
+
1035
+ def _make_answer(self, end: int, status: AnswerStatus, exit_code: Optional[int] = None) -> OutputAnswer:
1036
+ return OutputAnswer(self._read_output(end), status, exit_code)
1037
+
1038
+ def _read_output(self, end: int) -> str:
1039
+ """Take the output from where the last answer ended up to `end` out of memory, and return it."""
1040
+ with self._output_lock:
1041
+ if end <= self._position:
1042
+ return ""
1043
+ size = end - self._position
1044
+ data = bytes(self._output[:size])
1045
+ del self._output[:size]
1046
+ self._position = end
1047
+ return self._decoder.decode(data).replace("\r\n", "\n")
1048
+
1049
+ def _output_size(self) -> int:
1050
+ """How much the process has written so far, with what is still in the pipe; once the new process of a restart
1051
+ reads the pipe, only what this process has read."""
1052
+ with self._output_lock:
1053
+ if self._reading:
1054
+ self._take_output()
1055
+ return self._read_size
1056
+
1057
+ def at_line_start(self) -> bool:
1058
+ """Whether what the process has written up to now ends with a line break, or there is nothing yet."""
1059
+ _flush_output()
1060
+ with self._output_lock:
1061
+ if self._reading:
1062
+ self._take_output()
1063
+ return self._at_line_start
1064
+
1065
+ def _start_reading(self) -> threading.Thread:
1066
+ self._reading = True
1067
+ reader = threading.Thread(target=self._read_pipe, name="robotcode-session-output", daemon=True)
1068
+ reader.start()
1069
+ return reader
1070
+
1071
+ def _stop_reading(self) -> None:
1072
+ """Stop reading the pipe, so that the new process of a restart can read it; what the pipe holds now still
1073
+ belongs to this process and is read first."""
1074
+ with self._output_lock:
1075
+ self._take_output()
1076
+ self._reading = False
1077
+ self._reader.join()
1078
+
1079
+ def _pipe_handle(self) -> int:
1080
+ """The pipe of the output as `session start` passed it down, a handle on Windows."""
1081
+ if sys.platform == "win32":
1082
+ return msvcrt.get_osfhandle(self._pipe)
1083
+ return self._pipe
1084
+
1085
+ def _read_pipe(self) -> None:
1086
+ """Read the pipe into memory while this process reads it, so that it doesn't fill up and hold up the processes
1087
+ that write to it."""
1088
+ delay = 0.001
1089
+ while True:
1090
+ if sys.platform == "win32":
1091
+ # Windows can't wait until a pipe has something to read.
1092
+ time.sleep(delay)
1093
+ else:
1094
+ # With a timeout, so that the thread soon notices when a restart stops the reading.
1095
+ select.select([self._pipe], [], [], 0.1)
1096
+ with self._output_lock:
1097
+ if not self._reading:
1098
+ return
1099
+ size = self._read_size
1100
+ if not self._take_output():
1101
+ return
1102
+ delay = 0.001 if self._read_size > size else min(delay * 2, 0.05)
1103
+
1104
+ def _take_output(self) -> bool:
1105
+ """Move what the pipe holds now into memory, without waiting for more; False at the end of the pipe. The
1106
+ caller holds `_output_lock`."""
1107
+ while True:
1108
+ try:
1109
+ if sys.platform == "win32":
1110
+ size = _winapi.PeekNamedPipe(msvcrt.get_osfhandle(self._pipe), 0)[0]
1111
+ if not size:
1112
+ return True
1113
+ data = os.read(self._pipe, size)
1114
+ else:
1115
+ data = os.read(self._pipe, 65536)
1116
+ except BlockingIOError:
1117
+ return True
1118
+ except OSError:
1119
+ # The end of the pipe on Windows.
1120
+ return False
1121
+ if not data:
1122
+ return False
1123
+ self._output += data
1124
+ self._read_size += len(data)
1125
+ self._at_line_start = data.endswith(b"\n")
1126
+
1127
+ def _update_entry(
1128
+ self,
1129
+ *,
1130
+ state: Optional[SessionState] = None,
1131
+ waits_since: Optional[float] = None,
1132
+ last_request: Optional[float] = None,
1133
+ ) -> None:
1134
+ """Write the session file, with the values given changed."""
1135
+ if self._closing or self._handing_over:
1136
+ return
1137
+ if state is not None:
1138
+ self._entry.state = state
1139
+ if waits_since is not None:
1140
+ self._entry.waits_since = waits_since
1141
+ if last_request is not None:
1142
+ self._entry.last_request = last_request
1143
+ with contextlib.suppress(OSError):
1144
+ _write_entry(self._entry)
1145
+
1146
+ def _signal(self, signum: int) -> None:
1147
+ """Let the interpreter, in the main thread, handle `signum` as when the signal arrives."""
1148
+ if sys.platform == "win32":
1149
+ signal.raise_signal(signum)
1150
+ else:
1151
+ main = threading.main_thread().ident
1152
+ if main is not None:
1153
+ signal.pthread_kill(main, signum)
1154
+
1155
+
1156
+ class SessionInterpreter(ConsoleInterpreter):
1157
+ """The console interpreter of a session: it takes its inputs from the channel of the session, each as a whole,
1158
+ instead of line by line from a terminal."""
1159
+
1160
+ def __init__(
1161
+ self, server: SessionServer, app: Application, files: List[Path], show_keywords: bool, inspect: bool
1162
+ ) -> None:
1163
+ super().__init__(app=app, files=files, show_keywords=show_keywords, inspect=inspect)
1164
+ self._server = server
1165
+ server.watch(self)
1166
+
1167
+ @property
1168
+ def termination_requested(self) -> bool:
1169
+ """Whether a signal has asked the REPL or the run to end, as SIGTERM does."""
1170
+ return self._termination_requested
1171
+
1172
+ def read_line(
1173
+ self, prompt: str, *, multiline_continuation: bool = False, prefill: str = "", completer: Any = None
1174
+ ) -> str:
1175
+ del multiline_continuation, prefill, completer # no editor in a session
1176
+ return self._server.next_input(prompt)
1177
+
1178
+ def get_input(self) -> Iterator[Optional[Keyword]]:
1179
+ # Script files given to `session start repl` run first, as in `robotcode repl`.
1180
+ if self.files or (self.executed_files and not self.inspect):
1181
+ yield from super().get_input()
1182
+ return
1183
+ self._input_from_file = False
1184
+ try:
1185
+ text = self._read(self.PROMPT)
1186
+ except KeyboardInterrupt:
1187
+ # An `interrupt` while the session waits for an input, as Ctrl-C at an empty prompt.
1188
+ self._report_interrupt(self.PROMPT)
1189
+ return
1190
+ if text.lstrip().startswith(".") and self._dispatch_dot_command(text):
1191
+ return
1192
+ # The input runs as a whole, as a script file does; a block it leaves unfinished fails with what Robot
1193
+ # Framework says it lacks.
1194
+ parsed = self.parse_input(text)
1195
+ if parsed.test.body and not parsed.errors:
1196
+ self._session_lines.append(text)
1197
+ yield from self._body_to_run(parsed)
1198
+
1199
+ def _report_interrupt(self, prompt: str) -> None:
1200
+ # A session doesn't write its prompt, so there is no line to end.
1201
+ del prompt
1202
+ self._echo("KeyboardInterrupt")
1203
+
1204
+ def _record_failure(self, error: ExecutionFailed) -> None:
1205
+ self._server.line_failed()
1206
+ super()._record_failure(error)
1207
+
1208
+ def _end_console_line(self) -> None:
1209
+ # The session knows its output: if it ends with a line break already, another one would leave an empty line.
1210
+ if not self._server.at_line_start():
1211
+ super()._end_console_line()
1212
+
1213
+ def _evaluate_at_stop(self, line: str) -> bool:
1214
+ # A keyword at a stop fails the input as at the REPL prompt.
1215
+ try:
1216
+ ran = super()._evaluate_at_stop(line)
1217
+ except ExecutionPassed as e:
1218
+ if e.earlier_failures:
1219
+ self._server.line_failed()
1220
+ raise
1221
+ except ExecutionFailed as e:
1222
+ if not e.skip:
1223
+ self._server.line_failed()
1224
+ raise
1225
+ except Exception:
1226
+ self._server.line_failed()
1227
+ raise
1228
+ if not ran:
1229
+ self._server.line_failed()
1230
+ return ran