From 5b40f662146e9962d61a4ee40fe7cf76a0680d91 Mon Sep 17 00:00:00 2001 From: derechopiano <257026466+derechopiano@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:11:16 +0100 Subject: [PATCH 1/2] 1566: add optional workbook-project role to craft-python --- .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 2 +- README.md | 45 ++++ craftpython/app.py | 4 +- craftpython/macro_worker.py | 91 ++++++++ craftpython/process_tree.py | 84 +++++++ craftpython/workbook_macros.py | 51 +++++ craftpython/workbook_projects.py | 324 +++++++++++++++++++++++++++ main.py | 6 +- plugin-macros.json | 36 +++ tests/test_workbook_macro_runtime.py | 263 ++++++++++++++++++++++ tests/test_workbook_macros.py | 73 ++++++ 12 files changed, 978 insertions(+), 5 deletions(-) create mode 100644 craftpython/macro_worker.py create mode 100644 craftpython/process_tree.py create mode 100644 craftpython/workbook_macros.py create mode 100644 craftpython/workbook_projects.py create mode 100644 plugin-macros.json create mode 100644 tests/test_workbook_macro_runtime.py create mode 100644 tests/test_workbook_macros.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c67b13e..d6eb559 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,9 @@ jobs: with: python-version: ${{ matrix.python }} - name: plugin.json is JSON - run: python -m json.tool plugin.json >/dev/null + run: | + python -m json.tool plugin.json >/dev/null + python -m json.tool plugin-macros.json >/dev/null - name: The versions agree run: | python - <<'PY' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1c053d..1578cec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: exit 1 fi mkdir -p dist/craft-python/craftpython - cp plugin.json main.py README.md LICENSE dist/craft-python/ + cp plugin.json plugin-macros.json main.py README.md LICENSE dist/craft-python/ cp craftpython/*.py dist/craft-python/craftpython/ (cd dist && zip -qr "craft-python-$version.zip" craft-python) - name: Publish diff --git a/README.md b/README.md index 5061e62..d1c91e1 100644 --- a/README.md +++ b/README.md @@ -357,3 +357,48 @@ apps' bridges, with the trust flow, time limits and malformed input), and ## Licence MIT + + +## Experimental workbook-project role (GridCraft) + +This optional role adds the desktop workbook-project portion of +[GridCraft #1566](https://github.com/storytold/gridcraft/issues/1566). It builds on +craft-python 0.3.0 and preserves Run Script, Console, Run File, PY.EVAL, helpers, +trust handling and WordCraft/DeckCraft support. It requires the companion host +extension on top of [#1123](https://github.com/storytold/gridcraft/pull/1123). +The ordinary released host does not support its proposed capabilities yet. + +Load `plugin-macros.json` explicitly, alongside the ordinary `plugin.json` when +PY.* formulas are needed. Set `run.command` to the installed Python executable +if `python3` is unavailable. Optional arguments are `--uv-executable`, +`--python-interpreter` and `--macro-cache`. The separate role ID does not register +duplicate functions or ribbon commands. Both modes use the existing App/Channel; +only `--workbook-macros` imports the macro runtime. + +The shared host owns the editor, document trust, source persistence and one-step +undo/rollback. This role implements the existing `macros/*` methods for validated +multi-module projects. Discovery uses AST parsing without executing source. A +macro is a public top-level synchronous function with no arguments, addressed as +`analysis.run`. It can import other project modules and use +`import gridcraft as gc; wb = gc.active_workbook()` for `wb.read`, `wb.cell`, +`wb.inspect`, literal `wb.write` and explicit `wb.execute` calls. + +uv prepares the project's PEP 723 environment offline by default. The host's +Python Settings can explicitly approve downloads for the current document and +exact metadata, separately from workbook contents. Source builds and unrelated +uv configuration remain disabled. Ordinary craft-python never starts this worker. +A successful worker stays warm for the same document/source, retires after 60 +idle seconds, and restarts after changes or errors. Startup/run have an absolute +30-second budget, messages a 16 MiB limit, diagnostics an 8 KiB limit. Windows +kill-on-close jobs and POSIX process groups own worker descendants; specific +POSIX descendant verification remains outstanding. These are trusted programs +with the user's OS permissions, not a sandbox. + +The host supplies Run/Stop and restores workbook changes on failure; external +Python side effects cannot be rolled back. Form controls, Pyodide, richer output +and language tooling remain future phases. This is not a replacement for the +existing scripting UI, Excel's Python formulas, or the Python-in-cells PR #469. + +Run the full provider suite with `python -m unittest discover -s tests -v`. +Native host integration in the companion GridCraft branch additionally tests +real uv startup, warm reuse, Stop-after-write rollback and PY.TWICE recalculation. diff --git a/craftpython/app.py b/craftpython/app.py index 8625662..232f070 100644 --- a/craftpython/app.py +++ b/craftpython/app.py @@ -112,9 +112,9 @@ def answer(self, msg): self.channel.send(reply) -def main(): +def main(app_factory=App): runner.install() - app = App(Channel()) + app = app_factory(Channel()) while not app.channel.shutdown: try: msg = app.channel.read() diff --git a/craftpython/macro_worker.py b/craftpython/macro_worker.py new file mode 100644 index 0000000..7ad9ade --- /dev/null +++ b/craftpython/macro_worker.py @@ -0,0 +1,91 @@ +"""Private, reusable Python worker. No workbook source executes before a run request.""" + +import ast as _ast +import contextlib as _contextlib +import importlib as _importlib +import inspect as _inspect +import json as _json +import sys as _sys +import traceback as _traceback +import types as _types + +_wire_in, _wire_out = _sys.stdin, _sys.stdout +_MAX = 16 * 1024 * 1024 + +def _send(message): + data = _json.dumps(message, allow_nan=False, separators=(',', ':')) + if len(data.encode('utf-8')) > _MAX: + raise ValueError('Macro message exceeds 16 MiB') + _wire_out.write(data + '\n') + _wire_out.flush() + +def _receive(): + data = _wire_in.buffer.readline(_MAX + 1) + if not data or len(data) > _MAX: + raise ValueError('Missing or oversized host response') + return _json.loads(data) + +def _call(command, params): + _send({'kind': 'command', 'command': command, 'params': params}) + result = _receive() + if 'error' in result: + raise RuntimeError(result['error']) + return result['result'] + +class _Workbook: + def read(self, reference, formulas=False): + return _call('sheet.read', {'range': reference, 'formulas': formulas})['values'] + def cell(self, reference): + return _call('cell.get', {'cell': reference}) + def inspect(self): + return _call('document.inspect', {}) + def write(self, reference, values): + return _call('range.setValues', {'range': reference, 'values': values, 'literal': True}) + def execute(self, command, **params): + return _call(command, params) + +_bridge = _types.ModuleType('gridcraft') +_bridge.active_workbook = lambda: _Workbook() +_sys.modules['gridcraft'] = _bridge + +class _Output: + def __init__(self, name): + self.name, self.remaining = name, 65536 + def write(self, text): + if self.remaining: + part = text[:self.remaining] + self.remaining -= len(part) + _send({'kind': 'output', 'stream': self.name, 'text': part}) + return len(text) + def flush(self): + pass + +_send({'kind': 'ready', 'interpreter': _sys.executable}) +if _sys.argv[-1] == '--prepare': + _sys.exit(0) +while True: + try: + _request = _receive() + except Exception: + break + if _request.get('action') == 'shutdown': + break + try: + if _request['modulePath'] not in _sys.path: + _sys.path.insert(0, _request['modulePath']) + with _contextlib.redirect_stdout(_Output('stdout')), _contextlib.redirect_stderr(_Output('stderr')): + _module, _function = _request['macro'].split('.') + _loaded = _importlib.import_module(_module) + _entry = getattr(_loaded, _function) + if not _inspect.isfunction(_entry) or _entry.__module__ != _loaded.__name__ or _function.startswith('_'): + raise ValueError('Select a public top-level function defined in the module') + if _inspect.signature(_entry).parameters or _inspect.isgeneratorfunction(_entry): + raise TypeError('Select a synchronous function with no arguments and no yield') + _return = _entry() + if _inspect.isawaitable(_return): + if _inspect.iscoroutine(_return): + _return.close() + raise TypeError('Async macros are not supported') + _send({'kind': 'done'}) + except BaseException: + _send({'kind': 'failed', 'error': _traceback.format_exc(limit=12)[-8192:]}) diff --git a/craftpython/process_tree.py b/craftpython/process_tree.py new file mode 100644 index 0000000..9d999e2 --- /dev/null +++ b/craftpython/process_tree.py @@ -0,0 +1,84 @@ +"""Own only this macro worker's process group; no process-name scans or global kills.""" +import os +import signal + + +class ProcessTree: + def __init__(self): + self.process = None + self.job = _WindowsJob() if os.name == 'nt' else None + + def attach(self, process): + self.process = process + if self.job is not None: + self.job.attach(process.pid) + + def close(self): + if self.job is not None: + self.job.close() + elif self.process is not None: + try: + os.killpg(self.process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + self.process = None + + +if os.name == 'nt': + import ctypes + from ctypes import wintypes as w + + class _BasicLimits(ctypes.Structure): + _fields_ = [('process_time', ctypes.c_longlong), ('job_time', ctypes.c_longlong), + ('flags', w.DWORD), ('minimum_ws', ctypes.c_size_t), + ('maximum_ws', ctypes.c_size_t), ('active_processes', w.DWORD), + ('affinity', ctypes.c_size_t), ('priority', w.DWORD), ('scheduling', w.DWORD)] + + class _IOCounters(ctypes.Structure): + _fields_ = [(name, ctypes.c_ulonglong) for name in + ('read_ops', 'write_ops', 'other_ops', 'read_bytes', 'write_bytes', 'other_bytes')] + + class _ExtendedLimits(ctypes.Structure): + _fields_ = [('basic', _BasicLimits), ('io', _IOCounters), + ('process_memory', ctypes.c_size_t), ('job_memory', ctypes.c_size_t), + ('peak_process_memory', ctypes.c_size_t), ('peak_job_memory', ctypes.c_size_t)] + + _kernel = ctypes.WinDLL('kernel32', use_last_error=True) + _kernel.CreateJobObjectW.argtypes = [ctypes.c_void_p, w.LPCWSTR] + _kernel.CreateJobObjectW.restype = w.HANDLE + _kernel.SetInformationJobObject.argtypes = [w.HANDLE, ctypes.c_int, ctypes.c_void_p, w.DWORD] + _kernel.SetInformationJobObject.restype = w.BOOL + _kernel.AssignProcessToJobObject.argtypes = [w.HANDLE, w.HANDLE] + _kernel.AssignProcessToJobObject.restype = w.BOOL + _kernel.OpenProcess.argtypes = [w.DWORD, w.BOOL, w.DWORD] + _kernel.OpenProcess.restype = w.HANDLE + _kernel.CloseHandle.argtypes = [w.HANDLE] + _kernel.CloseHandle.restype = w.BOOL + + class _WindowsJob: + def __init__(self): + self.handle = _kernel.CreateJobObjectW(None, None) + if not self.handle: + raise ctypes.WinError(ctypes.get_last_error()) + limits = _ExtendedLimits() + limits.basic.flags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, no breakaway. + if not _kernel.SetInformationJobObject(self.handle, 9, ctypes.byref(limits), ctypes.sizeof(limits)): + error = ctypes.WinError(ctypes.get_last_error()) + self.close() + raise error + + def attach(self, pid): + process = _kernel.OpenProcess(0x0100 | 0x0001, False, pid) # SET_QUOTA | TERMINATE + if not process: + raise ctypes.WinError(ctypes.get_last_error()) + try: + if not _kernel.AssignProcessToJobObject(self.handle, process): + raise ctypes.WinError(ctypes.get_last_error()) + finally: + _kernel.CloseHandle(process) + + def close(self): + if self.handle: + if not _kernel.CloseHandle(self.handle): + raise ctypes.WinError(ctypes.get_last_error()) + self.handle = None diff --git a/craftpython/workbook_macros.py b/craftpython/workbook_macros.py new file mode 100644 index 0000000..a3d82a1 --- /dev/null +++ b/craftpython/workbook_macros.py @@ -0,0 +1,51 @@ +"""Optional workbook-project role, using craft-python's existing App and Channel. + +Run Script, Console, Run File, PY.EVAL and the other apps keep their ordinary path. +Only --workbook-macros loads this adapter or starts the uv-managed worker. +""" +import argparse + +from .app import App, main +from .commands import PluginError +from .wire import HostError +from . import workbook_projects + +MACRO_ID = "io.github.sirmmo.python.macros" + + +class WorkbookMacroApp(App): + def __init__(self, channel, options): + super().__init__(channel) + workbook_projects.configure(channel.request, options.uv_executable, + options.python_interpreter, options.macro_cache) + + def handle(self, method, params): + if method == "initialize": + result = super().handle(method, params) + if self.host != "gridcraft": + raise PluginError("Workbook Python projects require GridCraft") + try: + workbook_projects.handle(method, params) + except RuntimeError as error: + raise PluginError(str(error)) from error + result["id"] = MACRO_ID + return result + if method.startswith("macros/"): + try: + return workbook_projects.handle(method, params) + except (RuntimeError, ValueError, SyntaxError, OSError, HostError) as error: + raise PluginError(str(error)) from error + return super().handle(method, params) + + +def run(): + parser = argparse.ArgumentParser() + parser.add_argument("--workbook-macros", action="store_true") + parser.add_argument("--uv-executable", default="uv") + parser.add_argument("--python-interpreter") + parser.add_argument("--macro-cache") + options = parser.parse_args() + try: + main(lambda channel: WorkbookMacroApp(channel, options)) + finally: + workbook_projects.close_worker() diff --git a/craftpython/workbook_projects.py b/craftpython/workbook_projects.py new file mode 100644 index 0000000..1577133 --- /dev/null +++ b/craftpython/workbook_projects.py @@ -0,0 +1,324 @@ +"""Experimental workbook-macro extension to Python Tools. + +Uses the shared GridCraft macro host. This module is loaded only in explicit +--workbook-macros mode; ordinary functions and WordCraft need no uv runtime. +The host owns UI cancellation and workbook transactions; this module owns the worker tree. +""" + +import ast, hashlib, json, os, pathlib, subprocess, sys, tempfile, traceback +import queue +import threading +import time +from .process_tree import ProcessTree + +HOST_CALL = None +UV_EXECUTABLE = 'uv' +PYTHON_INTERPRETER = None +CACHE_DIR = None +ALLOW_DOWNLOADS = False +MAX = 16 * 1024 * 1024 +STARTUP_SECONDS = 30 +RUN_SECONDS = 30 +IDLE_SECONDS = 60 +_runtime_lock = threading.RLock() +_idle_timer = None +_idle_token = None +worker, worker_key, module_dir, document_id = None, None, None, None +environment_key, environment_python = None, None + +def configure(host_call, uv_executable='uv', interpreter=None, cache=None): + global HOST_CALL, UV_EXECUTABLE, PYTHON_INTERPRETER, CACHE_DIR + HOST_CALL, UV_EXECUTABLE, PYTHON_INTERPRETER = host_call, uv_executable, interpreter + if cache is None: + base = pathlib.Path(os.environ.get('LOCALAPPDATA') or os.environ.get('XDG_CACHE_HOME') or pathlib.Path.home()/'.cache') + cache = base/'GridCraft'/'PythonMacros' + CACHE_DIR = pathlib.Path(cache) + CACHE_DIR.mkdir(parents=True, exist_ok=True) + +class WorkerProcess: + """Bounded pipe readers let the calling thread enforce an absolute deadline.""" + + def __init__(self, command, **options): + self.tree = ProcessTree() + if os.name == 'nt': + options['creationflags'] = options.get('creationflags', 0) | subprocess.CREATE_NO_WINDOW + else: + options['start_new_session'] = True + try: + self.process = subprocess.Popen(command, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding='utf-8', **options) + # User modules cannot run until the startup handshake and a later run request. + self.tree.attach(self.process) + except BaseException: + self.tree.close() + if getattr(self, 'process', None) is not None: + self.process.kill() + self.process.wait(timeout=5) + for stream in (self.process.stdin, self.process.stdout, self.process.stderr): + stream.close() + raise + self.stdin, self.stdout = self.process.stdin, self.process.stdout + self.messages = queue.Queue(maxsize=1) + self.stopped = threading.Event() + self.diagnostics = b'' + self.readers = [threading.Thread(target=f, daemon=True) + for f in (self._read_messages, self._read_errors)] + try: + for reader in self.readers: + reader.start() + except BaseException: + self.kill() + self.process.wait(timeout=5) + self.close_pipes() + raise + + def _read_messages(self): + try: + while not self.stopped.is_set(): + line = self.stdout.buffer.readline(MAX + 1) + message = line if len(line) <= MAX else RuntimeError('Oversized worker message') + while not self.stopped.is_set(): + try: + self.messages.put(message, timeout=0.1) + break + except queue.Full: + pass + if not line or isinstance(message, Exception): + return + except (OSError, ValueError) as error: + try: + self.messages.put_nowait(RuntimeError(str(error))) + except queue.Full: + pass + + def _read_errors(self): + try: + while not self.stopped.is_set(): + chunk = os.read(self.process.stderr.fileno(), 4096) + if not chunk: + return + self.diagnostics = (self.diagnostics + chunk)[-8192:] + except (OSError, ValueError): + pass + + def poll(self): + return self.process.poll() + + def wait(self, timeout=None): + return self.process.wait(timeout=timeout) + + def kill(self): + self.tree.close() + if self.process.poll() is None: + self.process.kill() + + def close_pipes(self): + self.tree.close() # Also close descendants after a normal interpreter exit. + self.stopped.set() + for reader in self.readers: + if reader.ident is not None: + reader.join(timeout=1) + for stream in (self.stdin, self.stdout, self.process.stderr): + stream.close() + + +def receive(child, deadline): + left = deadline - time.monotonic() + if left <= 0: + raise RuntimeError('Python worker exceeded its time limit') + try: + message = child.messages.get(timeout=left) + except queue.Empty as error: + raise RuntimeError('Python worker exceeded its time limit') from error + if isinstance(message, Exception): + raise message + if not message: + child.readers[1].join(timeout=0.1) + detail = child.diagnostics.decode('utf-8', errors='replace').strip() + raise RuntimeError('Python worker exited before replying' + (': ' + detail if detail else '')) + return json.loads(message) + +def host(method, params): + if HOST_CALL is None: + raise RuntimeError('Macro provider has not been initialized') + return HOST_CALL(method, params) + +def project(): + global document_id, ALLOW_DOWNLOADS + result = host('host/pythonProject/get', {}) + document_id = result['documentId'] + ALLOW_DOWNLOADS = result.get('allowDownloads') is True + return result['project'] or { + 'metadata': '# /// script\n# requires-python = ">=3.11"\n# dependencies = []\n# ///\n', + 'modules': {}, + } + +def _close_worker(): + global worker, worker_key, module_dir + if worker is not None: + child, worker = worker, None + try: + # Do not write to a potentially full pipe when stopping a busy macro. + child.kill() + child.wait(timeout=5) + finally: + child.close_pipes() + if module_dir is not None: + module_dir.cleanup() + module_dir = None + worker_key = None + +def _cancel_idle(): + global _idle_timer, _idle_token + if _idle_timer is not None: + _idle_timer.cancel() + _idle_timer, _idle_token = None, None + + +def close_worker(): + with _runtime_lock: + _cancel_idle() + _close_worker() + + +def _retire_when_idle(child): + global _idle_timer, _idle_token + _cancel_idle() + token = object() + _idle_token = token + def retire(): + with _runtime_lock: + # A cancelled callback may already be waiting for the lock. Identity alone + # is insufficient when the same warm interpreter has just run another macro. + if _idle_token is token and worker is child: + close_worker() + _idle_timer = threading.Timer(IDLE_SECONDS, retire) + _idle_timer.daemon = True + _idle_timer.start() + + +def uv_command(script, allow_downloads=False): + command = [UV_EXECUTABLE, 'run', '--no-config', '--no-project', '--no-env-file', + '--no-build', '--cache-dir', str(CACHE_DIR/'uv')] + if not allow_downloads: + command += ['--offline', '--no-python-downloads'] + if PYTHON_INTERPRETER: + command += ['--python', PYTHON_INTERPRETER] + return command + ['--script', str(script), '--prepare'] + + +def get_worker(p, deadline): + global worker, worker_key, module_dir, environment_key, environment_python + key = (document_id, hashlib.sha256(json.dumps(p,sort_keys=True).encode()).hexdigest()) + if worker is not None and worker_key == key and worker.poll() is None: + return worker, pathlib.Path(module_dir.name) + close_worker() + folder = CACHE_DIR + module_dir = tempfile.TemporaryDirectory(prefix='modules-', dir=folder) + modules = pathlib.Path(module_dir.name) + for name, source in p['modules'].items(): + (modules / (name+'.py')).write_text(source, encoding='utf-8') + bootstrap = pathlib.Path(__file__).with_name('macro_worker.py').read_text(encoding='utf-8') + source = p['metadata']+'\n'+bootstrap + env_key = hashlib.sha256(source.encode()).hexdigest() + scripts = folder/'scripts' + scripts.mkdir(exist_ok=True) + script = scripts/(env_key+'.py') + try: + with script.open('x',encoding='utf-8') as out: + out.write(source) + except FileExistsError: + with script.open('r',encoding='utf-8') as stored: + if stored.read(len(source)+1) != source: + raise RuntimeError('Macro bootstrap cache differs; choose another cache folder') + # Resolve with uv once per environment; source changes need a fresh interpreter, + # not another environment-resolution subprocess. Never reuse across metadata changes. + if environment_key == env_key and environment_python and pathlib.Path(environment_python).is_file(): + command = [environment_python, str(script)] + else: + command = uv_command(script, ALLOW_DOWNLOADS) + env = os.environ.copy() + env.pop('PYTHONPATH', None) + env.update(PYTHONUTF8='1', PYTHONUNBUFFERED='1') + deadline = min(deadline, time.monotonic() + STARTUP_SECONDS) + worker = WorkerProcess(command, cwd=folder, env=env) + ready = receive(worker, deadline) + if ready.get('kind') != 'ready' or not isinstance(ready.get('interpreter'),str): + close_worker() + raise RuntimeError('Worker did not complete its startup handshake') + environment_key, environment_python = env_key, ready['interpreter'] + if command[-1] == '--prepare': + # uv prepares the environment, then exits. Keep only the interpreter alive. + if worker.wait(timeout=5) != 0: + close_worker() + raise RuntimeError('uv environment preparation failed') + worker.close_pipes() + worker = WorkerProcess([environment_python, str(script)], cwd=folder, env=env) + ready = receive(worker, deadline) + if ready.get('kind') != 'ready': + close_worker() + raise RuntimeError('Prepared interpreter failed its startup handshake') + worker_key = key + return worker, modules + +def _handle(method, params): + if method == 'initialize': + features = params['host'].get('features', []) + if not all(f in features for f in ('pythonProjects', 'engineCommands', 'literalValues')): + raise RuntimeError('This Python provider requires the Python-project host extension') + return {'protocolVersion': 1} + p = project() + if method == 'macros/modules': + return {'modules': [{'name': n, 'kind': 'standard', 'source': s} for n,s in p['modules'].items()]} + if method == 'macros/list': + macros = [] + for name, source in p['modules'].items(): + for node in ast.parse(source, filename=name+'.py').body: + if isinstance(node, ast.FunctionDef) and not node.name.startswith('_'): + a = node.args + if not (a.posonlyargs or a.args or a.kwonlyargs or a.vararg or a.kwarg): + macros.append({'name': name+'.'+node.name, 'module': name, 'procedure': node.name}) + return {'macros': macros} + if method == 'macros/saveModule': + p['modules'][params['name']] = params['source'] + return host('host/pythonProject/set', {'project': p}) + if method == 'macros/deleteModule': + p['modules'].pop(params['name'], None) + return host('host/pythonProject/set', {'project': p}) + if method == 'macros/run': + # Keep a warm worker for this workbook and exact project; never share module state between books. + try: + deadline = time.monotonic() + RUN_SECONDS + child, modules = get_worker(p, deadline) + child.stdin.write(json.dumps({'action':'run', 'macro':params['name'], 'modulePath':str(modules)})+'\n') + child.stdin.flush() + while True: + message = receive(child, deadline) + if message['kind'] == 'command': + result = host('host/engine.execute', {'command':message['command'], 'params':message['params']}) + child.stdin.write(json.dumps({'result':result})+'\n') + child.stdin.flush() + elif message['kind'] == 'output': + host('host/log', {'text':message.get('text','')}) + elif message['kind'] == 'failed': + raise RuntimeError(message['error']) + elif message['kind'] == 'done': + return {} + else: + raise RuntimeError('Unexpected worker message') + except BaseException: + close_worker() + raise + raise ValueError('Unsupported method: '+method) + + +def handle(method, params): + # Serialize lifecycle changes with idle retirement, including host round trips. + with _runtime_lock: + if method == 'macros/run': + _cancel_idle() + try: + return _handle(method, params) + finally: + if method == 'macros/run' and worker is not None: + _retire_when_idle(worker) diff --git a/main.py b/main.py index bcf636f..a3fbabc 100644 --- a/main.py +++ b/main.py @@ -18,4 +18,8 @@ from craftpython.app import main # noqa: E402 if __name__ == "__main__": - main() + if "--workbook-macros" in sys.argv: + from craftpython.workbook_macros import run + run() + else: + main() diff --git a/plugin-macros.json b/plugin-macros.json new file mode 100644 index 0000000..e44d736 --- /dev/null +++ b/plugin-macros.json @@ -0,0 +1,36 @@ +{ + "id": "io.github.sirmmo.python.macros", + "name": "Python Workbook Macros (experimental)", + "version": "0.3.0", + "description": "Optional workbook macro role for Python Tools; requires the proposed GridCraft macro integration.", + "author": "Marco Montanari", + "homepage": "https://github.com/sirmmo/craft-python", + "apps": [ + "gridcraft" + ], + "run": { + "command": "python3", + "args": [ + "main.py", + "--workbook-macros" + ] + }, + "functions": [], + "commands": [], + "ui": {}, + "uiFor": {}, + "macros": { + "language": "Python", + "recording": false, + "ribbonCallbacks": false + }, + "requires": { + "protocol": ">=2.2", + "apps": {"gridcraft": ">=0.7.0"}, + "features": [ + "pythonProjects", + "engineCommands", + "literalValues" + ] + } +} diff --git a/tests/test_workbook_macro_runtime.py b/tests/test_workbook_macro_runtime.py new file mode 100644 index 0000000..34e15d2 --- /dev/null +++ b/tests/test_workbook_macro_runtime.py @@ -0,0 +1,263 @@ +"""Worker failure and bounded-transport checks using disposable Python children.""" +import sys +import os +import time +import unittest + +from craftpython import workbook_projects as runtime + + +class WorkerTransport(unittest.TestCase): + def start(self, source): + child = runtime.WorkerProcess([sys.executable, '-u', '-c', source]) + self.addCleanup(self.stop, child) + return child + + @staticmethod + def stop(child): + if child.poll() is None: + child.kill() + child.wait(timeout=5) + child.close_pipes() + + def test_stuck_worker_has_a_deadline(self): + child = self.start('import time; time.sleep(60)') + with self.assertRaisesRegex(RuntimeError, 'time limit'): + runtime.receive(child, time.monotonic() + 0.1) + + def test_messages_do_not_extend_deadline(self): + child = self.start('while True:\n print(\'{"kind":"output"}\', flush=True)') + deadline = time.monotonic() + 0.3 + count = 0 + with self.assertRaisesRegex(RuntimeError, 'time limit'): + while True: + runtime.receive(child, deadline) + count += 1 + self.assertGreater(count, 0) + + def test_startup_error_is_reported_and_bounded(self): + child = self.start("import sys; sys.stderr.write('x'*100000+'missing dependency'); sys.exit(1)") + with self.assertRaisesRegex(RuntimeError, 'missing dependency') as caught: + runtime.receive(child, time.monotonic() + 5) + self.assertLess(len(str(caught.exception)), 8400) + + def test_oversized_message_is_rejected(self): + child = self.start("import sys; sys.stdout.write('x'*(16*1024*1024+1)); sys.stdout.flush()") + with self.assertRaisesRegex(RuntimeError, 'Oversized'): + runtime.receive(child, time.monotonic() + 5) + + def test_failed_startup_discards_worker(self): + # Environment setup errors must not retain a process or a temporary module directory. + import tempfile + project = {'metadata': '# /// script\n# dependencies = []\n# ///\n', 'modules': {}} + runtime.close_worker() + with tempfile.TemporaryDirectory() as cache: + runtime.configure(lambda *_: {'documentId': 1, 'project': project}, + uv_executable=cache + '/missing-uv-executable', cache=cache) + runtime.environment_key = None + with self.assertRaises(OSError): + runtime.handle('macros/run', {'name': 'analysis.run'}) + self.assertIsNone(runtime.worker) + self.assertIsNone(runtime.module_dir) + + def test_infinite_macro_is_stopped_and_next_run_recovers(self): + import hashlib + import pathlib + import tempfile + from unittest.mock import patch + project = {'metadata': '# /// script\n# dependencies = []\n# ///\n', + 'modules': {'analysis': 'def stuck():\n while True:\n pass\ndef good():\n pass\n'}} + runtime.close_worker() + self.addCleanup(runtime.close_worker) + with tempfile.TemporaryDirectory() as cache: + runtime.configure(lambda *_: {'documentId': 1, 'project': project}, cache=cache) + # Reuse this already available interpreter; this test must not invoke uv or download anything. + bootstrap = pathlib.Path(runtime.__file__).with_name('macro_worker.py').read_text(encoding='utf-8') + runtime.environment_key = hashlib.sha256((project['metadata'] + '\n' + bootstrap).encode()).hexdigest() + runtime.environment_python = sys.executable + with patch.object(runtime, 'RUN_SECONDS', 1): + with self.assertRaisesRegex(RuntimeError, 'time limit'): + runtime.handle('macros/run', {'name': 'analysis.stuck'}) + self.assertIsNone(runtime.worker) + self.assertIsNone(runtime.module_dir) + self.assertEqual(runtime.handle('macros/run', {'name': 'analysis.good'}), {}) + runtime.close_worker() + + + def test_workbook_write_uses_protocol_literal_values(self): + import hashlib + import pathlib + import tempfile + project = {'metadata': '# /// script\n# dependencies = []\n# ///\n', + 'modules': {'analysis': "import gridcraft as gc\ndef write_text():\n gc.active_workbook().write('A1', [['00123', '=not a formula', '1/2']])\n"}} + calls = [] + def host(method, params): + if method == 'host/pythonProject/get': + return {'documentId': 1, 'project': project} + calls.append((method, params)) + return {} + runtime.close_worker() + self.addCleanup(runtime.close_worker) + with tempfile.TemporaryDirectory() as cache: + runtime.configure(host, cache=cache) + bootstrap = pathlib.Path(runtime.__file__).with_name('macro_worker.py').read_text(encoding='utf-8') + runtime.environment_key = hashlib.sha256((project['metadata'] + '\n' + bootstrap).encode()).hexdigest() + runtime.environment_python = sys.executable + runtime.handle('macros/run', {'name': 'analysis.write_text'}) + self.assertEqual(calls, [('host/engine.execute', {'command': 'range.setValues', 'params': { + 'range': 'A1', 'values': [['00123', '=not a formula', '1/2']], 'literal': True}})]) + runtime.close_worker() + + +class IdleWorker(unittest.TestCase): + def setUp(self): + import hashlib + import pathlib + import tempfile + runtime.close_worker() + self.cache = tempfile.TemporaryDirectory() + self.addCleanup(self.cache.cleanup) + self.addCleanup(runtime.close_worker) + self.project = {'metadata': '# /// script\n# dependencies = []\n# ///\n', + 'modules': {'analysis': 'import time\ndef quick():\n pass\ndef slow():\n time.sleep(0.3)\n'}} + runtime.configure(lambda *_: {'documentId': 1, 'project': self.project}, cache=self.cache.name) + bootstrap = pathlib.Path(runtime.__file__).with_name('macro_worker.py').read_text(encoding='utf-8') + runtime.environment_key = hashlib.sha256((self.project['metadata'] + '\n' + bootstrap).encode()).hexdigest() + runtime.environment_python = sys.executable + + def test_idle_worker_releases_process_and_module_directory(self): + from unittest.mock import patch + with patch.object(runtime, 'IDLE_SECONDS', 0.1): + runtime.handle('macros/run', {'name': 'analysis.quick'}) + child = runtime.worker + self.assertIsNotNone(child) + deadline = time.monotonic() + 5 + while runtime.worker is not None and time.monotonic() < deadline: + time.sleep(0.02) + with runtime._runtime_lock: # Wait for the retirement callback to finish cleanup. + self.assertIsNone(runtime.worker) + self.assertIsNotNone(child.wait(timeout=5)) + self.assertIsNone(runtime.module_dir) + + def test_idle_deadline_does_not_interrupt_active_macro(self): + from unittest.mock import patch + with patch.object(runtime, 'IDLE_SECONDS', 0.1): + runtime.handle('macros/run', {'name': 'analysis.quick'}) + child = runtime.worker + self.assertEqual(runtime.handle('macros/run', {'name': 'analysis.slow'}), {}) + self.assertIs(runtime.worker, child) + self.assertIsNone(child.poll()) + + +@unittest.skipUnless(os.name == 'nt', 'Windows job-object lifetime checks') +class WindowsWorkerTree(unittest.TestCase): + @staticmethod + def spawn_source(exit_parent=False): + return ("import sys, subprocess, json, time\n" + "sys.stdin.readline()\n" + "child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n" + "print(json.dumps({'pid': child.pid}), flush=True)\n" + + ("sys.exit(0)\n" if exit_parent else "time.sleep(60)\n")) + + def watch(self, pid): + # Keep an actual process handle: never terminate by a possibly reused PID. + import ctypes + from ctypes import wintypes as w + kernel = ctypes.WinDLL('kernel32', use_last_error=True) + kernel.OpenProcess.argtypes = [w.DWORD, w.BOOL, w.DWORD] + kernel.OpenProcess.restype = w.HANDLE + kernel.WaitForSingleObject.argtypes = [w.HANDLE, w.DWORD] + kernel.WaitForSingleObject.restype = w.DWORD + kernel.TerminateProcess.argtypes = [w.HANDLE, w.UINT] + kernel.TerminateProcess.restype = w.BOOL + kernel.CloseHandle.argtypes = [w.HANDLE] + kernel.CloseHandle.restype = w.BOOL + handle = kernel.OpenProcess(0x100000 | 0x1, False, pid) + self.assertTrue(handle, 'fixture descendant should still be alive') + def cleanup(): + if kernel.WaitForSingleObject(handle, 0) == 258: + kernel.TerminateProcess(handle, 1) + kernel.WaitForSingleObject(handle, 5000) + kernel.CloseHandle(handle) + self.addCleanup(cleanup) + return lambda ms: kernel.WaitForSingleObject(handle, ms) + + def test_killing_worker_also_kills_its_descendant(self): + child = runtime.WorkerProcess([sys.executable, '-u', '-c', self.spawn_source()]) + self.addCleanup(WorkerTransport.stop, child) + child.stdin.write('\n'); child.stdin.flush() + pid = runtime.receive(child, time.monotonic() + 5)['pid'] + wait = self.watch(pid) + self.assertEqual(wait(0), 258) + child.kill() + self.assertEqual(wait(5000), 0, 'descendant survived worker termination') + + def test_normal_worker_exit_does_not_leave_descendant_holding_pipes(self): + child = runtime.WorkerProcess([sys.executable, '-u', '-c', self.spawn_source(True)]) + self.addCleanup(WorkerTransport.stop, child) + child.stdin.write('\n'); child.stdin.flush() + wait = self.watch(runtime.receive(child, time.monotonic() + 5)['pid']) + self.assertEqual(child.wait(timeout=5), 0) + self.assertEqual(wait(0), 258, 'fixture must outlive its parent for this check') + started = time.monotonic() + child.close_pipes() + self.assertEqual(wait(5000), 0) + self.assertLess(time.monotonic() - started, 3, 'inherited pipes blocked cleanup') + + def test_provider_crash_closes_owned_worker_tree(self): + import json + import queue + import subprocess + import threading + source = ("from craftpython import workbook_projects as runtime\nimport sys, json, time\n" + "child = runtime.WorkerProcess([sys.executable, '-u', '-c', " + repr(self.spawn_source()) + "])\n" + "child.stdin.write('\\n'); child.stdin.flush()\n" + "print(json.dumps(runtime.receive(child, time.monotonic()+5)), flush=True)\n" + "time.sleep(60)\n") + owner = subprocess.Popen([sys.executable, '-u', '-c', source], stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, creationflags=subprocess.CREATE_NO_WINDOW) + lines = queue.Queue(maxsize=1) + reader = threading.Thread(target=lambda: lines.put(owner.stdout.readline()), daemon=True) + reader.start() + try: + wait = self.watch(json.loads(lines.get(timeout=5))['pid']) + self.assertEqual(wait(0), 258) + owner.kill() # Kill only the provider, not its subprocess tree ourselves. + owner.wait(timeout=5) + self.assertEqual(wait(5000), 0, 'provider crash left an orphan worker descendant') + finally: + if owner.poll() is None: + owner.kill() + owner.wait(timeout=5) + reader.join(timeout=1) + owner.stdout.close(); owner.stderr.close() + + +class DownloadPolicy(unittest.TestCase): + def test_downloads_are_off_by_default_and_source_builds_stay_off(self): + import pathlib + from unittest.mock import patch + with patch.object(runtime, 'CACHE_DIR', pathlib.Path('cache')): + offline = runtime.uv_command('script.py') + online = runtime.uv_command('script.py', True) + self.assertIn('--offline', offline) + self.assertIn('--no-python-downloads', offline) + self.assertNotIn('--offline', online) + self.assertNotIn('--no-python-downloads', online) + for command in (offline, online): + for flag in ('--no-build', '--no-config', '--no-env-file', '--no-project'): + self.assertIn(flag, command) + + def test_only_the_hosts_top_level_boolean_can_grant_downloads(self): + from unittest.mock import patch + for flag in (None, False, 'true', True): + result = {'documentId': 1, 'project': {'metadata': '', 'modules': {}, 'allowDownloads': True}} + if flag is not None: result['allowDownloads'] = flag + with patch.object(runtime, 'HOST_CALL', lambda *_: result): + runtime.project() + self.assertEqual(runtime.ALLOW_DOWNLOADS, flag is True) + runtime.ALLOW_DOWNLOADS = False + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_workbook_macros.py b/tests/test_workbook_macros.py new file mode 100644 index 0000000..46e88da --- /dev/null +++ b/tests/test_workbook_macros.py @@ -0,0 +1,73 @@ +"""The optional role reuses the 0.3.0 dispatcher without replacing its script features.""" +import argparse +import json +import pathlib +import subprocess +import sys +import tempfile +import unittest + +from craftpython.app import App, Unknown +from craftpython.commands import PluginError +from craftpython.workbook_macros import WorkbookMacroApp, MACRO_ID + +ROOT = pathlib.Path(__file__).resolve().parent.parent +FEATURES = ["pythonProjects", "engineCommands", "literalValues"] + + +class Channel: + def __init__(self): + self.calls = [] + + def request(self, method, params): + self.calls.append((method, params)) + if method == "host/pythonProject/get": + return {"documentId": 1, "project": {"metadata": "", "modules": { + "analysis": "raise RuntimeError('discovery must not execute this')\ndef run():\n pass\n"}}} + raise AssertionError(method) + + +class WorkbookMacros(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory() + self.addCleanup(self.folder.cleanup) + options = argparse.Namespace(uv_executable="missing-uv", python_interpreter=None, + macro_cache=self.folder.name) + self.channel = Channel() + self.app = WorkbookMacroApp(self.channel, options) + + def test_regular_start_does_not_load_the_macro_runtime(self): + code = ("import main,sys; " + "assert 'craftpython.workbook_macros' not in sys.modules; " + "assert 'craftpython.workbook_projects' not in sys.modules; " + "assert 'craftpython.process_tree' not in sys.modules") + subprocess.run([sys.executable, "-c", code], cwd=ROOT, check=True, timeout=10, + capture_output=True) + regular = App(Channel()) + with self.assertRaises(Unknown): + regular.handle("macros/list", {}) + + def test_optional_role_reuses_app_dispatch_and_inert_discovery(self): + info = self.app.handle("initialize", {"host": {"id": "gridcraft", "features": FEATURES}}) + self.assertEqual(info["id"], MACRO_ID) + self.assertEqual(info["version"], "0.3.0") + self.assertEqual(self.app.handle("functions/call", {"name": "PY.TWICE", "args": [21]}), {"value": 42}) + listing = self.app.handle("macros/list", {}) + self.assertIn("analysis.run", str(listing)) + self.assertEqual(self.channel.calls[0][0], "host/pythonProject/get") + + def test_unsupported_hosts_and_missing_capabilities_are_refused(self): + with self.assertRaisesRegex(PluginError, "require GridCraft"): + self.app.handle("initialize", {"host": {"id": "wordcraft", "features": FEATURES}}) + with self.assertRaisesRegex(PluginError, "host extension"): + self.app.handle("initialize", {"host": {"id": "gridcraft", "features": []}}) + + def test_macro_manifest_cannot_shadow_the_regular_role(self): + regular = json.loads((ROOT / "plugin.json").read_text(encoding="utf-8")) + macro = json.loads((ROOT / "plugin-macros.json").read_text(encoding="utf-8")) + self.assertEqual(regular["apps"], ["gridcraft", "wordcraft", "deckcraft"]) + self.assertNotIn("macros", regular) + self.assertNotEqual(regular["id"], macro["id"]) + self.assertEqual(macro["version"], regular["version"]) + self.assertEqual((macro["functions"], macro["commands"]), ([], [])) + self.assertEqual(macro["macros"], {"language": "Python", "recording": False, "ribbonCallbacks": False}) From 914c3146f5b8451cce3f3bf1b7660c512ade76c9 Mon Sep 17 00:00:00 2001 From: derechopiano <257026466+derechopiano@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:15:24 +0100 Subject: [PATCH 2/2] 1566: own POSIX workers across provider termination --- .github/workflows/ci.yml | 1 + README.md | 6 ++-- craftpython/process_tree.py | 45 ++++++++++++++++++++--- tests/test_workbook_macro_runtime.py | 53 +++++++++++++++++++++++++++- 4 files changed, 98 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6eb559..5226b81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + workflow_dispatch: permissions: contents: read diff --git a/README.md b/README.md index d1c91e1..69312cb 100644 --- a/README.md +++ b/README.md @@ -390,8 +390,10 @@ uv configuration remain disabled. Ordinary craft-python never starts this worker A successful worker stays warm for the same document/source, retires after 60 idle seconds, and restarts after changes or errors. Startup/run have an absolute 30-second budget, messages a 16 MiB limit, diagnostics an 8 KiB limit. Windows -kill-on-close jobs and POSIX process groups own worker descendants; specific -POSIX descendant verification remains outstanding. These are trusted programs +kill-on-close jobs own worker descendants. On POSIX, a small helper blocks on an +owned pipe and kills the worker process group when the provider closes or dies; +it uses no polling and retires with the worker. Platform regressions cover forced +shutdown, a normal interpreter exit and abrupt provider death. These are trusted programs with the user's OS permissions, not a sandbox. The host supplies Run/Stop and restores workbook changes on failure; external diff --git a/craftpython/process_tree.py b/craftpython/process_tree.py index 9d999e2..3ca696e 100644 --- a/craftpython/process_tree.py +++ b/craftpython/process_tree.py @@ -1,27 +1,64 @@ """Own only this macro worker's process group; no process-name scans or global kills.""" import os import signal +import subprocess +import sys + +# The provider is the sole writer of this pipe. EOF also arrives after SIGKILL/crash, when +# Python finally blocks cannot run. The helper stays outside the worker's process group and +# sleeps in the kernel until needed; user modules never inherit the write end. +_GROUP_OWNER = """ +import os, signal, sys +try: + sys.stdin.buffer.read() +finally: + try: + os.killpg(int(sys.argv[1]), signal.SIGKILL) + except ProcessLookupError: + pass +""" class ProcessTree: def __init__(self): self.process = None + self.owner = None self.job = _WindowsJob() if os.name == 'nt' else None def attach(self, process): self.process = process if self.job is not None: self.job.attach(process.pid) + else: + self.owner = subprocess.Popen([sys.executable, '-c', _GROUP_OWNER, str(process.pid)], + stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, close_fds=True) def close(self): if self.job is not None: self.job.close() elif self.process is not None: try: - os.killpg(self.process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - self.process = None + if self.owner is not None: + self.owner.stdin.close() + try: + if self.owner.wait(timeout=5) != 0: + self._kill_group() + except subprocess.TimeoutExpired: + self._kill_group() + self.owner.kill() + self.owner.wait(timeout=5) + else: + self._kill_group() # Failed ownership setup still cleans its new session. + finally: + self.owner = None + self.process = None + + def _kill_group(self): + try: + os.killpg(self.process.pid, signal.SIGKILL) + except ProcessLookupError: + pass if os.name == 'nt': diff --git a/tests/test_workbook_macro_runtime.py b/tests/test_workbook_macro_runtime.py index 34e15d2..ada898e 100644 --- a/tests/test_workbook_macro_runtime.py +++ b/tests/test_workbook_macro_runtime.py @@ -214,8 +214,9 @@ def test_provider_crash_closes_owned_worker_tree(self): "child.stdin.write('\\n'); child.stdin.flush()\n" "print(json.dumps(runtime.receive(child, time.monotonic()+5)), flush=True)\n" "time.sleep(60)\n") + options = {'creationflags': subprocess.CREATE_NO_WINDOW} if os.name == 'nt' else {} owner = subprocess.Popen([sys.executable, '-u', '-c', source], stdout=subprocess.PIPE, - stderr=subprocess.PIPE, text=True, creationflags=subprocess.CREATE_NO_WINDOW) + stderr=subprocess.PIPE, text=True, **options) lines = queue.Queue(maxsize=1) reader = threading.Thread(target=lambda: lines.put(owner.stdout.readline()), daemon=True) reader.start() @@ -233,6 +234,56 @@ def test_provider_crash_closes_owned_worker_tree(self): owner.stdout.close(); owner.stderr.close() +@unittest.skipUnless(os.name == 'posix', 'POSIX process-group ownership') +class PosixWorkerTree(unittest.TestCase): + spawn_source = staticmethod(WindowsWorkerTree.spawn_source) + test_killing_worker_also_kills_its_descendant = WindowsWorkerTree.test_killing_worker_also_kills_its_descendant + test_normal_worker_exit_does_not_leave_descendant_holding_pipes = WindowsWorkerTree.test_normal_worker_exit_does_not_leave_descendant_holding_pipes + test_provider_crash_closes_owned_worker_tree = WindowsWorkerTree.test_provider_crash_closes_owned_worker_tree + + def watch(self, pid): + import pathlib + + def alive(): + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + # Linux can leave a killed orphan as a zombie briefly before init reaps it. + # It is already stopped and has released its pipes. Other POSIX hosts reap it. + try: + state = pathlib.Path('/proc/{}/stat'.format(pid)).read_text().rsplit(')', 1)[1].split()[0] + if state == 'Z': + return False + except (OSError, IndexError): + pass + return True + + self.assertTrue(alive(), 'fixture descendant should still be alive') + group = os.getpgid(pid) + self.assertNotEqual(group, os.getpgrp(), 'fixture must own a separate process group') + + def cleanup(): + try: + if alive() and os.getpgid(pid) == group: + os.killpg(group, signal.SIGKILL) + except ProcessLookupError: + pass + + import signal + self.addCleanup(cleanup) + + def wait(ms): + deadline = time.monotonic() + ms / 1000 + while alive(): + if time.monotonic() >= deadline: + return 258 + time.sleep(0.01) + return 0 + + return wait + + class DownloadPolicy(unittest.TestCase): def test_downloads_are_off_by_default_and_source_builds_stay_off(self): import pathlib