robotcode-repl 2.9.0.dev74__tar.gz → 2.9.0.dev79__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/PKG-INFO +2 -2
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/pyproject.toml +1 -1
- robotcode_repl-2.9.0.dev79/src/robotcode/repl/__version__.py +1 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_debug/__init__.py +2 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_debug/controller.py +8 -12
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_debug/types.py +9 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_session.py +250 -139
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/session_cli.py +78 -61
- robotcode_repl-2.9.0.dev74/src/robotcode/repl/__version__.py +0 -1
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/.gitignore +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/README.md +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/Repl/__init__.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/Repl/repl.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/__init__.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_backends.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_indent.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_keyword_lookup.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_progress.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_pt/__init__.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_pt/completion.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_pt/components.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_pt/doc_viewer.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_pt/history.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_pt/lexer.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_session_export.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/base_interpreter.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/cli.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/console_interpreter.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/doc_cli.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/hooks.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/prompt_toolkit_interpreter.py +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/py.typed +0 -0
- {robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/run.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: robotcode-repl
|
|
3
|
-
Version: 2.9.0-dev.
|
|
3
|
+
Version: 2.9.0-dev.79
|
|
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.79
|
|
31
31
|
Description-Content-Type: text/markdown
|
|
32
32
|
|
|
33
33
|
# robotcode-repl
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.9.0-dev.79"
|
{robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_debug/__init__.py
RENAMED
|
@@ -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",
|
{robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_debug/controller.py
RENAMED
|
@@ -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-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_debug/types.py
RENAMED
|
@@ -90,6 +90,15 @@ class Breakpoint:
|
|
|
90
90
|
hits: int = 0
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
@dataclass
|
|
94
|
+
class BreakpointState:
|
|
95
|
+
"""The breakpoints and the exception breakpoints of a `DebugController` as they are, for one in another process."""
|
|
96
|
+
|
|
97
|
+
breakpoints: List[Breakpoint]
|
|
98
|
+
next_id: int
|
|
99
|
+
exception_filters: List[str]
|
|
100
|
+
|
|
101
|
+
|
|
93
102
|
@dataclass
|
|
94
103
|
class StopEvent:
|
|
95
104
|
reason: StopReason
|
|
@@ -32,20 +32,22 @@ import uuid
|
|
|
32
32
|
import warnings
|
|
33
33
|
from collections import deque
|
|
34
34
|
from dataclasses import dataclass
|
|
35
|
-
from enum import Enum, unique
|
|
35
|
+
from enum import Enum, auto, unique
|
|
36
36
|
from multiprocessing.connection import Client, Connection, Listener
|
|
37
37
|
from pathlib import Path
|
|
38
38
|
from types import TracebackType
|
|
39
|
-
from typing import Any, Callable, Deque, Dict, Iterator, List, Optional, Tuple, Type, TypeVar
|
|
39
|
+
from typing import Any, Callable, Deque, Dict, Iterator, List, Optional, Tuple, Type, TypeVar, Union
|
|
40
40
|
|
|
41
41
|
import platformdirs
|
|
42
42
|
from robot.errors import ExecutionFailed, ExecutionPassed
|
|
43
43
|
from robot.running import Keyword
|
|
44
|
+
from typing_extensions import assert_never
|
|
44
45
|
|
|
46
|
+
from robotcode.core.utils.dataclasses import from_json
|
|
45
47
|
from robotcode.plugin import Application
|
|
46
48
|
from robotcode.robot.config.utils import get_config_files
|
|
47
49
|
|
|
48
|
-
from ._debug import DebugController
|
|
50
|
+
from ._debug import BreakpointState, DebugController
|
|
49
51
|
from .console_interpreter import ConsoleInterpreter
|
|
50
52
|
|
|
51
53
|
if sys.platform == "win32":
|
|
@@ -59,8 +61,7 @@ if hasattr(platformdirs, "RuntimeDirWarning"):
|
|
|
59
61
|
|
|
60
62
|
|
|
61
63
|
class _Value(str, Enum):
|
|
62
|
-
"""A string enum whose members stand for their plain values in the file of a session
|
|
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,7 +614,7 @@ 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
|
)
|
|
@@ -557,17 +654,16 @@ class SessionServer:
|
|
|
557
654
|
"""Wait at `prompt` for the next request and return its whole input; the request that ran before gets its
|
|
558
655
|
answer first. While it waits, end the REPL or the run when the session has been idle too long or is asked to
|
|
559
656
|
stop or restart."""
|
|
560
|
-
prompt = prompt.strip()
|
|
561
657
|
with self._condition:
|
|
562
|
-
self._arrive(prompt)
|
|
658
|
+
self._arrive(_Prompt.STOP if prompt.strip() == ConsoleInterpreter.DEBUG_PROMPT.strip() else _Prompt.INPUT)
|
|
563
659
|
while True:
|
|
564
|
-
if self.
|
|
565
|
-
self._current =
|
|
566
|
-
|
|
660
|
+
if self._queue:
|
|
661
|
+
self._current = queued = self._queue.popleft()
|
|
662
|
+
queued.started = True
|
|
567
663
|
self._prompt = None
|
|
568
664
|
self._update_entry(state=SessionState.RUNS)
|
|
569
|
-
return
|
|
570
|
-
if self.
|
|
665
|
+
return queued.text
|
|
666
|
+
if self._waits_at_stop():
|
|
571
667
|
if self._stop_requested or self._restart_requested:
|
|
572
668
|
# A debug session ends its run as `.abort` does.
|
|
573
669
|
return ".abort"
|
|
@@ -641,27 +737,27 @@ class SessionServer:
|
|
|
641
737
|
with self._condition:
|
|
642
738
|
self._handing_over = True
|
|
643
739
|
breakpoints = self._controller.breakpoint_state() if self._controller is not None else None
|
|
740
|
+
handover = _Handover(self._generation + 1, self._entry.started, breakpoints)
|
|
644
741
|
# Only one process may read the pipe; from now on it is the new one.
|
|
645
742
|
self._stop_reading()
|
|
646
743
|
process: "Optional[subprocess.Popen[bytes]]" = None
|
|
647
744
|
try:
|
|
648
|
-
_write_json(
|
|
649
|
-
_restart_path(self.session_id),
|
|
650
|
-
{"generation": self._generation + 1, "started": self._entry.started, "breakpoints": breakpoints},
|
|
651
|
-
)
|
|
745
|
+
_write_json(_restart_path(self.session_id), handover)
|
|
652
746
|
process = start_session_process(self._command, self._pipe_handle(), 1, self._environment, self._folder)
|
|
653
|
-
|
|
747
|
+
self._wait_for_new_process(process)
|
|
748
|
+
except SessionError as e:
|
|
749
|
+
failure = str(e)
|
|
654
750
|
except OSError as e:
|
|
655
751
|
failure = f"The session couldn't start again: {e.strerror or e}."
|
|
656
|
-
|
|
752
|
+
else:
|
|
657
753
|
self._hand_over()
|
|
658
754
|
return True
|
|
659
755
|
self._keep_session(process, failure)
|
|
660
756
|
return False
|
|
661
757
|
|
|
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
|
-
|
|
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."""
|
|
665
761
|
# Known by its channel: on Windows, the `python.exe` of a virtual environment runs the interpreter in a process
|
|
666
762
|
# of its own, whose ID isn't that of `process`.
|
|
667
763
|
address = _address(self.session_id, self._generation + 1)
|
|
@@ -669,13 +765,15 @@ class SessionServer:
|
|
|
669
765
|
while True:
|
|
670
766
|
entry = _load_entry(self.session_id)
|
|
671
767
|
if entry is not None and entry.address == address:
|
|
672
|
-
return
|
|
768
|
+
return
|
|
673
769
|
if process.poll() is not None:
|
|
674
|
-
|
|
770
|
+
raise SessionError(
|
|
771
|
+
f"The session couldn't start again; its new process ended with exit code {process.returncode}."
|
|
772
|
+
)
|
|
675
773
|
if time.monotonic() >= ready_by:
|
|
676
774
|
process.kill()
|
|
677
775
|
process.wait()
|
|
678
|
-
|
|
776
|
+
raise SessionError(
|
|
679
777
|
f"The session couldn't start again; its new process wasn't ready after {STARTUP_LIMIT} seconds "
|
|
680
778
|
"and was ended."
|
|
681
779
|
)
|
|
@@ -687,7 +785,7 @@ class SessionServer:
|
|
|
687
785
|
with self._output_lock:
|
|
688
786
|
size = self._read_size
|
|
689
787
|
with self._condition:
|
|
690
|
-
self._restart_answer =
|
|
788
|
+
self._restart_answer = RestartedAnswer(self._read_output(size))
|
|
691
789
|
self._handed_over = True
|
|
692
790
|
self._close_channel()
|
|
693
791
|
# Anything this process still writes would end up in the output of the new process.
|
|
@@ -712,11 +810,11 @@ class SessionServer:
|
|
|
712
810
|
self._handing_over = False
|
|
713
811
|
# The new process may have written the file before it ended.
|
|
714
812
|
self._update_entry()
|
|
715
|
-
self._restart_answer =
|
|
813
|
+
self._restart_answer = RestartFailedAnswer(self._read_output(self._output_size()), failure)
|
|
716
814
|
self._restart_requested = False
|
|
717
815
|
self._condition.notify_all()
|
|
718
816
|
|
|
719
|
-
def _arrive(self, prompt:
|
|
817
|
+
def _arrive(self, prompt: _Prompt) -> None:
|
|
720
818
|
"""The interpreter has arrived at `prompt`: give the request that ran its answer, and wake the threads that
|
|
721
819
|
wait for it."""
|
|
722
820
|
_flush_output()
|
|
@@ -724,11 +822,11 @@ class SessionServer:
|
|
|
724
822
|
# No I/O up to the notification: a Ctrl-C at the prompt can interrupt this method, and the request must then
|
|
725
823
|
# either have its answer or be left to the next arrival.
|
|
726
824
|
failed, self._failed = self._failed, False
|
|
727
|
-
|
|
728
|
-
if
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
|
732
830
|
self._arrivals += 1
|
|
733
831
|
self._arrival_failed = failed
|
|
734
832
|
self._arrival_end = end
|
|
@@ -743,6 +841,10 @@ class SessionServer:
|
|
|
743
841
|
idle_since = max(self._entry.last_request, self._entry.waits_since or 0)
|
|
744
842
|
return time.time() - idle_since >= self._entry.idle_timeout
|
|
745
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
|
+
|
|
746
848
|
# --- the requests, each on a thread of its own ---------------------------
|
|
747
849
|
|
|
748
850
|
def _accept(self) -> None:
|
|
@@ -760,9 +862,7 @@ class SessionServer:
|
|
|
760
862
|
self._serving += 1
|
|
761
863
|
try:
|
|
762
864
|
with connection:
|
|
763
|
-
|
|
764
|
-
if answer is not None:
|
|
765
|
-
connection.send(answer)
|
|
865
|
+
connection.send(self._answer(connection, connection.recv()))
|
|
766
866
|
except (EOFError, OSError):
|
|
767
867
|
# The client has gone.
|
|
768
868
|
pass
|
|
@@ -771,72 +871,71 @@ class SessionServer:
|
|
|
771
871
|
self._serving -= 1
|
|
772
872
|
self._condition.notify_all()
|
|
773
873
|
|
|
774
|
-
def _answer(self, connection: Connection,
|
|
775
|
-
|
|
874
|
+
def _answer(self, connection: Connection, request: SessionRequest) -> ChannelAnswer:
|
|
875
|
+
kind = request.kind
|
|
776
876
|
with self._condition:
|
|
777
877
|
if self._handed_over:
|
|
778
878
|
# The command then asks the new process, which the session file names now.
|
|
779
|
-
return
|
|
879
|
+
return MovedAnswer()
|
|
780
880
|
if self._closing:
|
|
781
881
|
# The session has just ended, as the next command would find.
|
|
782
|
-
return
|
|
882
|
+
return RefusedAnswer(f"No session '{self.session_id}' is open.")
|
|
783
883
|
self._update_entry(last_request=time.time())
|
|
784
884
|
run_ended = self._run_ended
|
|
785
|
-
if
|
|
885
|
+
if kind is Request.SEND:
|
|
786
886
|
if run_ended:
|
|
787
887
|
return self._ended_error()
|
|
788
|
-
return self._send(connection,
|
|
789
|
-
if
|
|
888
|
+
return self._send(connection, request.input)
|
|
889
|
+
if kind is Request.WAIT:
|
|
790
890
|
return self._wait(connection)
|
|
791
|
-
if
|
|
891
|
+
if kind is Request.STATUS:
|
|
792
892
|
with self._condition:
|
|
793
893
|
return self._status()
|
|
794
|
-
if
|
|
894
|
+
if kind is Request.STATE:
|
|
795
895
|
with self._condition:
|
|
796
|
-
return
|
|
797
|
-
if
|
|
896
|
+
return StateAnswer(self._state())
|
|
897
|
+
if kind is Request.RESULT:
|
|
798
898
|
if not run_ended:
|
|
799
|
-
return
|
|
800
|
-
return
|
|
801
|
-
if
|
|
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:
|
|
802
902
|
if run_ended:
|
|
803
903
|
return self._ended_error()
|
|
804
904
|
with self._condition:
|
|
805
905
|
self._interrupted = True
|
|
806
906
|
# As Ctrl-C does at the terminal of the session.
|
|
807
907
|
self._signal(signal.SIGINT)
|
|
808
|
-
return
|
|
809
|
-
if
|
|
908
|
+
return InterruptedAnswer()
|
|
909
|
+
if kind is Request.STOP:
|
|
810
910
|
return self._stop(connection)
|
|
811
|
-
if
|
|
911
|
+
if kind is Request.RESTART:
|
|
812
912
|
return self._restart(connection)
|
|
813
|
-
|
|
913
|
+
assert_never(kind)
|
|
814
914
|
|
|
815
|
-
def _ended_error(self) ->
|
|
915
|
+
def _ended_error(self) -> RefusedAnswer:
|
|
816
916
|
session_id = self.session_id
|
|
817
|
-
return
|
|
818
|
-
|
|
819
|
-
f"
|
|
820
|
-
|
|
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
|
+
)
|
|
821
921
|
|
|
822
|
-
def _send(self, connection: Connection, text: str) ->
|
|
922
|
+
def _send(self, connection: Connection, text: str) -> ChannelAnswer:
|
|
823
923
|
lines = text.strip().splitlines()
|
|
824
924
|
if len(lines) > 1 and lines[0].lstrip().startswith("."):
|
|
825
925
|
# A dot-command is an input of its own.
|
|
826
|
-
return
|
|
827
|
-
|
|
926
|
+
return OutputAnswer("[ ERROR ] Dot-commands can only be sent on their own.\n", AnswerStatus.FAILED)
|
|
927
|
+
queued = _QueuedInput(text)
|
|
828
928
|
with self._condition:
|
|
829
|
-
self.
|
|
929
|
+
self._queue.append(queued)
|
|
830
930
|
self._condition.notify_all()
|
|
831
931
|
return self._await(
|
|
832
932
|
connection,
|
|
833
|
-
lambda:
|
|
834
|
-
lambda: self._make_answer(request.output_end, _passed_or_failed(request.failed)),
|
|
933
|
+
lambda: self._make_answer(queued.output_end, _passed_or_failed(queued.failed)) if queued.done else None,
|
|
835
934
|
# An input that was still queued when the REPL or the run ended never ran.
|
|
836
|
-
lambda: self._answer_ended() if
|
|
935
|
+
lambda: self._answer_ended() if queued.started else self._ended_error(),
|
|
837
936
|
)
|
|
838
937
|
|
|
839
|
-
def _wait(self, connection: Connection) ->
|
|
938
|
+
def _wait(self, connection: Connection) -> ChannelAnswer:
|
|
840
939
|
with self._condition:
|
|
841
940
|
if self._run_ended:
|
|
842
941
|
return self._answer_ended()
|
|
@@ -846,42 +945,43 @@ class SessionServer:
|
|
|
846
945
|
arrivals = self._arrivals
|
|
847
946
|
return self._await(
|
|
848
947
|
connection,
|
|
849
|
-
lambda:
|
|
850
|
-
|
|
948
|
+
lambda: (
|
|
949
|
+
self._make_answer(self._arrival_end, _passed_or_failed(self._arrival_failed))
|
|
950
|
+
if self._arrivals > arrivals
|
|
951
|
+
else None
|
|
952
|
+
),
|
|
851
953
|
self._answer_ended,
|
|
852
954
|
)
|
|
853
955
|
|
|
854
|
-
def _stop(self, connection: Connection) ->
|
|
956
|
+
def _stop(self, connection: Connection) -> ChannelAnswer:
|
|
855
957
|
with self._condition:
|
|
856
958
|
self._stop_requested = True
|
|
857
959
|
self._condition.notify_all()
|
|
858
960
|
self._end_run()
|
|
859
|
-
return self._await(connection, lambda: self.
|
|
961
|
+
return self._await(connection, lambda: self._stopped_answer() if self._closing else None)
|
|
860
962
|
|
|
861
|
-
def _stopped_answer(self) ->
|
|
963
|
+
def _stopped_answer(self) -> Union[MovedAnswer, StoppedAnswer]:
|
|
862
964
|
if self._handed_over:
|
|
863
965
|
# A restart has taken over meanwhile: the command stops the new process.
|
|
864
|
-
return
|
|
865
|
-
return
|
|
966
|
+
return MovedAnswer()
|
|
967
|
+
return StoppedAnswer(self._read_output(self._output_size()))
|
|
866
968
|
|
|
867
|
-
def _restart(self, connection: Connection) ->
|
|
969
|
+
def _restart(self, connection: Connection) -> ChannelAnswer:
|
|
868
970
|
with self._condition:
|
|
869
971
|
if self._restart_requested:
|
|
870
|
-
return
|
|
972
|
+
return RefusedAnswer(f"Session '{self.session_id}' restarts already.")
|
|
871
973
|
self._restart_requested = True
|
|
872
974
|
self._restart_answer = None
|
|
873
975
|
self._condition.notify_all()
|
|
874
976
|
self._end_run()
|
|
875
|
-
return self._await(
|
|
876
|
-
connection, lambda: self._restart_answer is not None, lambda: dict(self._restart_answer or {})
|
|
877
|
-
)
|
|
977
|
+
return self._await(connection, lambda: self._restart_answer)
|
|
878
978
|
|
|
879
979
|
def _end_run(self) -> None:
|
|
880
980
|
"""End the REPL or the run, unless it has ended, for a `stop` or a `restart` that the caller has noted."""
|
|
881
981
|
with self._condition:
|
|
882
982
|
if self._run_ended:
|
|
883
983
|
return
|
|
884
|
-
waits_at_stop = self.
|
|
984
|
+
waits_at_stop = self._waits_at_stop()
|
|
885
985
|
if self._kind is SessionKind.REPL:
|
|
886
986
|
# As SIGTERM does.
|
|
887
987
|
self._signal(signal.SIGTERM)
|
|
@@ -893,48 +993,47 @@ class SessionServer:
|
|
|
893
993
|
def _await(
|
|
894
994
|
self,
|
|
895
995
|
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.
|
|
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.
|
|
901
1000
|
Meanwhile the client can send a `status` request after its timeout: it then gets the output so far, and a
|
|
902
1001
|
later `wait` gets the answer."""
|
|
903
1002
|
while True:
|
|
904
1003
|
with self._condition:
|
|
905
|
-
|
|
906
|
-
|
|
1004
|
+
answer = ready()
|
|
1005
|
+
if answer is not None:
|
|
1006
|
+
return answer
|
|
907
1007
|
if at_end is not None and self._run_ended:
|
|
908
1008
|
return at_end()
|
|
909
1009
|
self._condition.wait(0.1)
|
|
910
1010
|
if connection.poll():
|
|
911
|
-
if connection.recv().
|
|
1011
|
+
if connection.recv().kind is Request.STATUS:
|
|
912
1012
|
with self._condition:
|
|
913
1013
|
return self._status()
|
|
914
1014
|
|
|
915
1015
|
def _runs(self) -> bool:
|
|
916
|
-
return self._prompt is None or self._current is not None or bool(self.
|
|
1016
|
+
return self._prompt is None or self._current is not None or bool(self._queue)
|
|
917
1017
|
|
|
918
1018
|
def _state(self) -> SessionState:
|
|
919
1019
|
if self._run_ended:
|
|
920
1020
|
return SessionState.ENDED
|
|
921
1021
|
return SessionState.RUNS if self._runs() else SessionState.WAITS
|
|
922
1022
|
|
|
923
|
-
def _status(self) ->
|
|
1023
|
+
def _status(self) -> OutputAnswer:
|
|
924
1024
|
if self._run_ended:
|
|
925
1025
|
return self._answer_ended()
|
|
926
1026
|
if self._runs():
|
|
927
1027
|
return self._make_answer(self._output_size(), AnswerStatus.RUNNING)
|
|
928
1028
|
return self._make_answer(self._output_size(), _passed_or_failed(self._arrival_failed))
|
|
929
1029
|
|
|
930
|
-
def _answer_ended(self) ->
|
|
1030
|
+
def _answer_ended(self) -> OutputAnswer:
|
|
931
1031
|
"""The output since the last answer, with the status of the input that ran at the end and the exit code of the
|
|
932
1032
|
REPL or the run."""
|
|
933
|
-
|
|
934
|
-
return {**answer, "exit_code": self._exit_code}
|
|
1033
|
+
return self._make_answer(self._output_size(), _passed_or_failed(self._end_failed), self._exit_code)
|
|
935
1034
|
|
|
936
|
-
def _make_answer(self, end: int, status: AnswerStatus) ->
|
|
937
|
-
return
|
|
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)
|
|
938
1037
|
|
|
939
1038
|
def _read_output(self, end: int) -> str:
|
|
940
1039
|
"""Take the output from where the last answer ended up to `end` out of memory, and return it."""
|
|
@@ -1025,10 +1124,22 @@ class SessionServer:
|
|
|
1025
1124
|
self._read_size += len(data)
|
|
1026
1125
|
self._at_line_start = data.endswith(b"\n")
|
|
1027
1126
|
|
|
1028
|
-
def _update_entry(
|
|
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."""
|
|
1029
1135
|
if self._closing or self._handing_over:
|
|
1030
1136
|
return
|
|
1031
|
-
|
|
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
|
|
1032
1143
|
with contextlib.suppress(OSError):
|
|
1033
1144
|
_write_entry(self._entry)
|
|
1034
1145
|
|
|
@@ -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 +0,0 @@
|
|
|
1
|
-
__version__ = "2.9.0-dev.74"
|
|
File without changes
|
|
File without changes
|
{robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/Repl/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_keyword_lookup.py
RENAMED
|
File without changes
|
|
File without changes
|
{robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_pt/__init__.py
RENAMED
|
File without changes
|
{robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_pt/completion.py
RENAMED
|
File without changes
|
{robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_pt/components.py
RENAMED
|
File without changes
|
{robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_pt/doc_viewer.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/_session_export.py
RENAMED
|
File without changes
|
{robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/base_interpreter.py
RENAMED
|
File without changes
|
|
File without changes
|
{robotcode_repl-2.9.0.dev74 → robotcode_repl-2.9.0.dev79}/src/robotcode/repl/console_interpreter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|