diff --git a/.codex/AGENTS.md b/.codex/AGENTS.md index f673d128..ec2d073c 100644 --- a/.codex/AGENTS.md +++ b/.codex/AGENTS.md @@ -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 diff --git a/docs/developer_guide.md b/docs/developer_guide.md index 3ff6a918..057f82a6 100644 --- a/docs/developer_guide.md +++ b/docs/developer_guide.md @@ -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\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 diff --git a/scripts/check_docs_harness.py b/scripts/check_docs_harness.py index 77128448..3579704a 100644 --- a/scripts/check_docs_harness.py +++ b/scripts/check_docs_harness.py @@ -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: diff --git a/src/myna/core/app/base.py b/src/myna/core/app/base.py index 8b3dfc5c..83d912fa 100644 --- a/src/myna/core/app/base.py +++ b/src/myna/core/app/base.py @@ -10,6 +10,8 @@ import argparse import os +import re +import shlex import sys import time import shutil @@ -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 @@ -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 diff --git a/tests/test_app_versioning.py b/tests/test_app_versioning.py new file mode 100644 index 00000000..6ca11a11 --- /dev/null +++ b/tests/test_app_versioning.py @@ -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\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\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\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\d+\.\d+\.\d+)", + ) + + assert version == "5.6.7"