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/_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)