Skip to content

Registry

The workbench catalogue of what can be opened, the runner that streams catalogue scripts, and the process tree a stop ends.

femforge_workbench.registry

The workbench catalogue: specs of what can be opened, discovered from the built-ins and the femforge.workbenches entry points, imported only on open.

Type Aliases:

  • Target –

    A pkg.module:attr string, or the module-level function it names.

Classes:

  • WorkbenchKind –

    How a spec is opened: a dock of panels, one Renderable in a plotter, or a script run in a subprocess.

  • OutputKind –

    How a script output is shown.

  • OutputSpec –

    One file a script produces.

  • WorkbenchSpec –

    One card of the catalogue: what it is, how it opens, and what it leaves behind.

  • EntryPointLike –

    What discover needs from an entry point.

Functions:

  • split_target –

    The module and optional attribute of a pkg.module:attr target.

  • resolve –

    The object a target names, importing its module now: pkg.module:attr gives the attribute, pkg.module the module.

  • script_module –

    The module name the running script imports as: its -m name, else its file's stem, since the script's directory leads sys.path.

  • target_of –

    The pkg.module:attr string of a target; a function defined in the running script is named by the module its file imports as.

  • script –

    A script card, run in a subprocess.

  • panel –

    A dock card.

  • renderable –

    A viewer card, rebuilt on save.

  • legacy –

    The spec of a module following the old main() -> Renderable contract.

  • discover –

    Every spec: the built-ins plus what each entry point in the group provides (a spec or a sequence), sorted by group and title; a name given two different specs is an error.

Attributes:

BUILTIN module-attribute

BUILTIN: tuple[WorkbenchSpec, ...] = (LAUNCHER, DEMO, BROWSERS, FIELD_VIEWER, MONITORS)

The specs discover() carries without an entry point.

Target

Target = str | Callable[[], object]

A pkg.module:attr string, or the module-level function it names.

WorkbenchKind

Bases: StrEnum

How a spec is opened: a dock of panels, one Renderable in a plotter, or a script run in a subprocess.

OutputKind

Bases: StrEnum

How a script output is shown.

Methods:

  • from_path –

    The kind a file's suffix implies, text when unknown.

from_path classmethod

from_path(path: Path) -> OutputKind

The kind a file's suffix implies, text when unknown.

Source code in packages/femforge-workbench/src/femforge_workbench/registry.py
@classmethod
def from_path(cls, path: Path) -> "OutputKind":
    """The kind a file's suffix implies, text when unknown."""
    match path.suffix.lower():
        case ".csv" | ".parquet":
            return cls.TABLE
        case ".html" | ".htm":
            return cls.HTML
        case ".png" | ".jpg" | ".jpeg" | ".svg" | ".gif" | ".webp":
            return cls.IMAGE
        case ".md":
            return cls.MARKDOWN
        case _:
            return cls.TEXT

OutputSpec dataclass

OutputSpec(path: Path, title: str, kind: OutputKind)

One file a script produces.

Methods:

  • at –

    An output typed from its suffix and titled by its name unless given.

  • modified –

    When the file was last written, None while it does not exist.

at classmethod

at(path: Path, title: str | None = None) -> OutputSpec

An output typed from its suffix and titled by its name unless given.

Source code in packages/femforge-workbench/src/femforge_workbench/registry.py
@classmethod
def at(cls, path: Path, title: str | None = None) -> "OutputSpec":
    """An output typed from its suffix and titled by its name unless given."""
    return cls(path=path, title=title or path.name, kind=OutputKind.from_path(path))

modified

modified() -> datetime | None

When the file was last written, None while it does not exist.

Source code in packages/femforge-workbench/src/femforge_workbench/registry.py
def modified(self) -> datetime | None:
    """When the file was last written, None while it does not exist."""
    if not self.path.exists():
        return None

    return datetime.fromtimestamp(self.path.stat().st_mtime)

WorkbenchSpec dataclass

WorkbenchSpec(name: str, title: str, kind: WorkbenchKind, target: str, description: str = '', group: str = '', args: tuple[str, ...] = (), cwd: Path | None = None, outputs: tuple[OutputSpec, ...] = ())

One card of the catalogue: what it is, how it opens, and what it leaves behind.

Methods:

  • last_run –

    The newest output timestamp, None when nothing has been produced.

  • command –

    The subprocess that runs this spec: the module for a script, the workbench CLI for the rest.

Attributes:

  • module (str) –

    The module the target names.

module property

module: str

The module the target names.

last_run

last_run() -> datetime | None

The newest output timestamp, None when nothing has been produced.

Source code in packages/femforge-workbench/src/femforge_workbench/registry.py
def last_run(self) -> datetime | None:
    """The newest output timestamp, None when nothing has been produced."""
    stamps = [stamp for stamp in (output.modified() for output in self.outputs) if stamp is not None]
    if not stamps:
        return None

    return max(stamps)

command

command() -> list[str]

The subprocess that runs this spec: the module for a script, the workbench CLI for the rest.

Source code in packages/femforge-workbench/src/femforge_workbench/registry.py
def command(self) -> list[str]:
    """The subprocess that runs this spec: the module for a script, the workbench CLI for the rest."""
    if self.kind is WorkbenchKind.SCRIPT:
        return [sys.executable, "-m", self.module, *self.args]

    return [sys.executable, "-m", CLI_MODULE, self.name, *self.args]

EntryPointLike

Bases: Protocol

What discover needs from an entry point.

Methods:

  • load –

    Imports and returns what the entry point names.

Attributes:

  • name (str) –

    The entry point's name.

name property

name: str

The entry point's name.

load

load() -> object

Imports and returns what the entry point names.

Source code in packages/femforge-workbench/src/femforge_workbench/registry.py
def load(self) -> object:
    """Imports and returns what the entry point names."""
    ...

split_target

split_target(target: str) -> tuple[str, str | None]

The module and optional attribute of a pkg.module:attr target.

Source code in packages/femforge-workbench/src/femforge_workbench/registry.py
def split_target(target: str) -> tuple[str, str | None]:
    """The module and optional attribute of a `pkg.module:attr` target."""
    module, _, attr = target.partition(":")

    return module, attr or None

resolve

resolve(target: str) -> object

The object a target names, importing its module now: pkg.module:attr gives the attribute, pkg.module the module.

Source code in packages/femforge-workbench/src/femforge_workbench/registry.py
def resolve(target: str) -> object:
    """The object a target names, importing its module now: `pkg.module:attr` gives the attribute, `pkg.module` the module."""
    module_name, attr = split_target(target)
    module = importlib.import_module(module_name)
    if attr is None:
        return module

    return cast("object", getattr(module, attr))

script_module

script_module() -> str

The module name the running script imports as: its -m name, else its file's stem, since the script's directory leads sys.path.

Source code in packages/femforge-workbench/src/femforge_workbench/registry.py
def script_module() -> str:
    """The module name the running script imports as: its `-m` name, else its file's stem, since the script's directory leads sys.path."""
    main = sys.modules[MAIN_MODULE]
    if main.__spec__ is not None:
        return main.__spec__.name

    file = getattr(main, "__file__", None)
    if not isinstance(file, str):
        raise ValueError("an interactive session has no module a workbench can be imported from")

    return Path(file).stem

target_of

target_of(target: Target) -> str

The pkg.module:attr string of a target; a function defined in the running script is named by the module its file imports as.

Source code in packages/femforge-workbench/src/femforge_workbench/registry.py
def target_of(target: Target) -> str:
    """The `pkg.module:attr` string of a target; a function defined in the running script is named by the module its file imports as."""
    if isinstance(target, str):
        return target

    name = target.__qualname__
    if not name.isidentifier():
        raise ValueError(f"{name} is not a module attribute; define the workbench function at module level so a save can import it again")

    module = script_module() if target.__module__ == MAIN_MODULE else target.__module__

    return f"{module}:{name}"

script

script(name: str, title: str, *, target: str, description: str = '', group: str = '', args: Sequence[str] = (), cwd: Path | None = None, outputs: Sequence[OutputSpec] = ()) -> WorkbenchSpec

A script card, run in a subprocess.

Parameters:

  • name (str) –

    The card's unique name, which the command line opens it by.

  • title (str) –

    The title the card and the title menu show.

  • target (str) –

    The module run with -m.

  • description (str, default: '' ) –

    The one-line summary under the title.

  • group (str, default: '' ) –

    The catalogue group the card is listed under.

  • args (Sequence[str], default: () ) –

    The arguments the script runs with.

  • cwd (Path | None, default: None ) –

    The directory the script runs in; None keeps the shell's.

  • outputs (Sequence[OutputSpec], default: () ) –

    The files the script produces, which the launcher shows.

Source code in packages/femforge-workbench/src/femforge_workbench/registry.py
def script(name: str, title: str, *, target: str, description: str = "", group: str = "", args: Sequence[str] = (), cwd: Path | None = None, outputs: Sequence[OutputSpec] = ()) -> WorkbenchSpec:
    """A script card, run in a subprocess.

    Args:
        name: The card's unique name, which the command line opens it by.
        title: The title the card and the title menu show.
        target: The module run with `-m`.
        description: The one-line summary under the title.
        group: The catalogue group the card is listed under.
        args: The arguments the script runs with.
        cwd: The directory the script runs in; None keeps the shell's.
        outputs: The files the script produces, which the launcher shows.
    """
    return WorkbenchSpec(
        name=name,
        title=title,
        kind=WorkbenchKind.SCRIPT,
        target=target,
        description=description,
        group=group,
        args=tuple(args),
        cwd=cwd,
        outputs=tuple(outputs),
    )

panel

panel(name: str, title: str, *, target: Target, description: str = '', group: str = '', args: Sequence[str] = ()) -> WorkbenchSpec

A dock card.

Parameters:

  • name (str) –

    The card's unique name, which the command line opens it by.

  • title (str) –

    The title the card and the title menu show.

  • target (Target) –

    A module-level function returning the dock Split, or its pkg.module:attr name.

  • description (str, default: '' ) –

    The one-line summary under the title.

  • group (str, default: '' ) –

    The catalogue group the card is listed under.

  • args (Sequence[str], default: () ) –

    The arguments the card's command passes to the workbench command line.

Source code in packages/femforge-workbench/src/femforge_workbench/registry.py
def panel(name: str, title: str, *, target: Target, description: str = "", group: str = "", args: Sequence[str] = ()) -> WorkbenchSpec:
    """A dock card.

    Args:
        name: The card's unique name, which the command line opens it by.
        title: The title the card and the title menu show.
        target: A module-level function returning the dock Split, or its `pkg.module:attr` name.
        description: The one-line summary under the title.
        group: The catalogue group the card is listed under.
        args: The arguments the card's command passes to the workbench command line.
    """
    return WorkbenchSpec(
        name=name,
        title=title,
        kind=WorkbenchKind.PANEL,
        target=target_of(target),
        description=description,
        group=group,
        args=tuple(args),
    )

renderable

renderable(name: str, title: str, *, target: Target, description: str = '', group: str = '', args: Sequence[str] = ()) -> WorkbenchSpec

A viewer card, rebuilt on save.

Parameters:

  • name (str) –

    The card's unique name, which the command line opens it by.

  • title (str) –

    The title the card and the title menu show.

  • target (Target) –

    A module-level function returning the Renderable, or its pkg.module:attr name.

  • description (str, default: '' ) –

    The one-line summary under the title.

  • group (str, default: '' ) –

    The catalogue group the card is listed under.

  • args (Sequence[str], default: () ) –

    The arguments the card's command passes to the workbench command line.

Source code in packages/femforge-workbench/src/femforge_workbench/registry.py
def renderable(name: str, title: str, *, target: Target, description: str = "", group: str = "", args: Sequence[str] = ()) -> WorkbenchSpec:
    """A viewer card, rebuilt on save.

    Args:
        name: The card's unique name, which the command line opens it by.
        title: The title the card and the title menu show.
        target: A module-level function returning the Renderable, or its `pkg.module:attr` name.
        description: The one-line summary under the title.
        group: The catalogue group the card is listed under.
        args: The arguments the card's command passes to the workbench command line.
    """
    return WorkbenchSpec(
        name=name,
        title=title,
        kind=WorkbenchKind.RENDERABLE,
        target=target_of(target),
        description=description,
        group=group,
        args=tuple(args),
    )

legacy

legacy(module: str) -> WorkbenchSpec

The spec of a module following the old main() -> Renderable contract.

Source code in packages/femforge-workbench/src/femforge_workbench/registry.py
def legacy(module: str) -> WorkbenchSpec:
    """The spec of a module following the old `main() -> Renderable` contract."""
    name = module.rsplit(".", 1)[-1]

    return renderable(name, name.replace("_", " "), target=f"{module}:{LEGACY_ATTR}", description=f"{module}.main()")

discover

discover(entry_points: Sequence[EntryPointLike] | None = None) -> list[WorkbenchSpec]

Every spec: the built-ins plus what each entry point in the group provides (a spec or a sequence), sorted by group and title; a name given two different specs is an error.

Source code in packages/femforge-workbench/src/femforge_workbench/registry.py
def discover(entry_points: Sequence[EntryPointLike] | None = None) -> list[WorkbenchSpec]:
    """Every spec: the built-ins plus what each entry point in the group provides (a spec or a sequence), sorted by group and title; a name given two different specs is an error."""
    points: Sequence[EntryPointLike] = entry_points if entry_points is not None else list(metadata.entry_points(group=ENTRY_POINT_GROUP))
    specs = list(BUILTIN)

    def provided(point: EntryPointLike) -> list[WorkbenchSpec]:
        loaded = point.load()
        kind = type(loaded).__name__
        if isinstance(loaded, WorkbenchSpec):
            return [loaded]

        if isinstance(loaded, Sequence):
            items = cast("Sequence[object]", loaded)
            if all(isinstance(item, WorkbenchSpec) for item in items):
                return [cast("WorkbenchSpec", item) for item in items]

        raise TypeError(f"entry point {point.name!r} in {ENTRY_POINT_GROUP} must provide a WorkbenchSpec or a sequence of them, got {kind}")

    for point in points:
        for spec in provided(point):
            same_name = next((known for known in specs if known.name == spec.name), None)
            if same_name is None:
                specs.append(spec)
                continue

            if same_name != spec:
                raise ValueError(f"workbench name {spec.name!r} is provided twice with different specs ({same_name.target} and {spec.target})")

    return sorted(specs, key=lambda spec: (spec.group, spec.title))

femforge_workbench.runner

Script runs on the server loop: each catalogue script as a subprocess whose lines stream as they arrive and whose process tree dies on stop.

Type Aliases:

  • LineHandler –

    Receives the task name and one output line without its newline.

  • TaskHandler –

    Receives the task after it started and again after it exited.

Classes:

  • TaskStatus –

    Where a script run stands.

  • TaskField –

    The keys of a task row in the client state.

  • Task –

    One script run: its process, when it started and how it ended.

  • ScriptRunner –

    Starts named commands, streaming their output lines and reporting each status change.

Attributes:

  • LINE_LIMIT –

    Bytes one streamed line may hold before the reader gives up on it.

  • STREAM_ENV –

    Makes the child flush every line as it is written, in UTF-8 whatever the console code page, laid out for a wide terminal.

LINE_LIMIT module-attribute

LINE_LIMIT = 1 << 20

Bytes one streamed line may hold before the reader gives up on it.

STREAM_ENV module-attribute

STREAM_ENV = {'PYTHONUNBUFFERED': '1', 'PYTHONIOENCODING': 'utf-8', 'COLUMNS': '200'}

Makes the child flush every line as it is written, in UTF-8 whatever the console code page, laid out for a wide terminal.

LineHandler

LineHandler = Callable[[str, str], None]

Receives the task name and one output line without its newline.

TaskHandler

TaskHandler = Callable[[Task], None]

Receives the task after it started and again after it exited.

TaskStatus

Bases: StrEnum

Where a script run stands.

TaskField

Bases: StrEnum

The keys of a task row in the client state.

Task dataclass

Task(name: str, process: Process, tree: ProcessTree, started: datetime = now(), status: TaskStatus = RUNNING, returncode: int | None = None, stopping: bool = False)

One script run: its process, when it started and how it ended.

Methods:

  • row –

    The task as a row of the client state.

Attributes:

  • pid (int) –

    The process id.

pid property

pid: int

The process id.

row

row() -> dict[str, object]

The task as a row of the client state.

Source code in packages/femforge-workbench/src/femforge_workbench/runner.py
def row(self) -> dict[str, object]:
    """The task as a row of the client state."""
    return {
        TaskField.NAME: self.name,
        TaskField.PID: self.pid,
        TaskField.STARTED: self.started.isoformat(timespec="seconds"),
        TaskField.STATUS: str(self.status),
        TaskField.RETURNCODE: self.returncode,
    }

ScriptRunner

ScriptRunner(on_line: LineHandler, on_change: TaskHandler)

Starts named commands, streaming their output lines and reporting each status change.

Methods:

  • running –

    Whether the named task runs.

  • rows –

    Every task as a row of the client state.

  • run –

    Runs the command to completion, streaming its lines; a name still running is an error.

  • stop –

    Kills the task's process tree; its run then ends as stopped.

Source code in packages/femforge-workbench/src/femforge_workbench/runner.py
def __init__(self, on_line: LineHandler, on_change: TaskHandler) -> None:
    """Takes the handlers every task's lines and status changes go to."""
    self.on_line = on_line
    self.on_change = on_change
    self.tasks: dict[str, Task] = {}

running

running(name: str) -> bool

Whether the named task runs.

Source code in packages/femforge-workbench/src/femforge_workbench/runner.py
def running(self, name: str) -> bool:
    """Whether the named task runs."""
    task = self.tasks.get(name)

    return task is not None and task.status is TaskStatus.RUNNING

rows

rows() -> list[dict[str, object]]

Every task as a row of the client state.

Source code in packages/femforge-workbench/src/femforge_workbench/runner.py
def rows(self) -> list[dict[str, object]]:
    """Every task as a row of the client state."""
    return [task.row() for task in self.tasks.values()]

run async

run(name: str, command: Sequence[str], *, cwd: Path | None = None) -> Task

Runs the command to completion, streaming its lines; a name still running is an error.

Parameters:

  • name (str) –

    The task name, unique among the running tasks.

  • command (Sequence[str]) –

    The command and its arguments.

  • cwd (Path | None, default: None ) –

    The directory the command runs in; None keeps the server's.

Source code in packages/femforge-workbench/src/femforge_workbench/runner.py
async def run(self, name: str, command: Sequence[str], *, cwd: Path | None = None) -> Task:
    """Runs the command to completion, streaming its lines; a name still running is an error.

    Args:
        name: The task name, unique among the running tasks.
        command: The command and its arguments.
        cwd: The directory the command runs in; None keeps the server's.
    """
    if self.running(name):
        raise RuntimeError(f"{name} is already running")

    process = await asyncio.create_subprocess_exec(
        *command,
        cwd=cwd,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.STDOUT,
        env={**os.environ, **STREAM_ENV},
        limit=LINE_LIMIT,
        creationflags=CREATION_FLAGS,
        start_new_session=NEW_SESSION,
    )
    tree = ProcessTree.adopt(process.pid)
    task = Task(name, process, tree)
    self.tasks[name] = task
    log.bind(task=name, pid=task.pid, command=list(command)).info("Script started.")
    self.on_change(task)

    stdout = process.stdout
    if stdout is None:
        raise RuntimeError(f"{name} has no output pipe")

    try:
        async for raw in stdout:
            self.on_line(name, raw.decode("utf-8", errors="replace").rstrip("\r\n"))

        task.returncode = await process.wait()
    finally:
        tree.close()

    if task.stopping:
        task.status = TaskStatus.STOPPED
    else:
        task.status = TaskStatus.DONE if task.returncode == 0 else TaskStatus.FAILED

    log.bind(task=name, returncode=task.returncode, status=str(task.status)).info("Script exited.")
    self.on_change(task)

    return task

stop async

stop(name: str) -> None

Kills the task's process tree; its run then ends as stopped.

Source code in packages/femforge-workbench/src/femforge_workbench/runner.py
async def stop(self, name: str) -> None:
    """Kills the task's process tree; its run then ends as stopped."""
    task = self.tasks.get(name)
    if task is None or task.status is not TaskStatus.RUNNING:
        return

    task.stopping = True
    signalled = await asyncio.to_thread(task.tree.kill)
    log.bind(task=name, processes=signalled).info("Script stopped.")

femforge_workbench.process_tree

One child process and everything it starts, ended together: a job object on Windows, a process group elsewhere.

Classes:

  • ProcessTree –

    One process and every process it starts: a job object on Windows, the process group the leader heads elsewhere.

Attributes:

  • WINDOWS (Final) –

    Whether a tree is a job object (Windows) or a process group (elsewhere).

  • CREATION_FLAGS (Final) –

    creationflags for Popen: CREATE_SUSPENDED on Windows, so the child waits until it is in its job and nothing it starts can escape; 0 elsewhere.

  • NEW_SESSION (Final) –

    start_new_session for Popen: on POSIX the child leads a process group of its own, whose id is its pid.

  • KILL_GRACE (Final) –

    Seconds a terminated tree gets before what is left is killed.

  • POLL (Final) –

    Seconds between looks at a tree that was asked to exit.

WINDOWS module-attribute

WINDOWS: Final = sys.platform == 'win32'

Whether a tree is a job object (Windows) or a process group (elsewhere).

CREATION_FLAGS module-attribute

CREATION_FLAGS: Final = 4 if WINDOWS else 0

creationflags for Popen: CREATE_SUSPENDED on Windows, so the child waits until it is in its job and nothing it starts can escape; 0 elsewhere.

NEW_SESSION module-attribute

NEW_SESSION: Final = not WINDOWS

start_new_session for Popen: on POSIX the child leads a process group of its own, whose id is its pid.

KILL_GRACE module-attribute

KILL_GRACE: Final = 2.0

Seconds a terminated tree gets before what is left is killed.

POLL module-attribute

POLL: Final = 0.05

Seconds between looks at a tree that was asked to exit.

ProcessTree dataclass

ProcessTree(pid: int, job: int = 0)

One process and every process it starts: a job object on Windows, the process group the leader heads elsewhere.

Methods:

  • adopt –

    Takes a process spawned with CREATION_FLAGS and NEW_SESSION: into a new job and let run on Windows, checked to lead its own group elsewhere.

  • kill –

    Ends every process in the tree and returns how many there were; POSIX asks first and kills what is left after grace seconds, Windows has no gentle signal.

  • close –

    Ends anything still in the tree and releases it: a job dies with its handle, a group gets SIGKILL.

Attributes:

  • pid (int) –

    The process the tree was built around; on POSIX also the group id.

  • job (int) –

    The Windows job handle; 0 on POSIX, where a group needs none.

pid instance-attribute

pid: int

The process the tree was built around; on POSIX also the group id.

job class-attribute instance-attribute

job: int = 0

The Windows job handle; 0 on POSIX, where a group needs none.

adopt classmethod

adopt(pid: int) -> ProcessTree

Takes a process spawned with CREATION_FLAGS and NEW_SESSION: into a new job and let run on Windows, checked to lead its own group elsewhere.

Source code in packages/femforge-workbench/src/femforge_workbench/process_tree.py
@classmethod
def adopt(cls, pid: int) -> "ProcessTree":
    """Takes a process spawned with `CREATION_FLAGS` and `NEW_SESSION`: into a new job and let run on Windows, checked to lead its own group elsewhere."""
    if not WINDOWS:
        if os.getpgid(pid) != pid:
            raise ValueError(f"process {pid} does not lead its own group")

        return cls(pid)

    process = _checked("OpenProcess", _SET_QUOTA_AND_TERMINATE, False, pid)
    try:
        job = _checked("CreateJobObjectW", None, None)
        limits = _ExtendedLimits()
        limits.BasicLimitInformation.LimitFlags = _KILL_ON_JOB_CLOSE
        _ = _checked("SetInformationJobObject", job, _EXTENDED_LIMITS, ctypes.byref(limits), ctypes.sizeof(limits))
        _ = _checked("AssignProcessToJobObject", job, process)
    except OSError:
        _ = _win32("TerminateProcess", process, 1)
        raise
    finally:
        _ = _win32("CloseHandle", process)

    psutil.Process(pid).resume()

    return cls(pid, job)

kill

kill(grace: float = KILL_GRACE) -> int

Ends every process in the tree and returns how many there were; POSIX asks first and kills what is left after grace seconds, Windows has no gentle signal.

Source code in packages/femforge-workbench/src/femforge_workbench/process_tree.py
def kill(self, grace: float = KILL_GRACE) -> int:
    """Ends every process in the tree and returns how many there were; POSIX asks first and kills what is left after `grace` seconds, Windows has no gentle signal."""
    if WINDOWS:
        accounting = _BasicAccounting()
        _ = _checked("QueryInformationJobObject", self.job, _BASIC_ACCOUNTING, ctypes.byref(accounting), ctypes.sizeof(accounting), None)
        _ = _checked("TerminateJobObject", self.job, 1)

        return accounting.ActiveProcesses

    held = len(self._members())
    if held == 0:
        return 0

    with suppress(ProcessLookupError):
        os.killpg(self.pid, signal.SIGTERM)

    deadline = time.monotonic() + grace
    while self._members() and time.monotonic() < deadline:
        time.sleep(POLL)

    with suppress(ProcessLookupError):
        os.killpg(self.pid, signal.SIGKILL)

    return held

close

close() -> None

Ends anything still in the tree and releases it: a job dies with its handle, a group gets SIGKILL.

Source code in packages/femforge-workbench/src/femforge_workbench/process_tree.py
def close(self) -> None:
    """Ends anything still in the tree and releases it: a job dies with its handle, a group gets SIGKILL."""
    if WINDOWS:
        _ = _win32("CloseHandle", self.job)

        return

    if self._members():
        with suppress(ProcessLookupError):
            os.killpg(self.pid, signal.SIGKILL)