Skip to content
7 changes: 5 additions & 2 deletions src/opi/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from typing import Any, cast

from opi.execution.core import Runner
from opi.execution.text_stream import StreamTargetSpec
from opi.input.blocks.block_output import BlockOutput
from opi.input.core import Input
from opi.input.structures.structure import Structure
Expand Down Expand Up @@ -273,7 +274,9 @@ def _create_runner(self) -> "Runner":
"""Create a `Runner` object passing on `self.working_dir`."""
return Runner(working_dir=self.working_dir)

def run(self, *, timeout: int = -1) -> bool:
def run(
self, *, stdout: StreamTargetSpec = (), stderr: StreamTargetSpec = (), timeout: int = -1
) -> bool:
"""
Execute ORCA calculation.

Expand All @@ -290,7 +293,7 @@ def run(self, *, timeout: int = -1) -> bool:
"""
runner = self._create_runner()
assert self.inpfile
runner.run_orca(self.inpfile, timeout=timeout)
runner.run_orca(self.inpfile, stdout=stdout, stderr=stderr, timeout=timeout)
output = self.get_output()
return output.terminated_normally()

Expand Down
217 changes: 131 additions & 86 deletions src/opi/execution/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,19 @@
import os
import shutil
import subprocess
import warnings
from collections.abc import Callable, Sequence
from contextlib import nullcontext
from io import TextIOWrapper
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from subprocess import CompletedProcess
from typing import Any, Concatenate, ParamSpec, TypeVar, cast
from typing import Any, Concatenate, Final, ParamSpec, TypeVar, cast

from opi import ORCA_MINIMAL_VERSION
from opi.execution.run import run_subprocess_with_fanout
from opi.execution.text_stream import StreamTargetSpec
from opi.lib.orca_binary import OrcaBinary
from opi.utils.config import get_config
from opi.utils.misc import add_to_env, check_minimal_version, delete_empty_file, resolve_binary_name
from opi.utils.misc import add_to_env, check_minimal_version, resolve_binary_name
from opi.utils.orca_version import OrcaVersion

RunnerType = TypeVar("RunnerType", bound="BaseRunner")
Expand Down Expand Up @@ -73,6 +75,40 @@ def wrapper(self: RunnerType, /, *args: Any, **kwargs: Any) -> R:
return wrapper


class _Unset(Enum):
Value = "UNSET"


UNSET: Final = _Unset.Value


@dataclass(frozen=True)
class RunResult:
binary: str
args: tuple[str, ...]
returncode: int
stdout: str
stderr: str

def returncode_ok(self) -> bool:
"""Check for zero exit code"""
return self.returncode == 0

def check_returncode(self) -> None:
"""Raise RuntimeError exit code is non-zero."""
if not self.returncode_ok():
raise RuntimeError(
f"Running '{self.binary}' with arguments {self.args} failed with exit code: {self.returncode}"
) # > TODO: change to OpiExecutionError when PR #224 merged

def get_signal(self) -> int | None:
"""Check and return IPC signals."""
if self.returncode < 0:
return abs(self.returncode)
else:
return None


class BaseRunner:
"""
Base class that facilitates the execution of ORCA binaries.
Expand Down Expand Up @@ -137,115 +173,120 @@ def run(
args: Sequence[str] = (),
/,
*,
stdin_str: str | None = None,
stdout: Path | None = None,
stderr: Path | None = None,
silent: bool = True,
capture: bool = False,
stdin: str | None = None,
stdout: StreamTargetSpec = (),
stderr: StreamTargetSpec = (),
cwd: Path | None = None,
timeout: int = -1,
) -> subprocess.CompletedProcess[str] | None:
timeout: float | None = None,
# deprecated
stdin_str: _Unset | str | None = UNSET,
capture: _Unset | bool = UNSET,
silent: _Unset | bool = UNSET,
) -> RunResult:
"""
Function that executes ORCA binary.

The `stdout` and `stderr` arguments can be one or more `StreamTarget`.
A stream target can be a file object, a path to a file to write to, a callable
or the output can be captured into the `RunResult` using the target `subprocess.PIPE`.

Parameters
----------
binary : OrcaBinary
Name of ORCA binary to be executed. Path is automatically resolved based on configuration.
args : Sequence[str], default: ()
Command line arguments to pass to ORCA binary.
stdin_str: str | None = None
Command line arguments to pass to ORCA binary
stdin : str | None, default: None
String to be passed to stdin.
stdout : Path | None, default: None
Dump STDOUT to a file.
stderr : Path | None, default: None
Dump STDERR to a file.
silent : bool, default: True
Redirect STDOUT and STDERR to null-device.
Is overruled respectively by `stdout` and `stderr` and `capture`.
capture : bool, default: False
Capture STDOUT and STDERR and return with `CompletedProcess[str]` object.
Is overruled respectively by `stdout` and `stderr`.
stdout : StreamTargetSpec, default: ()
One or more target streams to pipe the subprocess `stdout` to.
stderr : StreamTargetSpec, default: ()
One or more target streams to pipe the subprocess `stderr` to.
cwd : Path | None, default: None
Set working directory for execution. Overrules `self.working_dir`.
timeout : int, default: -1
Optional timeout in seconds to wait for process to complete.
timeout : float | None, by default None
Optional timeout in seconds to wait for process to complete. None or
negative timeout denotes no timeout.
stdin_str : _Unset | str | None, default: UNSET
DEPRECATED, use `stdin` instead.
capture : _Unset | bool, default: UNSET
DEPRECATED, use `stdout`/`stderr`=`subprocess.PIPE` instead.
silent : _Unset | bool, default: UNSET
DEPRECATED, use `stdout`/`stderr`=`()` instead.

Returns
-------
subprocess.subprocess.CompletedProcess[str] | None:
Completed ORCA process.
RunResult
Completed ORCA run result.

Raises
------
ValueError
Raised if an invalid ORCA binary is passed in.
FileNotFound:
Error if path to ORCA binary cannot be resolved.
subprocess.TimeoutExpired:
If `timeout>-1` and the process times out.
Error if path to ORCA binary cannot be resolved.
subprocess.TimeoutExpired
Raised by `run_subprocess_with_fanout` if a timeout is set and the process
times out.
BaseException
Raised by `run_subprocess_with_fanout`, after the process has finished, the
first error accumulated from a failed write is raised.
"""

# ------------------------------------------------------------
def determine_dump(source: Path | None = None) -> TextIOWrapper:
"""
Determine where to dump `source` to.

Parameters
----------
source : Path | None, default: None
"""

if source:
return source.open("w")
elif capture:
return cast(TextIOWrapper, nullcontext(subprocess.PIPE))
elif silent:
return Path(os.devnull).open("w")
else:
return cast(TextIOWrapper, nullcontext())

# ------------------------------------------------------------
if stdin_str is not UNSET:
warnings.warn(
"`stdin_str` is deprecated; use `stdin` instead.",
DeprecationWarning,
stacklevel=2,
)
stdin = stdin_str
if capture is not UNSET:
warnings.warn(
"`capture` is deprecated; pass stdout=subprocess.PIPE and/or stderr=subprocess.PIPE instead.",
DeprecationWarning,
stacklevel=2,
)
# TODO: should we add `subprocess.PIPE` to `stdout` and `stderr` here?
if silent is not UNSET:
warnings.warn(
"`silent` is deprecated; omit stdout/stderr for silent execution.",
DeprecationWarning,
stacklevel=2,
)

# > Get requested ORCA binary
if not isinstance(binary, OrcaBinary):
raise ValueError(f"`binary` must be of type OrcaBinary, not: {type(binary)}")
orca_bin = str(self.get_orca_binary(binary))

# > Working dir
if not cwd:
cwd = self.working_dir
args = tuple(args)

# > Get requested ORCA binary
orca_bin = self.get_orca_binary(binary)
# > Assembling full call
cmd = (orca_bin,) + args

# > STDOUT and STDERR capturing/dumping
outfile = determine_dump(stdout)
errfile = determine_dump(stderr)
timeout_value = None if timeout is None or timeout < 0 else timeout

# > Assembling full call
cmd = [str(orca_bin)]
if args:
cmd += list(args)
# > Working dir
if not cwd:
cwd = self.working_dir

# Run the binary
proc = None
try:
with outfile as f_out, errfile as f_err:
proc = subprocess.run(
cmd,
input=stdin_str,
stdout=f_out,
stderr=f_err,
cwd=cwd,
text=True,
timeout=timeout if timeout > 0 else None,
)
return proc
except subprocess.TimeoutExpired:
raise
finally:
# > Delete empty STDOUT and STDERR dumps
if stdout:
delete_empty_file(stdout)
if stderr:
delete_empty_file(stderr)
result = run_subprocess_with_fanout(
cmd,
stdin=stdin,
stdout=stdout,
stderr=stderr,
timeout=timeout_value,
cwd=cwd,
)

return RunResult(
binary=binary,
args=args,
returncode=result.returncode,
stdout=result.stdout,
stderr=result.stderr,
)

def get_version(self) -> OrcaVersion | None:
"""
Expand All @@ -261,10 +302,14 @@ def get_version(self) -> OrcaVersion | None:

try:
# > May raise subprocess.TimeoutExpired
orca_proc = self.run(OrcaBinary.ORCA, ["--version"], capture=True, timeout=5)
orca_proc = self.run(
OrcaBinary.ORCA,
["--version"],
stdout=subprocess.PIPE,
timeout=5,
)

# > Pleasing type checker
assert isinstance(orca_proc, CompletedProcess)
return OrcaVersion.from_output(orca_proc.stdout)

except (subprocess.TimeoutExpired, ValueError, AssertionError):
Expand Down
Loading
Loading