ffl-python 0.1.4__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.
- ffl/__init__.py +63 -0
- ffl/_generated.py +1658 -0
- ffl/_runtime.py +1136 -0
- ffl/bin/ffl.com +4 -0
- ffl/client.py +328 -0
- ffl/errors.py +25 -0
- ffl/events.py +280 -0
- ffl/models.py +285 -0
- ffl/parsing.py +110 -0
- ffl/py.typed +1 -0
- ffl_python-0.1.4.dist-info/METADATA +210 -0
- ffl_python-0.1.4.dist-info/RECORD +15 -0
- ffl_python-0.1.4.dist-info/WHEEL +5 -0
- ffl_python-0.1.4.dist-info/licenses/LICENSE +201 -0
- ffl_python-0.1.4.dist-info/top_level.txt +1 -0
ffl/_runtime.py
ADDED
|
@@ -0,0 +1,1136 @@
|
|
|
1
|
+
# Generated by APEBind. Do not edit directly.
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class APEProcessResult:
|
|
20
|
+
argv: tuple[str, ...]
|
|
21
|
+
return_code: int
|
|
22
|
+
stdout: str
|
|
23
|
+
stderr: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class APEEvent:
|
|
28
|
+
name: str
|
|
29
|
+
data: dict[str, Any]
|
|
30
|
+
raw: dict[str, Any]
|
|
31
|
+
known: bool
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class APEProcessError(RuntimeError):
|
|
35
|
+
def __init__(self, result: APEProcessResult):
|
|
36
|
+
super().__init__(
|
|
37
|
+
f'APE exited with code {result.return_code}: {result.stderr.strip()}'
|
|
38
|
+
f'{_wsl_interop_guidance(result.stderr)}'
|
|
39
|
+
)
|
|
40
|
+
self.result = result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class APEEventError(RuntimeError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _normalize_ape_return_code(return_code: int) -> int:
|
|
48
|
+
"""Convert Cosmopolitan's Windows wait status to the program exit code."""
|
|
49
|
+
if os.name == 'nt' and return_code > 0 and return_code % 256 == 0:
|
|
50
|
+
return return_code // 256
|
|
51
|
+
|
|
52
|
+
return return_code
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_wsl2() -> bool:
|
|
56
|
+
try:
|
|
57
|
+
version = Path('/proc/version').read_text(encoding='utf-8').lower()
|
|
58
|
+
except OSError:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
return 'microsoft' in version or 'wsl' in version
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _wsl_interop_guidance(stderr: str) -> str:
|
|
65
|
+
if 'tlserror([0x6300])' not in stderr.lower() or not _is_wsl2():
|
|
66
|
+
return ''
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
'\n\nWSL2 may be executing this .com APE through Windows interop. '
|
|
70
|
+
'Run the following command in WSL, then restart the WSL session:\n\n'
|
|
71
|
+
" sudo sh -c 'echo -1 > /proc/sys/fs/binfmt_misc/WSLInterop'"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _extract_field(value: Any, field_path: str | None) -> Any:
|
|
76
|
+
if not field_path:
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
current = value
|
|
80
|
+
|
|
81
|
+
for part in field_path.split('.'):
|
|
82
|
+
current = current[part]
|
|
83
|
+
|
|
84
|
+
return current
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _RuntimeFiles:
|
|
88
|
+
def __init__(self, directory: Path, paths: dict[str, Path]):
|
|
89
|
+
self._directory = directory
|
|
90
|
+
self._paths = paths
|
|
91
|
+
self._cleaned = False
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def create(cls, operation: dict[str, Any]) -> _RuntimeFiles | None:
|
|
95
|
+
roles: dict[str, str] = {}
|
|
96
|
+
if operation['result']['format'] == 'json_file':
|
|
97
|
+
roles['result'] = 'result.json'
|
|
98
|
+
|
|
99
|
+
events = operation.get('events')
|
|
100
|
+
if events is not None and events['source']['kind'] == 'jsonl_file':
|
|
101
|
+
roles['events'] = 'events.jsonl'
|
|
102
|
+
|
|
103
|
+
if not roles:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
directory = Path(tempfile.mkdtemp(prefix='apebind-'))
|
|
107
|
+
paths = {
|
|
108
|
+
role: directory / file_name
|
|
109
|
+
for role, file_name in roles.items()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return cls(directory, paths)
|
|
113
|
+
|
|
114
|
+
def path(self, role: str) -> Path | None:
|
|
115
|
+
return self._paths.get(role)
|
|
116
|
+
|
|
117
|
+
def cleanup(self) -> None:
|
|
118
|
+
if self._cleaned:
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
shutil.rmtree(self._directory)
|
|
122
|
+
self._cleaned = True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _EventDecoder:
|
|
126
|
+
_MISSING = object()
|
|
127
|
+
|
|
128
|
+
def __init__(self, config: dict[str, Any]):
|
|
129
|
+
self._config = config
|
|
130
|
+
self._types = {
|
|
131
|
+
event_type['source_value']: event_type
|
|
132
|
+
for event_type in config['types']
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def _field_value(cls, raw: dict[str, Any], field_path: str) -> Any:
|
|
137
|
+
try:
|
|
138
|
+
return _extract_field(raw, field_path)
|
|
139
|
+
except (KeyError, TypeError):
|
|
140
|
+
return cls._MISSING
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _validated_value(event_name: str, field: dict[str, Any], value: Any) -> Any:
|
|
144
|
+
value_type = field['value_type']
|
|
145
|
+
valid = False
|
|
146
|
+
|
|
147
|
+
if value_type in ('string', 'path'):
|
|
148
|
+
valid = isinstance(value, str)
|
|
149
|
+
elif value_type == 'boolean':
|
|
150
|
+
valid = isinstance(value, bool)
|
|
151
|
+
elif value_type == 'integer':
|
|
152
|
+
valid = isinstance(value, int) and not isinstance(value, bool)
|
|
153
|
+
elif value_type == 'float':
|
|
154
|
+
valid = isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
155
|
+
else:
|
|
156
|
+
raise APEEventError(f'Unsupported event field type: {value_type}')
|
|
157
|
+
|
|
158
|
+
if not valid:
|
|
159
|
+
raise APEEventError(
|
|
160
|
+
f"Event {event_name} field {field['name']} must be {value_type}"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if value_type == 'float':
|
|
164
|
+
return float(value)
|
|
165
|
+
|
|
166
|
+
return value
|
|
167
|
+
|
|
168
|
+
def decode(self, line: str) -> APEEvent:
|
|
169
|
+
try:
|
|
170
|
+
raw = json.loads(line)
|
|
171
|
+
except json.JSONDecodeError as error:
|
|
172
|
+
raise APEEventError(f'Invalid JSONL event: {line!r}') from error
|
|
173
|
+
|
|
174
|
+
if not isinstance(raw, dict):
|
|
175
|
+
raise APEEventError('JSONL event must be a JSON object')
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
source_name = _extract_field(raw, self._config['discriminator'])
|
|
179
|
+
except (KeyError, TypeError) as error:
|
|
180
|
+
raise APEEventError(
|
|
181
|
+
f"Event is missing discriminator: {self._config['discriminator']}"
|
|
182
|
+
) from error
|
|
183
|
+
|
|
184
|
+
if not isinstance(source_name, str) or not source_name:
|
|
185
|
+
raise APEEventError('Event discriminator must resolve to a non-empty string')
|
|
186
|
+
|
|
187
|
+
event_type = self._types.get(source_name)
|
|
188
|
+
if event_type is None:
|
|
189
|
+
return APEEvent(source_name, dict(raw), raw, False)
|
|
190
|
+
|
|
191
|
+
data: dict[str, Any] = {}
|
|
192
|
+
|
|
193
|
+
for event_field in event_type['fields']:
|
|
194
|
+
value = self._field_value(raw, event_field['name'])
|
|
195
|
+
if value is self._MISSING:
|
|
196
|
+
if event_field['required']:
|
|
197
|
+
raise APEEventError(
|
|
198
|
+
f"Event {event_type['name']} is missing field: {event_field['name']}"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
data[event_field['api_name']] = self._validated_value(
|
|
204
|
+
event_type['name'],
|
|
205
|
+
event_field,
|
|
206
|
+
value,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return APEEvent(event_type['name'], data, raw, True)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class _JSONLFileEventChannel:
|
|
213
|
+
def __init__(
|
|
214
|
+
self,
|
|
215
|
+
path: Path,
|
|
216
|
+
config: dict[str, Any],
|
|
217
|
+
is_running: Callable[[], bool],
|
|
218
|
+
):
|
|
219
|
+
self._path = path
|
|
220
|
+
self._decoder = _EventDecoder(config)
|
|
221
|
+
self._is_running = is_running
|
|
222
|
+
self._events: list[APEEvent] = []
|
|
223
|
+
self._listeners: dict[str, list[Callable[[dict[str, Any]], None]]] = {}
|
|
224
|
+
self._condition = threading.Condition()
|
|
225
|
+
self._thread: threading.Thread | None = None
|
|
226
|
+
self._error: BaseException | None = None
|
|
227
|
+
self._finished = False
|
|
228
|
+
self._offset = 0
|
|
229
|
+
self._buffer = ''
|
|
230
|
+
|
|
231
|
+
def start(self) -> None:
|
|
232
|
+
with self._condition:
|
|
233
|
+
if self._thread is not None:
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
self._thread = threading.Thread(
|
|
237
|
+
target=self._run,
|
|
238
|
+
name='apebind-event-channel',
|
|
239
|
+
daemon=True,
|
|
240
|
+
)
|
|
241
|
+
self._thread.start()
|
|
242
|
+
|
|
243
|
+
def _read_new_text(self) -> str:
|
|
244
|
+
if not self._path.exists():
|
|
245
|
+
return ''
|
|
246
|
+
|
|
247
|
+
text = self._path.read_text(encoding='utf-8', errors='strict')
|
|
248
|
+
if len(text) < self._offset:
|
|
249
|
+
raise APEEventError('JSONL event file was truncated while being consumed')
|
|
250
|
+
|
|
251
|
+
chunk = text[self._offset:]
|
|
252
|
+
self._offset = len(text)
|
|
253
|
+
return chunk
|
|
254
|
+
|
|
255
|
+
def _publish(self, event: APEEvent) -> None:
|
|
256
|
+
with self._condition:
|
|
257
|
+
self._events.append(event)
|
|
258
|
+
listeners = tuple(self._listeners.get(event.name, ()))
|
|
259
|
+
self._condition.notify_all()
|
|
260
|
+
|
|
261
|
+
for listener in listeners:
|
|
262
|
+
listener(event.data)
|
|
263
|
+
|
|
264
|
+
def _consume_available(self, *, final: bool = False) -> None:
|
|
265
|
+
self._buffer += self._read_new_text()
|
|
266
|
+
lines = self._buffer.splitlines(keepends=True)
|
|
267
|
+
self._buffer = ''
|
|
268
|
+
|
|
269
|
+
for line in lines:
|
|
270
|
+
if not line.endswith(('\n', '\r')):
|
|
271
|
+
self._buffer = line
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
content = line.strip()
|
|
275
|
+
if content:
|
|
276
|
+
self._publish(self._decoder.decode(content))
|
|
277
|
+
|
|
278
|
+
if final and self._buffer.strip():
|
|
279
|
+
self._publish(self._decoder.decode(self._buffer.strip()))
|
|
280
|
+
self._buffer = ''
|
|
281
|
+
|
|
282
|
+
def _run(self) -> None:
|
|
283
|
+
try:
|
|
284
|
+
while self._is_running():
|
|
285
|
+
self._consume_available()
|
|
286
|
+
time.sleep(0.02)
|
|
287
|
+
|
|
288
|
+
self._consume_available(final=True)
|
|
289
|
+
except BaseException as error:
|
|
290
|
+
with self._condition:
|
|
291
|
+
self._error = error
|
|
292
|
+
finally:
|
|
293
|
+
with self._condition:
|
|
294
|
+
self._finished = True
|
|
295
|
+
self._condition.notify_all()
|
|
296
|
+
|
|
297
|
+
def on(
|
|
298
|
+
self,
|
|
299
|
+
event_name: str,
|
|
300
|
+
listener: Callable[[dict[str, Any]], None],
|
|
301
|
+
) -> None:
|
|
302
|
+
self.start()
|
|
303
|
+
|
|
304
|
+
with self._condition:
|
|
305
|
+
history = tuple(event for event in self._events if event.name == event_name)
|
|
306
|
+
self._listeners.setdefault(event_name, []).append(listener)
|
|
307
|
+
|
|
308
|
+
for event in history:
|
|
309
|
+
listener(event.data)
|
|
310
|
+
|
|
311
|
+
def events(self, timeout: float | None = None) -> Iterator[APEEvent]:
|
|
312
|
+
self.start()
|
|
313
|
+
index = 0
|
|
314
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
315
|
+
|
|
316
|
+
while True:
|
|
317
|
+
with self._condition:
|
|
318
|
+
while index >= len(self._events) and not self._finished:
|
|
319
|
+
remaining = None
|
|
320
|
+
if deadline is not None:
|
|
321
|
+
remaining = deadline - time.monotonic()
|
|
322
|
+
if remaining <= 0:
|
|
323
|
+
raise TimeoutError('Timed out waiting for an APE event')
|
|
324
|
+
|
|
325
|
+
self._condition.wait(timeout=remaining)
|
|
326
|
+
|
|
327
|
+
if index < len(self._events):
|
|
328
|
+
event = self._events[index]
|
|
329
|
+
index += 1
|
|
330
|
+
else:
|
|
331
|
+
if self._error is not None:
|
|
332
|
+
raise self._error
|
|
333
|
+
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
yield event
|
|
337
|
+
|
|
338
|
+
def wait(self) -> None:
|
|
339
|
+
thread = self._thread
|
|
340
|
+
if thread is None:
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
thread.join()
|
|
344
|
+
|
|
345
|
+
if self._error is not None:
|
|
346
|
+
raise self._error
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class ProcessSession:
|
|
350
|
+
def __init__(
|
|
351
|
+
self,
|
|
352
|
+
process: subprocess.Popen[bytes],
|
|
353
|
+
argv: list[str],
|
|
354
|
+
runtime_files: _RuntimeFiles | None = None,
|
|
355
|
+
event_config: dict[str, Any] | None = None,
|
|
356
|
+
capture_stdout: bool = True,
|
|
357
|
+
):
|
|
358
|
+
self._process = process
|
|
359
|
+
self._argv = tuple(argv)
|
|
360
|
+
self._runtime_files = runtime_files
|
|
361
|
+
self._result: Any = None
|
|
362
|
+
self._process_result: APEProcessResult | None = None
|
|
363
|
+
self._stopped_by_client = False
|
|
364
|
+
self._capture_stdout = capture_stdout
|
|
365
|
+
self._streamed_stdout_consumed = capture_stdout
|
|
366
|
+
self._streamed_stdout_iterating = False
|
|
367
|
+
self._streamed_stdout_lock = threading.Lock()
|
|
368
|
+
self._stdout_chunks: list[bytes] = []
|
|
369
|
+
self._stderr_chunks: list[bytes] = []
|
|
370
|
+
self._output_condition = threading.Condition()
|
|
371
|
+
self._stdout_finished = not capture_stdout
|
|
372
|
+
self._stderr_finished = False
|
|
373
|
+
self._stdout_error: OSError | None = None
|
|
374
|
+
self._stderr_error: OSError | None = None
|
|
375
|
+
self._stdin_source: Any = None
|
|
376
|
+
self._stdin_thread: threading.Thread | None = None
|
|
377
|
+
self._stdin_error: BaseException | None = None
|
|
378
|
+
self._stdin_lock = threading.Lock()
|
|
379
|
+
self._event_channel = self._create_event_channel(event_config)
|
|
380
|
+
self._exit_listeners: list[Callable[[], None]] = []
|
|
381
|
+
self._exit_lock = threading.Lock()
|
|
382
|
+
self._exit_notified = False
|
|
383
|
+
|
|
384
|
+
if self._event_channel is not None:
|
|
385
|
+
self._event_channel.start()
|
|
386
|
+
|
|
387
|
+
self._stdout_thread = self._start_output_reader(
|
|
388
|
+
self._process.stdout,
|
|
389
|
+
self._stdout_chunks,
|
|
390
|
+
is_stdout=True,
|
|
391
|
+
) if capture_stdout else None
|
|
392
|
+
self._stderr_thread = self._start_output_reader(
|
|
393
|
+
self._process.stderr,
|
|
394
|
+
self._stderr_chunks,
|
|
395
|
+
is_stdout=False,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
threading.Thread(
|
|
399
|
+
target=self._watch_for_exit,
|
|
400
|
+
name='apebind-process-session',
|
|
401
|
+
daemon=True,
|
|
402
|
+
).start()
|
|
403
|
+
|
|
404
|
+
def _watch_for_exit(self) -> None:
|
|
405
|
+
self._process.wait()
|
|
406
|
+
self._close_stdin_source()
|
|
407
|
+
self._notify_exit()
|
|
408
|
+
|
|
409
|
+
def _start_output_reader(
|
|
410
|
+
self,
|
|
411
|
+
stream: Any,
|
|
412
|
+
chunks: list[bytes],
|
|
413
|
+
*,
|
|
414
|
+
is_stdout: bool,
|
|
415
|
+
) -> threading.Thread | None:
|
|
416
|
+
if stream is None:
|
|
417
|
+
with self._output_condition:
|
|
418
|
+
if is_stdout:
|
|
419
|
+
self._stdout_finished = True
|
|
420
|
+
else:
|
|
421
|
+
self._stderr_finished = True
|
|
422
|
+
self._output_condition.notify_all()
|
|
423
|
+
return None
|
|
424
|
+
|
|
425
|
+
thread = threading.Thread(
|
|
426
|
+
target=self._drain_output,
|
|
427
|
+
args=(stream, chunks, is_stdout),
|
|
428
|
+
name='apebind-stdout' if is_stdout else 'apebind-stderr',
|
|
429
|
+
daemon=True,
|
|
430
|
+
)
|
|
431
|
+
thread.start()
|
|
432
|
+
return thread
|
|
433
|
+
|
|
434
|
+
def _drain_output(
|
|
435
|
+
self,
|
|
436
|
+
stream: Any,
|
|
437
|
+
chunks: list[bytes],
|
|
438
|
+
is_stdout: bool,
|
|
439
|
+
) -> None:
|
|
440
|
+
try:
|
|
441
|
+
while chunk := stream.read(65536):
|
|
442
|
+
with self._output_condition:
|
|
443
|
+
chunks.append(chunk)
|
|
444
|
+
self._output_condition.notify_all()
|
|
445
|
+
except OSError as error:
|
|
446
|
+
with self._output_condition:
|
|
447
|
+
if is_stdout:
|
|
448
|
+
self._stdout_error = error
|
|
449
|
+
else:
|
|
450
|
+
self._stderr_error = error
|
|
451
|
+
finally:
|
|
452
|
+
stream.close()
|
|
453
|
+
with self._output_condition:
|
|
454
|
+
if is_stdout:
|
|
455
|
+
self._stdout_finished = True
|
|
456
|
+
else:
|
|
457
|
+
self._stderr_finished = True
|
|
458
|
+
self._output_condition.notify_all()
|
|
459
|
+
|
|
460
|
+
def _notify_exit(self) -> None:
|
|
461
|
+
with self._exit_lock:
|
|
462
|
+
if self._exit_notified:
|
|
463
|
+
return
|
|
464
|
+
|
|
465
|
+
self._exit_notified = True
|
|
466
|
+
listeners = tuple(self._exit_listeners)
|
|
467
|
+
self._exit_listeners.clear()
|
|
468
|
+
|
|
469
|
+
for listener in listeners:
|
|
470
|
+
listener()
|
|
471
|
+
|
|
472
|
+
def _create_event_channel(
|
|
473
|
+
self,
|
|
474
|
+
event_config: dict[str, Any] | None,
|
|
475
|
+
) -> _JSONLFileEventChannel | None:
|
|
476
|
+
if event_config is None:
|
|
477
|
+
return None
|
|
478
|
+
|
|
479
|
+
if self._runtime_files is None:
|
|
480
|
+
raise RuntimeError('Event operation has no runtime-owned files')
|
|
481
|
+
|
|
482
|
+
event_path = self._runtime_files.path('events')
|
|
483
|
+
if event_path is None:
|
|
484
|
+
raise RuntimeError('Event operation has no runtime-owned event path')
|
|
485
|
+
|
|
486
|
+
return _JSONLFileEventChannel(event_path, event_config, lambda: self.running)
|
|
487
|
+
|
|
488
|
+
@property
|
|
489
|
+
def argv(self) -> tuple[str, ...]:
|
|
490
|
+
return self._argv
|
|
491
|
+
|
|
492
|
+
@property
|
|
493
|
+
def pid(self) -> int:
|
|
494
|
+
return self._process.pid
|
|
495
|
+
|
|
496
|
+
@property
|
|
497
|
+
def running(self) -> bool:
|
|
498
|
+
return self._process.poll() is None
|
|
499
|
+
|
|
500
|
+
@property
|
|
501
|
+
def return_code(self) -> int | None:
|
|
502
|
+
return_code = self._process.poll()
|
|
503
|
+
if return_code is None:
|
|
504
|
+
return None
|
|
505
|
+
|
|
506
|
+
if self._stopped_by_client:
|
|
507
|
+
return 0
|
|
508
|
+
|
|
509
|
+
return _normalize_ape_return_code(return_code)
|
|
510
|
+
|
|
511
|
+
@property
|
|
512
|
+
def stdout(self):
|
|
513
|
+
return self._process.stdout
|
|
514
|
+
|
|
515
|
+
@property
|
|
516
|
+
def stderr(self):
|
|
517
|
+
return self._process.stderr
|
|
518
|
+
|
|
519
|
+
@property
|
|
520
|
+
def result(self) -> Any:
|
|
521
|
+
return self._result
|
|
522
|
+
|
|
523
|
+
@property
|
|
524
|
+
def process_result(self) -> APEProcessResult:
|
|
525
|
+
if self._process_result is None:
|
|
526
|
+
raise RuntimeError('APE process has not completed')
|
|
527
|
+
|
|
528
|
+
return self._process_result
|
|
529
|
+
|
|
530
|
+
def set_result(self, result: Any) -> None:
|
|
531
|
+
self._result = result
|
|
532
|
+
|
|
533
|
+
def on_exit(self, listener: Callable[[], None]) -> ProcessSession:
|
|
534
|
+
with self._exit_lock:
|
|
535
|
+
if not self._exit_notified:
|
|
536
|
+
self._exit_listeners.append(listener)
|
|
537
|
+
return self
|
|
538
|
+
|
|
539
|
+
listener()
|
|
540
|
+
return self
|
|
541
|
+
|
|
542
|
+
def on(
|
|
543
|
+
self,
|
|
544
|
+
event_name: str,
|
|
545
|
+
listener: Callable[[dict[str, Any]], None],
|
|
546
|
+
) -> ProcessSession:
|
|
547
|
+
if self._event_channel is None:
|
|
548
|
+
raise RuntimeError('This operation does not define an event stream')
|
|
549
|
+
|
|
550
|
+
self._event_channel.on(event_name, listener)
|
|
551
|
+
return self
|
|
552
|
+
|
|
553
|
+
def events(self, timeout: float | None = None) -> Iterator[APEEvent]:
|
|
554
|
+
if self._event_channel is None:
|
|
555
|
+
raise RuntimeError('This operation does not define an event stream')
|
|
556
|
+
|
|
557
|
+
yield from self._event_channel.events(timeout=timeout)
|
|
558
|
+
|
|
559
|
+
def iter_stdout(self) -> Iterator[str]:
|
|
560
|
+
for chunk in self.iter_stdout_bytes():
|
|
561
|
+
yield chunk.decode('utf-8', errors='replace')
|
|
562
|
+
|
|
563
|
+
def iter_stdout_bytes(self) -> Iterator[bytes]:
|
|
564
|
+
if self._process.stdout is None:
|
|
565
|
+
self._streamed_stdout_consumed = True
|
|
566
|
+
return
|
|
567
|
+
|
|
568
|
+
if not self._capture_stdout:
|
|
569
|
+
with self._streamed_stdout_lock:
|
|
570
|
+
if self._streamed_stdout_iterating:
|
|
571
|
+
raise RuntimeError('streamed stdout can only be consumed once')
|
|
572
|
+
|
|
573
|
+
self._streamed_stdout_iterating = True
|
|
574
|
+
|
|
575
|
+
consumed = False
|
|
576
|
+
|
|
577
|
+
try:
|
|
578
|
+
while chunk := self._process.stdout.read(65536):
|
|
579
|
+
yield chunk
|
|
580
|
+
|
|
581
|
+
consumed = True
|
|
582
|
+
finally:
|
|
583
|
+
self._process.stdout.close()
|
|
584
|
+
|
|
585
|
+
with self._streamed_stdout_lock:
|
|
586
|
+
self._streamed_stdout_consumed = consumed
|
|
587
|
+
self._streamed_stdout_iterating = False
|
|
588
|
+
|
|
589
|
+
return
|
|
590
|
+
|
|
591
|
+
index = 0
|
|
592
|
+
while True:
|
|
593
|
+
with self._output_condition:
|
|
594
|
+
while index == len(self._stdout_chunks) and not self._stdout_finished:
|
|
595
|
+
self._output_condition.wait()
|
|
596
|
+
|
|
597
|
+
if index < len(self._stdout_chunks):
|
|
598
|
+
chunk = self._stdout_chunks[index]
|
|
599
|
+
index += 1
|
|
600
|
+
else:
|
|
601
|
+
if self._stdout_error is not None and not self._stopped_by_client:
|
|
602
|
+
raise self._stdout_error
|
|
603
|
+
return
|
|
604
|
+
|
|
605
|
+
yield chunk
|
|
606
|
+
|
|
607
|
+
def attach_stdin(self, source: Any) -> None:
|
|
608
|
+
self._stdin_source = source
|
|
609
|
+
self._stdin_thread = threading.Thread(
|
|
610
|
+
target=self._copy_stdin,
|
|
611
|
+
args=(source,),
|
|
612
|
+
name='apebind-stdin',
|
|
613
|
+
daemon=True,
|
|
614
|
+
)
|
|
615
|
+
self._stdin_thread.start()
|
|
616
|
+
|
|
617
|
+
def _copy_stdin(self, source: Any) -> None:
|
|
618
|
+
if self._process.stdin is None:
|
|
619
|
+
raise RuntimeError('APE stdin pipe was not created')
|
|
620
|
+
|
|
621
|
+
try:
|
|
622
|
+
while chunk := source.read(65536):
|
|
623
|
+
self._process.stdin.write(chunk)
|
|
624
|
+
|
|
625
|
+
self._process.stdin.flush()
|
|
626
|
+
except BrokenPipeError:
|
|
627
|
+
# The child can close stdin before it exits; the producer must not
|
|
628
|
+
# turn that normal process lifecycle into an unhandled failure.
|
|
629
|
+
pass
|
|
630
|
+
except OSError as error:
|
|
631
|
+
if self.running and not self._stopped_by_client:
|
|
632
|
+
self._stdin_error = error
|
|
633
|
+
self._process.terminate()
|
|
634
|
+
except BaseException as error:
|
|
635
|
+
if self.running and not self._stopped_by_client:
|
|
636
|
+
self._stdin_error = error
|
|
637
|
+
self._process.terminate()
|
|
638
|
+
finally:
|
|
639
|
+
if self._process.stdin is not None:
|
|
640
|
+
self._process.stdin.close()
|
|
641
|
+
|
|
642
|
+
def _close_stdin_source(self) -> None:
|
|
643
|
+
with self._stdin_lock:
|
|
644
|
+
source = self._stdin_source
|
|
645
|
+
self._stdin_source = None
|
|
646
|
+
|
|
647
|
+
if source is not None and hasattr(source, 'close'):
|
|
648
|
+
source.close()
|
|
649
|
+
|
|
650
|
+
def _wait_for_stdin(self) -> None:
|
|
651
|
+
if self._stdin_thread is not None:
|
|
652
|
+
self._stdin_thread.join()
|
|
653
|
+
|
|
654
|
+
if self._stdin_error is not None and not self._stopped_by_client:
|
|
655
|
+
raise self._stdin_error
|
|
656
|
+
|
|
657
|
+
def _wait_for_event_channel(self) -> None:
|
|
658
|
+
if self._event_channel is not None:
|
|
659
|
+
self._event_channel.wait()
|
|
660
|
+
|
|
661
|
+
def _wait_for_output(self, ignore_read_errors: bool) -> None:
|
|
662
|
+
for thread in (self._stdout_thread, self._stderr_thread):
|
|
663
|
+
if thread is not None:
|
|
664
|
+
thread.join()
|
|
665
|
+
|
|
666
|
+
if not ignore_read_errors:
|
|
667
|
+
if self._stdout_error is not None:
|
|
668
|
+
raise self._stdout_error
|
|
669
|
+
if self._stderr_error is not None:
|
|
670
|
+
raise self._stderr_error
|
|
671
|
+
|
|
672
|
+
def _captured_stdout(self) -> bytes:
|
|
673
|
+
if not self._capture_stdout:
|
|
674
|
+
return b''
|
|
675
|
+
|
|
676
|
+
with self._output_condition:
|
|
677
|
+
return b''.join(self._stdout_chunks)
|
|
678
|
+
|
|
679
|
+
def _captured_stderr(self) -> bytes:
|
|
680
|
+
with self._output_condition:
|
|
681
|
+
return b''.join(self._stderr_chunks)
|
|
682
|
+
|
|
683
|
+
def _wait(
|
|
684
|
+
self,
|
|
685
|
+
timeout: float | None = None,
|
|
686
|
+
*,
|
|
687
|
+
require_streamed_stdout: bool,
|
|
688
|
+
) -> int:
|
|
689
|
+
if not self._capture_stdout and not self._streamed_stdout_consumed:
|
|
690
|
+
if require_streamed_stdout:
|
|
691
|
+
raise RuntimeError(
|
|
692
|
+
'streamed stdout must be consumed before waiting for the process'
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
if self._process.stdout is not None and not self._process.stdout.closed:
|
|
696
|
+
self._process.stdout.close()
|
|
697
|
+
|
|
698
|
+
return_code = _normalize_ape_return_code(self._process.wait(timeout=timeout))
|
|
699
|
+
self._close_stdin_source()
|
|
700
|
+
self._wait_for_stdin()
|
|
701
|
+
self._wait_for_event_channel()
|
|
702
|
+
self._wait_for_output(self._stopped_by_client)
|
|
703
|
+
|
|
704
|
+
process_result = APEProcessResult(
|
|
705
|
+
self._argv,
|
|
706
|
+
return_code,
|
|
707
|
+
self._captured_stdout().decode('utf-8', errors='replace'),
|
|
708
|
+
self._captured_stderr().decode('utf-8', errors='replace'),
|
|
709
|
+
)
|
|
710
|
+
self._process_result = process_result
|
|
711
|
+
|
|
712
|
+
if return_code != 0 and not self._stopped_by_client:
|
|
713
|
+
raise APEProcessError(process_result)
|
|
714
|
+
|
|
715
|
+
return 0 if self._stopped_by_client else return_code
|
|
716
|
+
|
|
717
|
+
def wait(self, timeout: float | None = None) -> int:
|
|
718
|
+
return self._wait(timeout, require_streamed_stdout=True)
|
|
719
|
+
|
|
720
|
+
def _cleanup_runtime_files(self) -> None:
|
|
721
|
+
if self._runtime_files is None:
|
|
722
|
+
return
|
|
723
|
+
|
|
724
|
+
runtime_files = self._runtime_files
|
|
725
|
+
self._runtime_files = None
|
|
726
|
+
runtime_files.cleanup()
|
|
727
|
+
|
|
728
|
+
def stop(self, timeout: float = 5.0) -> None:
|
|
729
|
+
if not self.running:
|
|
730
|
+
try:
|
|
731
|
+
self._wait(require_streamed_stdout=False)
|
|
732
|
+
finally:
|
|
733
|
+
self._cleanup_runtime_files()
|
|
734
|
+
|
|
735
|
+
return
|
|
736
|
+
|
|
737
|
+
self._stopped_by_client = True
|
|
738
|
+
self._close_stdin_source()
|
|
739
|
+
self._process.terminate()
|
|
740
|
+
|
|
741
|
+
try:
|
|
742
|
+
self._process.wait(timeout=timeout)
|
|
743
|
+
except subprocess.TimeoutExpired:
|
|
744
|
+
self._process.kill()
|
|
745
|
+
self._process.wait()
|
|
746
|
+
|
|
747
|
+
try:
|
|
748
|
+
if (
|
|
749
|
+
not self._capture_stdout
|
|
750
|
+
and self._process.stdout is not None
|
|
751
|
+
and not self._process.stdout.closed
|
|
752
|
+
):
|
|
753
|
+
self._process.stdout.close()
|
|
754
|
+
|
|
755
|
+
self._wait_for_event_channel()
|
|
756
|
+
self._wait_for_output(True)
|
|
757
|
+
finally:
|
|
758
|
+
self._cleanup_runtime_files()
|
|
759
|
+
|
|
760
|
+
def close(self) -> None:
|
|
761
|
+
if self.running:
|
|
762
|
+
self.stop()
|
|
763
|
+
return
|
|
764
|
+
|
|
765
|
+
try:
|
|
766
|
+
self._wait(require_streamed_stdout=False)
|
|
767
|
+
finally:
|
|
768
|
+
self._cleanup_runtime_files()
|
|
769
|
+
|
|
770
|
+
def __enter__(self):
|
|
771
|
+
return self
|
|
772
|
+
|
|
773
|
+
def __exit__(self, exception_type, exception_value, traceback_value) -> None:
|
|
774
|
+
del exception_type, exception_value, traceback_value
|
|
775
|
+
self.close()
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
class APEClient:
|
|
779
|
+
def __init__(
|
|
780
|
+
self,
|
|
781
|
+
binary_path: Path,
|
|
782
|
+
operations: dict[str, dict[str, Any]],
|
|
783
|
+
runtime: dict[str, Any] | None = None,
|
|
784
|
+
):
|
|
785
|
+
self._binary_path = binary_path.resolve()
|
|
786
|
+
self._operations = operations
|
|
787
|
+
self._runtime = {} if runtime is None else runtime
|
|
788
|
+
|
|
789
|
+
@staticmethod
|
|
790
|
+
def _multiple_values(value: Any) -> Iterable[Any]:
|
|
791
|
+
if isinstance(value, (str, bytes, os.PathLike)):
|
|
792
|
+
return (value,)
|
|
793
|
+
|
|
794
|
+
if isinstance(value, Iterable):
|
|
795
|
+
return value
|
|
796
|
+
|
|
797
|
+
return (value,)
|
|
798
|
+
|
|
799
|
+
@classmethod
|
|
800
|
+
def _append_optional_value(
|
|
801
|
+
cls,
|
|
802
|
+
argv: list[str],
|
|
803
|
+
option: dict[str, Any],
|
|
804
|
+
value: Any,
|
|
805
|
+
) -> bool:
|
|
806
|
+
if not option['value_optional']:
|
|
807
|
+
return False
|
|
808
|
+
|
|
809
|
+
if value is None or value is False:
|
|
810
|
+
return True
|
|
811
|
+
|
|
812
|
+
argv.append(option['primary_flag'])
|
|
813
|
+
|
|
814
|
+
if value is True:
|
|
815
|
+
return True
|
|
816
|
+
|
|
817
|
+
argv.append(str(value))
|
|
818
|
+
return True
|
|
819
|
+
|
|
820
|
+
@staticmethod
|
|
821
|
+
def _runtime_path(
|
|
822
|
+
option: dict[str, Any],
|
|
823
|
+
runtime_files: _RuntimeFiles | None,
|
|
824
|
+
operation: dict[str, Any],
|
|
825
|
+
) -> tuple[str, Path]:
|
|
826
|
+
if runtime_files is None:
|
|
827
|
+
raise RuntimeError('Runtime-owned option has no runtime files')
|
|
828
|
+
|
|
829
|
+
role = option.get('runtime_role', 'result')
|
|
830
|
+
path = runtime_files.path(role)
|
|
831
|
+
if path is None:
|
|
832
|
+
raise RuntimeError(f'Runtime-owned option has no {role} path')
|
|
833
|
+
|
|
834
|
+
if role == 'events':
|
|
835
|
+
flag = operation['events']['source']['option_flag']
|
|
836
|
+
else:
|
|
837
|
+
flag = operation['result']['option_flag']
|
|
838
|
+
|
|
839
|
+
return flag, path
|
|
840
|
+
|
|
841
|
+
@classmethod
|
|
842
|
+
def _append_options(
|
|
843
|
+
cls,
|
|
844
|
+
argv: list[str],
|
|
845
|
+
options: list[dict[str, Any]],
|
|
846
|
+
values: dict[str, Any],
|
|
847
|
+
runtime_files: _RuntimeFiles | None,
|
|
848
|
+
operation: dict[str, Any],
|
|
849
|
+
) -> None:
|
|
850
|
+
for option in options:
|
|
851
|
+
if option['runtime_owned']:
|
|
852
|
+
flag, path = cls._runtime_path(option, runtime_files, operation)
|
|
853
|
+
argv.extend([flag, str(path)])
|
|
854
|
+
continue
|
|
855
|
+
|
|
856
|
+
value = values.get(option['api_name'])
|
|
857
|
+
if option['boolean']:
|
|
858
|
+
if value:
|
|
859
|
+
argv.append(option['primary_flag'])
|
|
860
|
+
|
|
861
|
+
continue
|
|
862
|
+
|
|
863
|
+
if cls._append_optional_value(argv, option, value):
|
|
864
|
+
continue
|
|
865
|
+
|
|
866
|
+
if value is None:
|
|
867
|
+
if option['required']:
|
|
868
|
+
raise TypeError(
|
|
869
|
+
f"Missing required argument: {option['api_name']}"
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
continue
|
|
873
|
+
|
|
874
|
+
if option['multiple']:
|
|
875
|
+
for item in cls._multiple_values(value):
|
|
876
|
+
argv.extend([option['primary_flag'], str(item)])
|
|
877
|
+
|
|
878
|
+
continue
|
|
879
|
+
|
|
880
|
+
argv.extend([option['primary_flag'], str(value)])
|
|
881
|
+
|
|
882
|
+
@classmethod
|
|
883
|
+
def _append_positionals(
|
|
884
|
+
cls,
|
|
885
|
+
argv: list[str],
|
|
886
|
+
positionals: list[dict[str, Any]],
|
|
887
|
+
values: dict[str, Any],
|
|
888
|
+
) -> None:
|
|
889
|
+
for positional in positionals:
|
|
890
|
+
value = values.get(positional['api_name'])
|
|
891
|
+
if value is None:
|
|
892
|
+
if positional['required']:
|
|
893
|
+
raise TypeError(
|
|
894
|
+
f"Missing required argument: {positional['api_name']}"
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
continue
|
|
898
|
+
|
|
899
|
+
if positional['multiple']:
|
|
900
|
+
argv.extend(str(item) for item in cls._multiple_values(value))
|
|
901
|
+
continue
|
|
902
|
+
|
|
903
|
+
argv.append(str(value))
|
|
904
|
+
|
|
905
|
+
def _build_argv(
|
|
906
|
+
self,
|
|
907
|
+
operation: dict[str, Any],
|
|
908
|
+
values: dict[str, Any],
|
|
909
|
+
runtime_files: _RuntimeFiles | None,
|
|
910
|
+
) -> list[str]:
|
|
911
|
+
argv: list[str] = []
|
|
912
|
+
|
|
913
|
+
for level_index, level in enumerate(operation['levels']):
|
|
914
|
+
if level_index > 0:
|
|
915
|
+
argv.append(level['token'])
|
|
916
|
+
|
|
917
|
+
self._append_options(
|
|
918
|
+
argv,
|
|
919
|
+
level['options'],
|
|
920
|
+
values,
|
|
921
|
+
runtime_files,
|
|
922
|
+
operation,
|
|
923
|
+
)
|
|
924
|
+
self._append_positionals(argv, level['positionals'], values)
|
|
925
|
+
|
|
926
|
+
return argv
|
|
927
|
+
|
|
928
|
+
def _environment(self) -> dict[str, str]:
|
|
929
|
+
environment = os.environ.copy()
|
|
930
|
+
|
|
931
|
+
for variable_name in self._runtime.get('env_unset', []):
|
|
932
|
+
environment.pop(variable_name, None)
|
|
933
|
+
|
|
934
|
+
return environment
|
|
935
|
+
|
|
936
|
+
def _launch_command(self, argv: list[str]) -> list[str]:
|
|
937
|
+
executable = str(self._binary_path)
|
|
938
|
+
if os.name == 'nt':
|
|
939
|
+
return [executable, *argv]
|
|
940
|
+
|
|
941
|
+
return ['/bin/sh', '-c', 'exec "$0" "$@"', executable, *argv]
|
|
942
|
+
|
|
943
|
+
def _run_command(
|
|
944
|
+
self,
|
|
945
|
+
argv: list[str],
|
|
946
|
+
timeout_seconds: float | None = None,
|
|
947
|
+
) -> APEProcessResult:
|
|
948
|
+
process = subprocess.run(
|
|
949
|
+
self._launch_command(argv),
|
|
950
|
+
capture_output=True,
|
|
951
|
+
text=True,
|
|
952
|
+
encoding='utf-8',
|
|
953
|
+
errors='replace',
|
|
954
|
+
check=False,
|
|
955
|
+
timeout=timeout_seconds,
|
|
956
|
+
env=self._environment(),
|
|
957
|
+
)
|
|
958
|
+
result = APEProcessResult(
|
|
959
|
+
tuple(argv),
|
|
960
|
+
_normalize_ape_return_code(process.returncode),
|
|
961
|
+
process.stdout,
|
|
962
|
+
process.stderr,
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
if result.return_code != 0:
|
|
966
|
+
raise APEProcessError(result)
|
|
967
|
+
|
|
968
|
+
return result
|
|
969
|
+
|
|
970
|
+
def _start_persistent(
|
|
971
|
+
self,
|
|
972
|
+
argv: list[str],
|
|
973
|
+
runtime_files: _RuntimeFiles | None,
|
|
974
|
+
event_config: dict[str, Any] | None = None,
|
|
975
|
+
stdin_source: Any = None,
|
|
976
|
+
capture_stdout: bool = True,
|
|
977
|
+
) -> ProcessSession:
|
|
978
|
+
process = subprocess.Popen(
|
|
979
|
+
self._launch_command(argv),
|
|
980
|
+
stdout=subprocess.PIPE,
|
|
981
|
+
stderr=subprocess.PIPE,
|
|
982
|
+
stdin=subprocess.PIPE if stdin_source is not None else None,
|
|
983
|
+
env=self._environment(),
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
session = ProcessSession(
|
|
987
|
+
process,
|
|
988
|
+
argv,
|
|
989
|
+
runtime_files,
|
|
990
|
+
event_config,
|
|
991
|
+
capture_stdout,
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
if stdin_source is not None:
|
|
995
|
+
session.attach_stdin(stdin_source)
|
|
996
|
+
|
|
997
|
+
return session
|
|
998
|
+
|
|
999
|
+
def _decode_result(
|
|
1000
|
+
self,
|
|
1001
|
+
result: APEProcessResult,
|
|
1002
|
+
result_path: Path | None,
|
|
1003
|
+
result_config: dict[str, Any],
|
|
1004
|
+
) -> Any:
|
|
1005
|
+
result_format = result_config['format']
|
|
1006
|
+
if result_format == 'command':
|
|
1007
|
+
return result
|
|
1008
|
+
|
|
1009
|
+
if result_format == 'text':
|
|
1010
|
+
return result.stdout.strip() if result_config['strip'] else result.stdout
|
|
1011
|
+
|
|
1012
|
+
if result_format == 'json':
|
|
1013
|
+
value = json.loads(result.stdout)
|
|
1014
|
+
return _extract_field(value, result_config['field'])
|
|
1015
|
+
|
|
1016
|
+
if result_format == 'json_file':
|
|
1017
|
+
if result_path is None or not result_path.exists():
|
|
1018
|
+
raise RuntimeError('APE did not create the configured JSON result file')
|
|
1019
|
+
|
|
1020
|
+
value = json.loads(result_path.read_text(encoding='utf-8'))
|
|
1021
|
+
return _extract_field(value, result_config['field'])
|
|
1022
|
+
|
|
1023
|
+
raise RuntimeError(f'Unsupported result format: {result_format}')
|
|
1024
|
+
|
|
1025
|
+
def _run_oneshot(
|
|
1026
|
+
self,
|
|
1027
|
+
argv: list[str],
|
|
1028
|
+
runtime_files: _RuntimeFiles | None,
|
|
1029
|
+
operation: dict[str, Any],
|
|
1030
|
+
) -> Any:
|
|
1031
|
+
result_path = None if runtime_files is None else runtime_files.path('result')
|
|
1032
|
+
|
|
1033
|
+
try:
|
|
1034
|
+
result = self._run_command(
|
|
1035
|
+
argv,
|
|
1036
|
+
operation['process']['timeout_seconds'],
|
|
1037
|
+
)
|
|
1038
|
+
return self._decode_result(result, result_path, operation['result'])
|
|
1039
|
+
finally:
|
|
1040
|
+
if runtime_files is not None:
|
|
1041
|
+
runtime_files.cleanup()
|
|
1042
|
+
|
|
1043
|
+
def _read_json_result(
|
|
1044
|
+
self,
|
|
1045
|
+
result_path: Path,
|
|
1046
|
+
result_config: dict[str, Any],
|
|
1047
|
+
) -> Any:
|
|
1048
|
+
value = json.loads(result_path.read_text(encoding='utf-8'))
|
|
1049
|
+
return _extract_field(value, result_config['field'])
|
|
1050
|
+
|
|
1051
|
+
def _wait_for_json_result(
|
|
1052
|
+
self,
|
|
1053
|
+
session: ProcessSession,
|
|
1054
|
+
result_path: Path | None,
|
|
1055
|
+
result_config: dict[str, Any],
|
|
1056
|
+
) -> Any:
|
|
1057
|
+
if result_path is None:
|
|
1058
|
+
raise RuntimeError('JSON result path was not created')
|
|
1059
|
+
|
|
1060
|
+
timeout = float(result_config['ready_timeout_seconds'])
|
|
1061
|
+
deadline = time.monotonic() + timeout
|
|
1062
|
+
last_size = -1
|
|
1063
|
+
stable_count = 0
|
|
1064
|
+
|
|
1065
|
+
while time.monotonic() < deadline:
|
|
1066
|
+
if result_path.exists():
|
|
1067
|
+
size = result_path.stat().st_size
|
|
1068
|
+
if size > 0 and size == last_size:
|
|
1069
|
+
stable_count += 1
|
|
1070
|
+
if stable_count >= 2:
|
|
1071
|
+
return self._read_json_result(result_path, result_config)
|
|
1072
|
+
else:
|
|
1073
|
+
stable_count = 0
|
|
1074
|
+
|
|
1075
|
+
last_size = size
|
|
1076
|
+
|
|
1077
|
+
if not session.running:
|
|
1078
|
+
session.wait()
|
|
1079
|
+
if result_path.exists() and result_path.stat().st_size > 0:
|
|
1080
|
+
return self._read_json_result(result_path, result_config)
|
|
1081
|
+
|
|
1082
|
+
raise RuntimeError('APE exited before producing the JSON result file')
|
|
1083
|
+
|
|
1084
|
+
time.sleep(0.02)
|
|
1085
|
+
|
|
1086
|
+
raise TimeoutError(f'JSON result was not ready within {timeout} seconds')
|
|
1087
|
+
|
|
1088
|
+
def invoke(self, operation_name: str, values: dict[str, Any]) -> Any:
|
|
1089
|
+
operation = self._operations[operation_name]
|
|
1090
|
+
result_config = operation['result']
|
|
1091
|
+
runtime_files = _RuntimeFiles.create(operation)
|
|
1092
|
+
|
|
1093
|
+
try:
|
|
1094
|
+
argv = self._build_argv(operation, values, runtime_files)
|
|
1095
|
+
except Exception:
|
|
1096
|
+
if runtime_files is not None:
|
|
1097
|
+
runtime_files.cleanup()
|
|
1098
|
+
|
|
1099
|
+
raise
|
|
1100
|
+
|
|
1101
|
+
if operation['execution'] == 'persistent':
|
|
1102
|
+
session = self._start_persistent(
|
|
1103
|
+
argv,
|
|
1104
|
+
runtime_files,
|
|
1105
|
+
operation.get('events'),
|
|
1106
|
+
values.get('stdin'),
|
|
1107
|
+
values.get('_apebind_capture_stdout', True),
|
|
1108
|
+
)
|
|
1109
|
+
result_path = None if runtime_files is None else runtime_files.path('result')
|
|
1110
|
+
|
|
1111
|
+
try:
|
|
1112
|
+
if (
|
|
1113
|
+
result_config['format'] == 'json_file'
|
|
1114
|
+
and result_config['ready_timeout_seconds'] is not None
|
|
1115
|
+
):
|
|
1116
|
+
session.set_result(
|
|
1117
|
+
self._wait_for_json_result(session, result_path, result_config)
|
|
1118
|
+
)
|
|
1119
|
+
except Exception:
|
|
1120
|
+
if session.running:
|
|
1121
|
+
session.stop()
|
|
1122
|
+
else:
|
|
1123
|
+
session._cleanup_runtime_files()
|
|
1124
|
+
|
|
1125
|
+
raise
|
|
1126
|
+
|
|
1127
|
+
return session
|
|
1128
|
+
|
|
1129
|
+
return self._run_oneshot(argv, runtime_files, operation)
|
|
1130
|
+
|
|
1131
|
+
def raw(self, arguments: list[str], persistent: bool = False):
|
|
1132
|
+
argv = [str(value) for value in arguments]
|
|
1133
|
+
if persistent:
|
|
1134
|
+
return self._start_persistent(argv, None)
|
|
1135
|
+
|
|
1136
|
+
return self._run_command(argv)
|