bazaar-compute-node 0.1.3__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.
Files changed (62) hide show
  1. bazaar_compute_node/__init__.py +3 -0
  2. bazaar_compute_node/app/__init__.py +1 -0
  3. bazaar_compute_node/app/application.py +398 -0
  4. bazaar_compute_node/app/attachments.py +154 -0
  5. bazaar_compute_node/app/command.py +342 -0
  6. bazaar_compute_node/app/config.py +121 -0
  7. bazaar_compute_node/app/registry.py +120 -0
  8. bazaar_compute_node/app/transport.py +264 -0
  9. bazaar_compute_node/app/windows_pipe.py +463 -0
  10. bazaar_compute_node/app/wrapper.py +63 -0
  11. bazaar_compute_node/bcc.py +524 -0
  12. bazaar_compute_node/cli.py +382 -0
  13. bazaar_compute_node/contrib/__init__.py +1 -0
  14. bazaar_compute_node/contrib/codex_app_server/__init__.py +63 -0
  15. bazaar_compute_node/contrib/codex_app_server/approval.py +168 -0
  16. bazaar_compute_node/contrib/codex_app_server/client.py +408 -0
  17. bazaar_compute_node/contrib/codex_app_server/events.py +431 -0
  18. bazaar_compute_node/contrib/codex_app_server/plugin.py +15 -0
  19. bazaar_compute_node/contrib/codex_app_server/process.py +583 -0
  20. bazaar_compute_node/contrib/codex_app_server/protocol.py +103 -0
  21. bazaar_compute_node/contrib/codex_app_server/runtime.py +513 -0
  22. bazaar_compute_node/contrib/logging/__init__.py +5 -0
  23. bazaar_compute_node/contrib/logging/audit.py +61 -0
  24. bazaar_compute_node/contrib/logging/plugin.py +11 -0
  25. bazaar_compute_node/contrib/sqlite/__init__.py +14 -0
  26. bazaar_compute_node/contrib/sqlite/codec.py +768 -0
  27. bazaar_compute_node/contrib/sqlite/database.py +282 -0
  28. bazaar_compute_node/contrib/sqlite/migrations.py +646 -0
  29. bazaar_compute_node/contrib/sqlite/plugin.py +11 -0
  30. bazaar_compute_node/contrib/sqlite/repository.py +1059 -0
  31. bazaar_compute_node/contrib/wecom/__init__.py +1 -0
  32. bazaar_compute_node/contrib/wecom/channel.py +960 -0
  33. bazaar_compute_node/contrib/wecom/markdown.py +146 -0
  34. bazaar_compute_node/contrib/wecom/plugin.py +29 -0
  35. bazaar_compute_node/core/__init__.py +5 -0
  36. bazaar_compute_node/core/approval.py +51 -0
  37. bazaar_compute_node/core/audit.py +101 -0
  38. bazaar_compute_node/core/channel.py +121 -0
  39. bazaar_compute_node/core/client.py +30 -0
  40. bazaar_compute_node/core/command.py +85 -0
  41. bazaar_compute_node/core/concurrency.py +29 -0
  42. bazaar_compute_node/core/correlation.py +48 -0
  43. bazaar_compute_node/core/instruction.py +224 -0
  44. bazaar_compute_node/core/lifecycle.py +48 -0
  45. bazaar_compute_node/core/models/__init__.py +63 -0
  46. bazaar_compute_node/core/models/entities.py +514 -0
  47. bazaar_compute_node/core/models/states.py +369 -0
  48. bazaar_compute_node/core/observability.py +47 -0
  49. bazaar_compute_node/core/orchestration/__init__.py +5 -0
  50. bazaar_compute_node/core/orchestration/command.py +614 -0
  51. bazaar_compute_node/core/orchestration/services.py +135 -0
  52. bazaar_compute_node/core/orchestration/session.py +891 -0
  53. bazaar_compute_node/core/orchestration/turn.py +451 -0
  54. bazaar_compute_node/core/outcomes.py +51 -0
  55. bazaar_compute_node/core/paths.py +19 -0
  56. bazaar_compute_node/core/runtime.py +118 -0
  57. bazaar_compute_node/core/storage.py +167 -0
  58. bazaar_compute_node-0.1.3.dist-info/METADATA +178 -0
  59. bazaar_compute_node-0.1.3.dist-info/RECORD +62 -0
  60. bazaar_compute_node-0.1.3.dist-info/WHEEL +4 -0
  61. bazaar_compute_node-0.1.3.dist-info/entry_points.txt +15 -0
  62. bazaar_compute_node-0.1.3.dist-info/licenses/LICENSE +613 -0
@@ -0,0 +1,463 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import ctypes
5
+ import hashlib
6
+ import json
7
+ import threading
8
+ import time
9
+ from collections.abc import Awaitable, Callable, Mapping
10
+ from ctypes import wintypes
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from urllib.parse import urlsplit
14
+
15
+ RequestHandler = Callable[[Mapping[str, object]], Awaitable[Mapping[str, object]]]
16
+
17
+ _ERROR_ALREADY_EXISTS = 183
18
+ _ERROR_BROKEN_PIPE = 109
19
+ _ERROR_FILE_NOT_FOUND = 2
20
+ _ERROR_PIPE_BUSY = 231
21
+ _ERROR_PIPE_CONNECTED = 535
22
+ _GENERIC_READ = 0x80000000
23
+ _GENERIC_WRITE = 0x40000000
24
+ _OPEN_EXISTING = 3
25
+ _FILE_ATTRIBUTE_NORMAL = 0x80
26
+ _PIPE_ACCESS_DUPLEX = 0x00000003
27
+ _PIPE_TYPE_BYTE = 0x00000000
28
+ _PIPE_READMODE_BYTE = 0x00000000
29
+ _PIPE_WAIT = 0x00000000
30
+ _PIPE_REJECT_REMOTE_CLIENTS = 0x00000008
31
+ _PIPE_UNLIMITED_INSTANCES = 255
32
+ _PIPE_BUFFER_SIZE = 64 * 1024
33
+ _MAX_MESSAGE_SIZE = 1024 * 1024
34
+ _INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
35
+
36
+ _WinDLL = getattr(ctypes, "WinDLL") # noqa: B009
37
+ _get_last_error = getattr(ctypes, "get_last_error") # noqa: B009
38
+ _set_last_error = getattr(ctypes, "set_last_error") # noqa: B009
39
+ _kernel32 = _WinDLL("kernel32", use_last_error=True)
40
+
41
+ _CloseHandle = _kernel32.CloseHandle
42
+ _CloseHandle.argtypes = (wintypes.HANDLE,)
43
+ _CloseHandle.restype = wintypes.BOOL
44
+
45
+ _ConnectNamedPipe = _kernel32.ConnectNamedPipe
46
+ _ConnectNamedPipe.argtypes = (wintypes.HANDLE, ctypes.c_void_p)
47
+ _ConnectNamedPipe.restype = wintypes.BOOL
48
+
49
+ _CreateFileW = _kernel32.CreateFileW
50
+ _CreateFileW.argtypes = (
51
+ wintypes.LPCWSTR,
52
+ wintypes.DWORD,
53
+ wintypes.DWORD,
54
+ ctypes.c_void_p,
55
+ wintypes.DWORD,
56
+ wintypes.DWORD,
57
+ wintypes.HANDLE,
58
+ )
59
+ _CreateFileW.restype = wintypes.HANDLE
60
+
61
+ _CreateMutexW = _kernel32.CreateMutexW
62
+ _CreateMutexW.argtypes = (ctypes.c_void_p, wintypes.BOOL, wintypes.LPCWSTR)
63
+ _CreateMutexW.restype = wintypes.HANDLE
64
+
65
+ _CreateNamedPipeW = _kernel32.CreateNamedPipeW
66
+ _CreateNamedPipeW.argtypes = (
67
+ wintypes.LPCWSTR,
68
+ wintypes.DWORD,
69
+ wintypes.DWORD,
70
+ wintypes.DWORD,
71
+ wintypes.DWORD,
72
+ wintypes.DWORD,
73
+ wintypes.DWORD,
74
+ ctypes.c_void_p,
75
+ )
76
+ _CreateNamedPipeW.restype = wintypes.HANDLE
77
+
78
+ _DisconnectNamedPipe = _kernel32.DisconnectNamedPipe
79
+ _DisconnectNamedPipe.argtypes = (wintypes.HANDLE,)
80
+ _DisconnectNamedPipe.restype = wintypes.BOOL
81
+
82
+ _FlushFileBuffers = _kernel32.FlushFileBuffers
83
+ _FlushFileBuffers.argtypes = (wintypes.HANDLE,)
84
+ _FlushFileBuffers.restype = wintypes.BOOL
85
+
86
+ _ReadFile = _kernel32.ReadFile
87
+ _ReadFile.argtypes = (
88
+ wintypes.HANDLE,
89
+ ctypes.c_void_p,
90
+ wintypes.DWORD,
91
+ ctypes.POINTER(wintypes.DWORD),
92
+ ctypes.c_void_p,
93
+ )
94
+ _ReadFile.restype = wintypes.BOOL
95
+
96
+ _ReleaseMutex = _kernel32.ReleaseMutex
97
+ _ReleaseMutex.argtypes = (wintypes.HANDLE,)
98
+ _ReleaseMutex.restype = wintypes.BOOL
99
+
100
+ _WaitNamedPipeW = _kernel32.WaitNamedPipeW
101
+ _WaitNamedPipeW.argtypes = (wintypes.LPCWSTR, wintypes.DWORD)
102
+ _WaitNamedPipeW.restype = wintypes.BOOL
103
+
104
+ _WriteFile = _kernel32.WriteFile
105
+ _WriteFile.argtypes = (
106
+ wintypes.HANDLE,
107
+ ctypes.c_void_p,
108
+ wintypes.DWORD,
109
+ ctypes.POINTER(wintypes.DWORD),
110
+ ctypes.c_void_p,
111
+ )
112
+ _WriteFile.restype = wintypes.BOOL
113
+
114
+
115
+ def _raise_last_error(message: str) -> None:
116
+ error_code = _get_last_error()
117
+ raise OSError(error_code, f"{message} (WinError {error_code})")
118
+
119
+
120
+ def _is_invalid_handle(handle: Any) -> bool:
121
+ return handle is None or handle == _INVALID_HANDLE_VALUE
122
+
123
+
124
+ def _pipe_name(endpoint_path: Path | None) -> str:
125
+ path = endpoint_path or (Path.home() / ".bcn" / "bcn.sock")
126
+ identity = str(path.expanduser().resolve(strict=False)).casefold()
127
+ digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:32]
128
+ return f"bcn-{digest}"
129
+
130
+
131
+ def named_pipe_endpoint(endpoint_path: Path | None) -> str:
132
+ return f"pipe://{_pipe_name(endpoint_path)}"
133
+
134
+
135
+ def _pipe_path(pipe_name: str) -> str:
136
+ return rf"\\.\pipe\{pipe_name}"
137
+
138
+
139
+ def _mutex_name(pipe_name: str) -> str:
140
+ return rf"Local\{pipe_name}-mutex"
141
+
142
+
143
+ def _create_mutex(pipe_name: str) -> Any:
144
+ _set_last_error(0)
145
+ handle = _CreateMutexW(None, True, _mutex_name(pipe_name))
146
+ if _is_invalid_handle(handle):
147
+ _raise_last_error("CreateMutexW failed")
148
+ if _get_last_error() == _ERROR_ALREADY_EXISTS:
149
+ _CloseHandle(handle)
150
+ raise FileExistsError(f"Windows named mutex already exists: {pipe_name}")
151
+ return handle
152
+
153
+
154
+ def _release_mutex(handle: Any) -> None:
155
+ if _is_invalid_handle(handle):
156
+ return
157
+ _ReleaseMutex(handle)
158
+ _CloseHandle(handle)
159
+
160
+
161
+ def _create_named_pipe(pipe_name: str) -> Any:
162
+ handle = _CreateNamedPipeW(
163
+ _pipe_path(pipe_name),
164
+ _PIPE_ACCESS_DUPLEX,
165
+ _PIPE_TYPE_BYTE
166
+ | _PIPE_READMODE_BYTE
167
+ | _PIPE_WAIT
168
+ | _PIPE_REJECT_REMOTE_CLIENTS,
169
+ _PIPE_UNLIMITED_INSTANCES,
170
+ _PIPE_BUFFER_SIZE,
171
+ _PIPE_BUFFER_SIZE,
172
+ 0,
173
+ None,
174
+ )
175
+ if _is_invalid_handle(handle):
176
+ _raise_last_error("CreateNamedPipeW failed")
177
+ return handle
178
+
179
+
180
+ def _open_named_pipe(pipe_name: str) -> Any:
181
+ path = _pipe_path(pipe_name)
182
+ for _ in range(20):
183
+ handle = _CreateFileW(
184
+ path,
185
+ _GENERIC_READ | _GENERIC_WRITE,
186
+ 0,
187
+ None,
188
+ _OPEN_EXISTING,
189
+ _FILE_ATTRIBUTE_NORMAL,
190
+ None,
191
+ )
192
+ if not _is_invalid_handle(handle):
193
+ return handle
194
+ error_code = _get_last_error()
195
+ if error_code != _ERROR_PIPE_BUSY:
196
+ if error_code == _ERROR_FILE_NOT_FOUND:
197
+ raise FileNotFoundError(path)
198
+ _raise_last_error("CreateFileW for named pipe failed")
199
+ if not _WaitNamedPipeW(path, 100):
200
+ _raise_last_error("WaitNamedPipeW failed")
201
+ raise TimeoutError(f"named pipe did not become available: {path}")
202
+
203
+
204
+ def _read_message(handle: Any) -> bytes:
205
+ chunks: list[bytes] = []
206
+ size = 0
207
+ while size <= _MAX_MESSAGE_SIZE:
208
+ buffer = ctypes.create_string_buffer(4096)
209
+ count = wintypes.DWORD()
210
+ if not _ReadFile(handle, buffer, len(buffer), ctypes.byref(count), None):
211
+ error_code = _get_last_error()
212
+ if error_code == _ERROR_BROKEN_PIPE:
213
+ return b""
214
+ _raise_last_error("ReadFile from named pipe failed")
215
+ if count.value == 0:
216
+ return b""
217
+ chunk = buffer.raw[: count.value]
218
+ chunks.append(chunk)
219
+ size += len(chunk)
220
+ payload = b"".join(chunks)
221
+ line_end = payload.find(b"\n")
222
+ if line_end >= 0:
223
+ return payload[:line_end]
224
+ raise ValueError("named pipe request is too large")
225
+
226
+
227
+ def _write_message(handle: Any, payload: bytes) -> None:
228
+ offset = 0
229
+ while offset < len(payload):
230
+ buffer = ctypes.create_string_buffer(payload[offset:])
231
+ count = wintypes.DWORD()
232
+ if not _WriteFile(
233
+ handle,
234
+ buffer,
235
+ len(payload) - offset,
236
+ ctypes.byref(count),
237
+ None,
238
+ ):
239
+ _raise_last_error("WriteFile to named pipe failed")
240
+ if count.value == 0:
241
+ raise ConnectionError("named pipe accepted no response bytes")
242
+ offset += count.value
243
+
244
+
245
+ def request_named_pipe(
246
+ endpoint: str, payload: Mapping[str, object]
247
+ ) -> Mapping[str, object]:
248
+ parsed = urlsplit(endpoint)
249
+ if (
250
+ parsed.scheme != "pipe"
251
+ or not parsed.netloc
252
+ or parsed.path
253
+ or parsed.query
254
+ or parsed.fragment
255
+ ):
256
+ raise ValueError(f"invalid Windows named pipe endpoint: {endpoint}")
257
+ handle = _open_named_pipe(parsed.netloc)
258
+ try:
259
+ request = (
260
+ json.dumps(
261
+ dict(payload), ensure_ascii=False, separators=(",", ":")
262
+ ).encode()
263
+ + b"\n"
264
+ )
265
+ _write_message(handle, request)
266
+ response_line = _read_message(handle)
267
+ if not response_line:
268
+ raise ConnectionError("named pipe closed without a response")
269
+ response = json.loads(response_line)
270
+ if not isinstance(response, dict):
271
+ raise TypeError("named pipe response must be a JSON object")
272
+ return response
273
+ finally:
274
+ _CloseHandle(handle)
275
+
276
+
277
+ def _wake_named_pipe(pipe_name: str) -> None:
278
+ try:
279
+ handle = _open_named_pipe(pipe_name)
280
+ except FileNotFoundError, OSError, TimeoutError:
281
+ return
282
+ _CloseHandle(handle)
283
+
284
+
285
+ class WindowsNamedPipeServer:
286
+ """Run one JSONL request handler over a per-user named pipe."""
287
+
288
+ def __init__(
289
+ self,
290
+ handler: RequestHandler,
291
+ *,
292
+ endpoint_path: Path | None,
293
+ ) -> None:
294
+ self._handler = handler
295
+ self._pipe_name = _pipe_name(endpoint_path)
296
+ self._endpoint = named_pipe_endpoint(endpoint_path)
297
+ self._loop: asyncio.AbstractEventLoop | None = None
298
+ self._mutex: Any = None
299
+ self._stop = threading.Event()
300
+ self._ready = threading.Event()
301
+ self._startup_errors: list[BaseException] = []
302
+ self._thread: threading.Thread | None = None
303
+ self._workers: set[threading.Thread] = set()
304
+ self._workers_lock = threading.Lock()
305
+
306
+ @property
307
+ def endpoint(self) -> str:
308
+ return self._endpoint
309
+
310
+ async def start(self) -> None:
311
+ if self._thread is not None:
312
+ return
313
+ self._mutex = _create_mutex(self._pipe_name)
314
+ self._loop = asyncio.get_running_loop()
315
+ self._stop.clear()
316
+ self._ready.clear()
317
+ self._startup_errors.clear()
318
+ self._thread = threading.Thread(
319
+ target=self._serve,
320
+ name="bcn-windows-named-pipe",
321
+ daemon=True,
322
+ )
323
+ self._thread.start()
324
+ ready = await asyncio.to_thread(self._ready.wait, 5)
325
+ if not ready or self._startup_errors:
326
+ error = self._startup_errors[0] if self._startup_errors else None
327
+ await self.stop()
328
+ if error is not None:
329
+ raise RuntimeError("Windows named pipe server failed") from error
330
+ raise TimeoutError("Windows named pipe server did not become ready")
331
+
332
+ async def stop(self) -> None:
333
+ thread = self._thread
334
+ if thread is None:
335
+ _release_mutex(self._mutex)
336
+ self._mutex = None
337
+ return
338
+ self._stop.set()
339
+ await asyncio.to_thread(_wake_named_pipe, self._pipe_name)
340
+ await asyncio.to_thread(thread.join, 5)
341
+ await asyncio.to_thread(self._join_workers, 5)
342
+ self._thread = None
343
+ self._loop = None
344
+ _release_mutex(self._mutex)
345
+ self._mutex = None
346
+ if thread.is_alive() or self._live_workers():
347
+ raise TimeoutError("Windows named pipe server did not stop")
348
+
349
+ def _serve(self) -> None:
350
+ try:
351
+ self._serve_loop()
352
+ except Exception as error: # noqa: BLE001
353
+ self._startup_errors.append(error)
354
+ self._ready.set()
355
+
356
+ def _serve_loop(self) -> None:
357
+ initialized = False
358
+ while not self._stop.is_set():
359
+ handle = _create_named_pipe(self._pipe_name)
360
+ if not initialized:
361
+ initialized = True
362
+ self._ready.set()
363
+ connected = _ConnectNamedPipe(handle, None)
364
+ if not connected and _get_last_error() != _ERROR_PIPE_CONNECTED:
365
+ if self._stop.is_set():
366
+ _DisconnectNamedPipe(handle)
367
+ _CloseHandle(handle)
368
+ return
369
+ _DisconnectNamedPipe(handle)
370
+ _CloseHandle(handle)
371
+ _raise_last_error("ConnectNamedPipe failed")
372
+ if self._stop.is_set():
373
+ _DisconnectNamedPipe(handle)
374
+ _CloseHandle(handle)
375
+ return
376
+ worker = threading.Thread(
377
+ target=self._serve_client_worker,
378
+ args=(handle,),
379
+ name="bcn-windows-named-pipe-client",
380
+ daemon=True,
381
+ )
382
+ with self._workers_lock:
383
+ self._workers.add(worker)
384
+ try:
385
+ worker.start()
386
+ except BaseException:
387
+ with self._workers_lock:
388
+ self._workers.discard(worker)
389
+ _DisconnectNamedPipe(handle)
390
+ _CloseHandle(handle)
391
+ raise
392
+
393
+ def _serve_client_worker(self, handle: Any) -> None:
394
+ try:
395
+ self._serve_client(handle)
396
+ except Exception: # noqa: BLE001
397
+ return
398
+ finally:
399
+ _DisconnectNamedPipe(handle)
400
+ _CloseHandle(handle)
401
+ with self._workers_lock:
402
+ self._workers.discard(threading.current_thread())
403
+
404
+ def _live_workers(self) -> tuple[threading.Thread, ...]:
405
+ with self._workers_lock:
406
+ return tuple(worker for worker in self._workers if worker.is_alive())
407
+
408
+ def _join_workers(self, timeout: float) -> None:
409
+ deadline = time.monotonic() + timeout
410
+ for worker in self._live_workers():
411
+ remaining = deadline - time.monotonic()
412
+ if remaining <= 0:
413
+ break
414
+ worker.join(remaining)
415
+
416
+ def _serve_client(self, handle: Any) -> None:
417
+ raw_request = _read_message(handle)
418
+ if not raw_request:
419
+ return
420
+ try:
421
+ payload = json.loads(raw_request)
422
+ if not isinstance(payload, dict):
423
+ raise TypeError("request must be a JSON object")
424
+ loop = self._loop
425
+ if loop is None:
426
+ raise RuntimeError("named pipe server event loop is unavailable")
427
+
428
+ async def invoke_handler() -> Mapping[str, object]:
429
+ return await self._handler(payload)
430
+
431
+ future = asyncio.run_coroutine_threadsafe(invoke_handler(), loop)
432
+ response: Mapping[str, object] = future.result()
433
+ except json.JSONDecodeError as error:
434
+ response = {
435
+ "ok": False,
436
+ "code": "INVALID_REQUEST",
437
+ "error": f"invalid JSON request: {error.msg}",
438
+ }
439
+ except ValueError as error:
440
+ response = {
441
+ "ok": False,
442
+ "code": "INVALID_REQUEST",
443
+ "error": str(error),
444
+ }
445
+ except Exception as error: # noqa: BLE001
446
+ response = {
447
+ "ok": False,
448
+ "code": "COMMAND_FAILED",
449
+ "error": str(error),
450
+ }
451
+ encoded_response = (
452
+ json.dumps(response, ensure_ascii=False, separators=(",", ":")).encode()
453
+ + b"\n"
454
+ )
455
+ _write_message(handle, encoded_response)
456
+ _FlushFileBuffers(handle)
457
+
458
+
459
+ __all__ = [
460
+ "WindowsNamedPipeServer",
461
+ "named_pipe_endpoint",
462
+ "request_named_pipe",
463
+ ]
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shlex
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ def _wrapper_paths(command_path: Path) -> tuple[Path, ...]:
10
+ if command_path.name == "bcc":
11
+ return (command_path,)
12
+ if command_path.name == "bcc.cmd":
13
+ return (command_path, command_path.with_name("bcc.ps1"))
14
+ raise ValueError(f"unsupported bcc wrapper path: {command_path}")
15
+
16
+
17
+ def install_bcc_wrapper(bin_dir: Path) -> Path:
18
+ """Install the development wrapper and return the executable path."""
19
+
20
+ bin_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
21
+ if os.name != "nt":
22
+ os.chmod(bin_dir, 0o700)
23
+ python_executable = Path(sys.executable)
24
+ if os.name == "nt":
25
+ command_path = bin_dir / "bcc.cmd"
26
+ try:
27
+ command_path.write_text(
28
+ f'@echo off\r\n"{python_executable}" -m bazaar_compute_node.bcc %*\r\n',
29
+ encoding="utf-8",
30
+ )
31
+ (bin_dir / "bcc.ps1").write_text(
32
+ f'& "{python_executable}" -m bazaar_compute_node.bcc @args\n'
33
+ "exit $LASTEXITCODE\n",
34
+ encoding="utf-8",
35
+ )
36
+ except BaseException:
37
+ remove_bcc_wrapper(command_path)
38
+ raise
39
+ return command_path
40
+
41
+ command_path = bin_dir / "bcc"
42
+ try:
43
+ command_path.write_text(
44
+ "#!/bin/sh\n"
45
+ f"exec {shlex.quote(str(python_executable))} "
46
+ '-m bazaar_compute_node.bcc "$@"\n',
47
+ encoding="utf-8",
48
+ )
49
+ os.chmod(command_path, 0o700)
50
+ except BaseException:
51
+ remove_bcc_wrapper(command_path)
52
+ raise
53
+ return command_path
54
+
55
+
56
+ def remove_bcc_wrapper(command_path: Path) -> None:
57
+ """Remove only the wrapper files generated for one node instance."""
58
+
59
+ for path in _wrapper_paths(command_path):
60
+ try:
61
+ path.unlink()
62
+ except FileNotFoundError:
63
+ pass