Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .codex/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,9 @@ When asked to draft or open a pull request, first inspect and complete
`.github/pull_request_template.md`. Preserve the template headings and checklist, and
state behavior impact, risk, and testing strategy. If the requested PR body is scoped
to a subset of commits while the branch contains other commits, state that scope
explicitly in the body.
explicitly in the body. This applies even when the user asks only for a PR title, PR
body, or commit/PR text; never draft a free-form PR body when the repository provides
a pull request template.

## Security and Data Handling

Expand Down
15 changes: 15 additions & 0 deletions docs/developer_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,21 @@ such as `self.input_file`, `self.step_name`, `self.last_step_name`, and
`self.step_index`. Avoid reading `MYNA_*` environment variables directly in new app
code; those names remain only as a compatibility fallback for direct stage invocation.

Apps that wrap an external executable can query that executable's version with
`self.get_executable_version()`. Call it after parsing stage arguments so user-provided
`--exec`, `--env`, and Docker settings are available. For example:

```python
version = self.get_executable_version(
"additiveFoam",
version_args=[],
version_regex=r"Version: (?P<version>\S+)",
)
```

The returned string can then be used to select a template or raise a clear
compatibility error.

It is likely that your app will require a `template` directory, or a set of input
files for your model that get copied into every case. If you are using a template
directory, then the intended functionality is that during `configure.py` the template
Expand Down
3 changes: 3 additions & 0 deletions scripts/check_docs_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ def check_pr_template_guidance() -> None:
".github/pull_request_template.md",
"Preserve the template headings and checklist",
"state behavior impact, risk, and testing strategy",
"even when the user asks only for a PR title",
"commit/PR text",
"never draft a free-form PR body",
]
for phrase in required_phrases:
if phrase not in agents_text:
Expand Down
155 changes: 152 additions & 3 deletions src/myna/core/app/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

import argparse
import os
import re
import shlex
import sys
import time
import shutil
Expand Down Expand Up @@ -341,9 +343,7 @@ def validate_executable(self, default):
"""Check if the specified executable exists and raise error if not"""

# Get the name of the executable
exe = self.args.exec
if exe is None:
exe = default
exe = self.get_executable(default)
exe_windows = exe + ".exe" # Try a Windows exe just in case

# If an executable is found, return
Expand All @@ -370,6 +370,155 @@ def validate_executable(self, default):
+ "does not have execute permissions."
)

def get_executable(self, default=None):
"""Return the configured executable, falling back to ``default``.

Args:
default: executable name to use when ``--exec`` was not specified

Returns:
str: executable path or command name
"""

exe = self.args.exec
if exe is None:
exe = default
if exe is None:
raise ValueError(
f"MynaApp {self.name} requires an executable, but no executable "
"was provided and no default was set."
)
return exe

def get_executable_version(
self,
default=None,
version_args=("--version",),
version_regex=None,
timeout=30,
):
"""Return a version identifier from the configured executable.

Subclasses can call this method after parsing stage arguments, or override it
when an executable requires custom version discovery. ``version_regex`` may
contain a named ``version`` group; otherwise the first capture group is used.
If no regex is provided, the first non-empty output line is returned.

Args:
default: executable name to use when ``--exec`` was not specified
version_args: arguments passed to the executable for version discovery
version_regex: optional regular expression used to extract the identifier
timeout: maximum time, in seconds, to wait for the executable

Returns:
str: extracted version identifier
"""

executable = self.get_executable(default)
version_args = [] if version_args is None else list(version_args)
cmd_args = [executable, *version_args]

output, returncode = self._run_executable_version_command(
cmd_args,
timeout=timeout,
)
try:
return self.extract_executable_version(output, version_regex)
except ValueError as exc:
if returncode != 0:
raise RuntimeError(
f"Could not determine version for {self.name} executable "
f'"{executable}". Version command exited with return code '
f"{returncode}."
) from exc
raise

def extract_executable_version(self, output, version_regex=None):
"""Extract a version identifier from executable output."""

output = output.strip()
if not output:
raise ValueError("Executable version output was empty.")

if version_regex is None:
for line in output.splitlines():
line = line.strip()
if line:
return line

match = re.search(version_regex, output, flags=re.MULTILINE)
if match is None:
raise ValueError(
f"Could not extract executable version using pattern {version_regex!r}."
)
version_group = match.groupdict().get("version")
if version_group is not None:
return version_group.strip()
if match.groups():
for group in match.groups():
if group is not None:
return group.strip()
return match.group(0).strip()

def _run_executable_version_command(self, cmd_args, timeout=30):
"""Run an executable version command and return combined output and code."""

if self.args.docker_image is not None:
return self._run_docker_executable_version_command(cmd_args)

if self.args.env is not None:
cmd_arg_str = " ".join(shlex.quote(str(x)) for x in cmd_args)
env_arg_str = shlex.quote(str(self.args.env))
cmd_args = f". {env_arg_str}; {cmd_arg_str}"
completed = subprocess.run(
cmd_args,
shell=True,
check=False,
capture_output=True,
text=True,
timeout=timeout,
)
else:
completed = subprocess.run(
[str(x) for x in cmd_args],
check=False,
capture_output=True,
text=True,
timeout=timeout,
)

output = "\n".join(x for x in [completed.stdout, completed.stderr] if x)
return output, completed.returncode

def _run_docker_executable_version_command(self, cmd_args):
"""Run an executable version command inside the configured Docker image."""

cmd_arg_str = " ".join(shlex.quote(str(x)) for x in cmd_args)
if self.args.env is not None:
cmd_arg_str = f". {shlex.quote(str(self.args.env))}; {cmd_arg_str}"

client = docker.from_env()
try:
output = client.containers.run(
self.args.docker_image,
["-c", cmd_arg_str],
entrypoint="bash",
remove=True,
stdout=True,
stderr=True,
)
return output.decode("utf-8", errors="replace"), 0
except docker.errors.ContainerError as exc:
output = b"".join(
x
for x in [
getattr(exc, "stdout", None),
getattr(exc, "stderr", None),
]
if x
)
return output.decode("utf-8", errors="replace"), exc.exit_status

def _set_procs(self):
"""Set processor information based on the `maxproc` and `np` inputs. If the
available CPU count can be determined by `os.cpu_count()` then it will be used
Expand Down
109 changes: 109 additions & 0 deletions tests/test_app_versioning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#
# Copyright (c) Oak Ridge National Laboratory.
#
# This file is part of Myna. For details, see the top-level license
# at https://github.com/ORNL-MDF/Myna/LICENSE.md.
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause.
#
import stat
import sys

import pytest

from myna.core.app.base import MynaApp


def _write_shell_executable(path, body):
path.write_text(f"#!/bin/sh\n{body}", encoding="utf-8")
path.chmod(path.stat().st_mode | stat.S_IXUSR)


def test_get_executable_version_extracts_named_regex_group(monkeypatch, tmp_path):
exe = tmp_path / "solver"
_write_shell_executable(
exe,
'printf "%s\\n" "MynaSolver version 2.3.4"\n',
)
monkeypatch.setattr(sys, "argv", ["test", "--exec", str(exe)])
app = MynaApp()

version = app.get_executable_version(
version_args=[],
version_regex=r"version (?P<version>\d+\.\d+\.\d+)",
)

assert version == "2.3.4"


def test_get_executable_version_uses_first_nonempty_line(monkeypatch, tmp_path):
exe = tmp_path / "solver"
_write_shell_executable(
exe,
'printf "%s\\n%s\\n" "" "MynaSolver 2026.05"\n',
)
monkeypatch.setattr(sys, "argv", ["test", "--exec", str(exe)])
app = MynaApp()

version = app.get_executable_version(version_args=[])

assert version == "MynaSolver 2026.05"


def test_get_executable_version_reads_stderr_from_nonzero_command(
monkeypatch,
tmp_path,
):
exe = tmp_path / "solver"
_write_shell_executable(
exe,
'printf "%s\\n" "MynaSolver version 2026.05" >&2\nexit 1\n',
)
monkeypatch.setattr(sys, "argv", ["test", "--exec", str(exe)])
app = MynaApp()

version = app.get_executable_version(
version_args=[],
version_regex=r"version (?P<version>\S+)",
)

assert version == "2026.05"


def test_get_executable_version_raises_for_unmatched_pattern(monkeypatch, tmp_path):
exe = tmp_path / "solver"
_write_shell_executable(
exe,
'printf "%s\\n" "MynaSolver build unknown"\n',
)
monkeypatch.setattr(sys, "argv", ["test", "--exec", str(exe)])
app = MynaApp()

with pytest.raises(ValueError, match="Could not extract executable version"):
app.get_executable_version(
version_args=[],
version_regex=r"version (?P<version>\S+)",
)


def test_get_executable_version_honors_env_file(monkeypatch, tmp_path):
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
exe = bin_dir / "solver"
_write_shell_executable(
exe,
'printf "%s\\n" "MynaSolver version 5.6.7"\n',
)
env_file = tmp_path / "solver_env.sh"
env_file.write_text(f'PATH="{bin_dir}:$PATH"\nexport PATH\n', encoding="utf-8")

monkeypatch.setattr(sys, "argv", ["test", "--env", str(env_file)])
app = MynaApp()

version = app.get_executable_version(
default="solver",
version_args=[],
version_regex=r"version (?P<version>\d+\.\d+\.\d+)",
)

assert version == "5.6.7"
Loading