robotcode-repl 2.9.0.dev74__py3-none-any.whl → 2.9.0.dev87__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.
- robotcode/repl/__version__.py +1 -1
- robotcode/repl/_debug/__init__.py +2 -0
- robotcode/repl/_debug/controller.py +8 -12
- robotcode/repl/_debug/types.py +9 -0
- robotcode/repl/_session.py +263 -141
- robotcode/repl/session_cli.py +78 -61
- {robotcode_repl-2.9.0.dev74.dist-info → robotcode_repl-2.9.0.dev87.dist-info}/METADATA +2 -2
- {robotcode_repl-2.9.0.dev74.dist-info → robotcode_repl-2.9.0.dev87.dist-info}/RECORD +10 -10
- {robotcode_repl-2.9.0.dev74.dist-info → robotcode_repl-2.9.0.dev87.dist-info}/WHEEL +0 -0
- {robotcode_repl-2.9.0.dev74.dist-info → robotcode_repl-2.9.0.dev87.dist-info}/entry_points.txt +0 -0
robotcode/repl/__version__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "2.9.0-dev.
|
|
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) ->
|
|
226
|
-
"""The breakpoints and the exception breakpoints as they are,
|
|
227
|
-
|
|
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:
|
|
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(
|
|
238
|
-
self._next_bp_id = state
|
|
239
|
-
self._exception_filters = set(state
|
|
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``
|
robotcode/repl/_debug/types.py
CHANGED
|
@@ -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
|
robotcode/repl/_session.py
CHANGED
|
@@ -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
|
|
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:
|
|
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),
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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) ->
|
|
388
|
-
"""Read and remove the file that the previous process of the session wrote for this one at a restart;
|
|
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
|
|
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
|
|
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.
|
|
446
|
-
# The
|
|
447
|
-
self._current: Optional[
|
|
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[
|
|
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[
|
|
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
|
|
491
|
-
self._handed_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.
|
|
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
|
-
|
|
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.
|
|
565
|
-
self._current =
|
|
566
|
-
|
|
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
|
|
570
|
-
if self.
|
|
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
|
-
|
|
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
|
-
|
|
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]") ->
|
|
663
|
-
"""Wait until `process` has written the session file, which it does once it accepts requests;
|
|
664
|
-
|
|
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
|
|
773
|
+
return
|
|
673
774
|
if process.poll() is not None:
|
|
674
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
728
|
-
if
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
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,
|
|
775
|
-
|
|
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
|
|
884
|
+
return MovedAnswer()
|
|
780
885
|
if self._closing:
|
|
781
886
|
# The session has just ended, as the next command would find.
|
|
782
|
-
return
|
|
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
|
|
890
|
+
if kind is Request.SEND:
|
|
786
891
|
if run_ended:
|
|
787
892
|
return self._ended_error()
|
|
788
|
-
return self._send(connection,
|
|
789
|
-
if
|
|
893
|
+
return self._send(connection, request.input)
|
|
894
|
+
if kind is Request.WAIT:
|
|
790
895
|
return self._wait(connection)
|
|
791
|
-
if
|
|
896
|
+
if kind is Request.STATUS:
|
|
792
897
|
with self._condition:
|
|
793
898
|
return self._status()
|
|
794
|
-
if
|
|
899
|
+
if kind is Request.STATE:
|
|
795
900
|
with self._condition:
|
|
796
|
-
return
|
|
797
|
-
if
|
|
901
|
+
return StateAnswer(self._state())
|
|
902
|
+
if kind is Request.RESULT:
|
|
798
903
|
if not run_ended:
|
|
799
|
-
return
|
|
800
|
-
return
|
|
801
|
-
if
|
|
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
|
|
809
|
-
if
|
|
913
|
+
return InterruptedAnswer()
|
|
914
|
+
if kind is Request.STOP:
|
|
810
915
|
return self._stop(connection)
|
|
811
|
-
if
|
|
916
|
+
if kind is Request.RESTART:
|
|
812
917
|
return self._restart(connection)
|
|
813
|
-
|
|
918
|
+
assert_never(kind)
|
|
814
919
|
|
|
815
|
-
def _ended_error(self) ->
|
|
920
|
+
def _ended_error(self) -> RefusedAnswer:
|
|
816
921
|
session_id = self.session_id
|
|
817
|
-
return
|
|
818
|
-
|
|
819
|
-
f"
|
|
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) ->
|
|
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
|
|
827
|
-
|
|
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.
|
|
934
|
+
self._queue.append(queued)
|
|
830
935
|
self._condition.notify_all()
|
|
831
936
|
return self._await(
|
|
832
937
|
connection,
|
|
833
|
-
lambda:
|
|
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
|
|
940
|
+
lambda: self._answer_ended() if queued.started else self._ended_error(),
|
|
837
941
|
)
|
|
838
942
|
|
|
839
|
-
def _wait(self, connection: Connection) ->
|
|
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:
|
|
850
|
-
|
|
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) ->
|
|
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.
|
|
966
|
+
return self._await(connection, lambda: self._stopped_answer() if self._closing else None)
|
|
860
967
|
|
|
861
|
-
def _stopped_answer(self) ->
|
|
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
|
|
865
|
-
return
|
|
971
|
+
return MovedAnswer()
|
|
972
|
+
return StoppedAnswer(self._read_output(self._output_size()))
|
|
866
973
|
|
|
867
|
-
def _restart(self, connection: Connection) ->
|
|
974
|
+
def _restart(self, connection: Connection) -> ChannelAnswer:
|
|
868
975
|
with self._condition:
|
|
869
976
|
if self._restart_requested:
|
|
870
|
-
return
|
|
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.
|
|
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
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
-
|
|
906
|
-
|
|
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().
|
|
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.
|
|
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) ->
|
|
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) ->
|
|
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
|
-
|
|
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) ->
|
|
937
|
-
return
|
|
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(
|
|
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
|
-
|
|
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:
|
robotcode/repl/session_cli.py
CHANGED
|
@@ -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,
|
|
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 =
|
|
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, **
|
|
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 =
|
|
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 =
|
|
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 =
|
|
322
|
+
answer = _expect(StateAnswer, _entry(session_id), SessionRequest(Request.STATE))
|
|
309
323
|
if _structured(app):
|
|
310
|
-
app.print_data(
|
|
324
|
+
app.print_data(answer)
|
|
311
325
|
else:
|
|
312
|
-
app.echo(str(answer
|
|
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 =
|
|
339
|
+
answer = _expect(ResultAnswer, _entry(session_id), SessionRequest(Request.RESULT))
|
|
326
340
|
if _structured(app):
|
|
327
|
-
app.print_data(
|
|
341
|
+
app.print_data(answer)
|
|
328
342
|
else:
|
|
329
|
-
app.echo(str(answer
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if
|
|
352
|
-
app.echo(
|
|
353
|
-
if
|
|
354
|
-
raise click.ClickException(
|
|
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 =
|
|
373
|
+
answer = _expect(OutputAnswer, entry, SessionRequest(Request.WAIT), timeout)
|
|
359
374
|
if _structured(app):
|
|
360
|
-
|
|
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),
|
|
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.
|
|
400
|
-
app.echo(answer
|
|
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) ->
|
|
417
|
-
answer = _request(entry,
|
|
418
|
-
if not answer
|
|
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,
|
|
468
|
-
"""The answer of the session `entry` to `
|
|
469
|
-
answer = _ask(entry,
|
|
470
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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,
|
|
482
|
-
"""Send `
|
|
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(
|
|
511
|
+
connection.send(request)
|
|
487
512
|
if timeout is not None and not connection.poll(timeout):
|
|
488
513
|
try:
|
|
489
|
-
connection.send(
|
|
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:
|
|
494
|
-
except (
|
|
495
|
-
|
|
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
|
|
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(
|
|
533
|
+
app.print_data(answer)
|
|
513
534
|
return
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
|
520
|
-
app.echo(f"{entry.kind.what} has ended with exit code {answer
|
|
521
|
-
elif answer.
|
|
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:
|
|
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
|
-
|
|
528
|
-
if not isinstance(status, AnswerStatus):
|
|
529
|
-
return int(DATA_ERROR)
|
|
530
|
-
return _EXIT_CODES[status]
|
|
547
|
+
return _EXIT_CODES[answer.status]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: robotcode-repl
|
|
3
|
-
Version: 2.9.0-dev.
|
|
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.
|
|
30
|
+
Requires-Dist: robotcode-runner==2.9.0-dev.87
|
|
31
31
|
Description-Content-Type: text/markdown
|
|
32
32
|
|
|
33
33
|
# robotcode-repl
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
robotcode/repl/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
robotcode/repl/__version__.py,sha256=
|
|
2
|
+
robotcode/repl/__version__.py,sha256=beOklIzsZ_RTE6dLeNVgEzmkZPKrnZskp1prKoYYu7U,29
|
|
3
3
|
robotcode/repl/_backends.py,sha256=s7PV-HBhnAvwjn2le0cbOJ5hwLb4MjahUo6KWtRC3lc,110
|
|
4
4
|
robotcode/repl/_indent.py,sha256=jwFKaHNvTWYpUwIWHu6JHoaCz7ljOxneR5sBrKcAjpI,3303
|
|
5
5
|
robotcode/repl/_keyword_lookup.py,sha256=c8InMUDGKsw4wYmiqJzBr9GGxPC-59sRZHI0C-GWjFA,7748
|
|
6
6
|
robotcode/repl/_progress.py,sha256=BaSbDGOy5UOXvD855Kd-lDrYbqPrYIWRVs507vJznK4,2586
|
|
7
|
-
robotcode/repl/_session.py,sha256=
|
|
7
|
+
robotcode/repl/_session.py,sha256=ltjuqOqU5aEWzi0KlASnkaEsl2AFDhraIDQ3cU4I0Q8,51096
|
|
8
8
|
robotcode/repl/_session_export.py,sha256=sTSkLEMoSxNQOsII3xfDY8h-nV-mrem-4h6Iug7VQv4,2841
|
|
9
9
|
robotcode/repl/base_interpreter.py,sha256=ep1xqH5QCtkn2U2P77IJQCk_Q5TTBsYOcRjGC5hV5cY,30176
|
|
10
10
|
robotcode/repl/cli.py,sha256=RcjFT1yvcDy8HMzVO6r0cuqWCKqFofrs7XdkJPUpp1E,26878
|
|
@@ -14,19 +14,19 @@ robotcode/repl/hooks.py,sha256=JD0A6vqQ0pcJx45l5w0wq-bWqnNkBV_6tPugtMZN4yo,280
|
|
|
14
14
|
robotcode/repl/prompt_toolkit_interpreter.py,sha256=fq0uRsWucGhjKwGekUShz4r4Vt0nvbeAa-phLvI6WNw,13416
|
|
15
15
|
robotcode/repl/py.typed,sha256=bWew9mHgMy8LqMu7RuqQXFXLBxh2CRx0dUbSx-3wE48,27
|
|
16
16
|
robotcode/repl/run.py,sha256=IWeiGZm56vkLn-hykP-_mWLrdpDciNyMk33_dT-6EJA,5327
|
|
17
|
-
robotcode/repl/session_cli.py,sha256=
|
|
17
|
+
robotcode/repl/session_cli.py,sha256=piaognxJ3MoXW1Ul0s_iupfsPKsigGvLNQIEO0RKQhY,20691
|
|
18
18
|
robotcode/repl/Repl/__init__.py,sha256=-j2WxY3MzdLfsMvpa2wMs6WVF_eeJygdshtMN50r67U,43
|
|
19
19
|
robotcode/repl/Repl/repl.py,sha256=k3vCeg9z_4cb2dgZ4YKHl4TD599gumVkQMImAzGl20Y,1628
|
|
20
|
-
robotcode/repl/_debug/__init__.py,sha256=
|
|
21
|
-
robotcode/repl/_debug/controller.py,sha256=
|
|
22
|
-
robotcode/repl/_debug/types.py,sha256=
|
|
20
|
+
robotcode/repl/_debug/__init__.py,sha256=70LQ5T2ELbckKR1T8fmIiWtubtg8gsrqkTy_va5sFro,952
|
|
21
|
+
robotcode/repl/_debug/controller.py,sha256=QOnHSxKcYHd8zyXlGFaQkF244Z1hYvJdkB0mDNGsX2U,33970
|
|
22
|
+
robotcode/repl/_debug/types.py,sha256=L1sLrsSyvv5J0rQORff9-piGztgeJo-CpnNFXGLJiFc,4838
|
|
23
23
|
robotcode/repl/_pt/__init__.py,sha256=k1bJgYbKwp5ScYO8fSx0BVy8XW9Aw_8o0jTOWQTQdX8,411
|
|
24
24
|
robotcode/repl/_pt/completion.py,sha256=BJD6sASKu1EfW0QPNKIDRc9O-8ttErXvxvnFb10lAxk,23318
|
|
25
25
|
robotcode/repl/_pt/components.py,sha256=kuYVXJc4bFJq4b3-JtAtBYymvS7ssSwlKDldWYFGMuA,20152
|
|
26
26
|
robotcode/repl/_pt/doc_viewer.py,sha256=iazNOQEvRygPSUyqKH2qOYShzdYqd7vufZcxPziJx8Q,71648
|
|
27
27
|
robotcode/repl/_pt/history.py,sha256=Rvbdu4LtbYk9dPgYJY7D1JpfyT1SETUdNmtevxo21H4,1591
|
|
28
28
|
robotcode/repl/_pt/lexer.py,sha256=YXtDMWQXPIocwceMXFlvULWyGXCDmqeAtTer_Ecuvwk,13925
|
|
29
|
-
robotcode_repl-2.9.0.
|
|
30
|
-
robotcode_repl-2.9.0.
|
|
31
|
-
robotcode_repl-2.9.0.
|
|
32
|
-
robotcode_repl-2.9.0.
|
|
29
|
+
robotcode_repl-2.9.0.dev87.dist-info/METADATA,sha256=cLPONlpD1SU_KqLLnI3iVHTTlc_euorqaEWPwQK6chU,2833
|
|
30
|
+
robotcode_repl-2.9.0.dev87.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
31
|
+
robotcode_repl-2.9.0.dev87.dist-info/entry_points.txt,sha256=lI4idVOEmSWxmmioejg-_QvHXTLySK6ZxJ13R_v8ykU,40
|
|
32
|
+
robotcode_repl-2.9.0.dev87.dist-info/RECORD,,
|
|
File without changes
|
{robotcode_repl-2.9.0.dev74.dist-info → robotcode_repl-2.9.0.dev87.dist-info}/entry_points.txt
RENAMED
|
File without changes
|