"""Read the flows a module declares, in a process of its own. Importing a module runs it. A research module that sets up a GPU framework at its top then opens a CUDA context in whichever process imported it — and for `fluksio run --follow` that process is the command line, which stays alive for the length of the run and holds the card without the `resources.env` the node was declared with. So the command line never imports the module: it starts a short-lived child, the child imports and declares, writes down what `sync` needs of each flow, and exits. Whatever the import opened goes with it. The request and the answer are files in a directory of their own, not a pipe or the command line. The child's output stays the user's: a `print` at the top of a study, a warning, a skipped module in a walk, all appear on the terminal as they did when the import happened here. ``python -m fluksio.sdk.declare`` is the child. It runs under the same interpreter and with the same ``sys.path`` as the command, so the module resolves its imports exactly as it did in-process. """ from __future__ import annotations import json import subprocess import sys import tempfile import traceback from pathlib import Path from typing import Any from fluksio.sdk import FLOWS, Declared, SyncError __all__ = ["ImportFailed", "declare", "declare_module"] class ImportFailed(Exception): """A module raised something other than a refusal while it was imported. ``str()`` is what `fluksio` printed for it when the import happened in process, ``Type: message``; the child's traceback is kept for the command that wants to show it. """ def __init__(self, kind: str, message: str, trace: str = "") -> None: super().__init__(f"{kind}: {message}") self.trace = trace #: What the child raised, by name, for the ones a command already handles by #: type. Anything else is an :class:`ImportFailed`. _RAISED: dict[str, type[Exception]] = { "SyncError": SyncError, "ImportError": ImportError, "ModuleNotFoundError": ModuleNotFoundError, } def declare(targets: list[str], keep_going: bool = False) -> list[Declared]: """What importing these targets declares — see ``cli.discover`` for the rules.""" return _ask({"targets": targets, "keep_going": keep_going}, ", ".join(targets)) def declare_module(root: str, dotted: str, expect: Path) -> list[Declared]: """What importing one module, by name and from this root, declares.""" return _ask({"module": [root, dotted, str(expect)]}, dotted) def _ask(request: dict[str, Any], what: str) -> list[Declared]: request["path"] = sys.path with tempfile.TemporaryDirectory() as scratch: (Path(scratch) / "request.json").write_text(json.dumps(request)) done = subprocess.run([sys.executable, "-m", __name__, scratch]) try: reply = json.loads((Path(scratch) / "answer.json").read_text()) except (OSError, ValueError): raise SyncError( f"importing {what} ended without an answer " f"(exit status {done.returncode}); its output is above" ) from None error = reply.get("error") if error: raised = _RAISED.get(error["type"]) if raised is not None: raise raised(error["message"]) raise ImportFailed(error["type"], error["message"], error["traceback"]) return [Declared(entry) for entry in reply["flows"]] def main(scratch: Path) -> None: """The child: read the request, import, declare, write the answer down.""" request = json.loads((scratch / "request.json").read_text()) # Imported here, not above: `cli` is the one place the walk lives, and it # imports this module for the other half. from fluksio.sdk.cli import _import, discover sys.path[:] = request["path"] try: if "module" in request: root, dotted, expect = request["module"] _import(root, dotted, Path(expect)) flows = list(FLOWS.values()) else: flows = discover(request["targets"], request["keep_going"]) reply: dict[str, Any] = {"flows": [flow.declaration() for flow in flows]} except Exception as exc: reply = { "error": { "type": type(exc).__name__, "message": str(exc), "traceback": traceback.format_exc(), } } (scratch / "answer.json").write_text(json.dumps(reply)) if __name__ == "__main__": main(Path(sys.argv[1]))