"""Tools for running applications in the simtools framework."""
import glob
import hashlib
import importlib
import json
import logging
import os
import re
import shutil
import subprocess
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
import yaml
import simtools.utils.general as gen
from simtools import dependencies
from simtools import version as simtools_version
from simtools.constants import RUN_TIME_ENVIRONMENT_SCHEMA
from simtools.data_model import schema, workflow_metadata
from simtools.io import ascii_handler
from simtools.io.file_type import product_filename
from simtools.job_execution import execution
from simtools.job_execution.execution import JobExecutionError
from simtools.utils import value_conversion
logger = logging.getLogger(__name__)
_SIMULATION_MODELS_PATH = "simulation_models_path"
_SIMULATION_MODELS_GIT_PATH = "simulation_models_git_path"
_IGNORE_EXISTING_PARAMETER_VERSION = "ignore_existing_parameter_version"
_MODEL_SOURCE_OPTIONS = (
_SIMULATION_MODELS_PATH,
_SIMULATION_MODELS_GIT_PATH,
"simulation_models_git_revision",
)
_SCIENCE_PLACEHOLDER = re.compile(r"__[A-Z][A-Z0-9_]+__")
[docs]
def prepare_workflow(args_dict, replacements=None):
"""Resolve a workflow and its backend configuration without executing it."""
replacements = dict(replacements or {})
replacements.setdefault(
"__CONFIG_DIRECTORY__", str(Path(args_dict["config_file"]).resolve().parent)
)
configurations, runtime, log_file, activity, collection = _read_application_configuration(
args_dict["config_file"],
args_dict.get("steps"),
args_dict.get("activity_id"),
replacements=replacements,
)
resolve_nested_configuration_files(configurations, replacements)
if args_dict.get("runtime_environment") is not None:
runtime = args_dict["runtime_environment"]
if args_dict.get("log_file") is not None:
log_file = args_dict["log_file"]
_validate_resolved_configuration(configurations, runtime, collection)
return configurations, runtime, log_file, activity, collection
[docs]
def run_applications(args_dict, run_time=None, replacements=None, context=None):
"""
Run simtools applications step-by-step as defined in a configuration file.
Parameters
----------
args_dict : dict
Command line arguments. Optional keys: ``overwrite_collection_files`` (allow
overwriting collection files with the same basename) and ``provenance_path``
(False omits the resolved workflow file).
run_time : list or None
Prepared runtime command, reused instead of preparing the runtime environment.
replacements : dict or None
Placeholder replacements applied recursively; ``__CONFIG_DIRECTORY__`` is added
automatically.
context : RunContext, optional
Run context used to report software versions and collect workflow metadata.
"""
(
configurations,
runtime_environment,
log_file,
workflow_activity_id,
collection_config,
) = prepare_workflow(args_dict, replacements=replacements)
workflow_start = datetime.now(UTC)
associated_activities = []
runtime_environment_snapshot = deepcopy(runtime_environment)
model_parameter_metadata_files = []
source_options = model_source_options(args_dict)
apply_execution_overrides(
configurations, args_dict, source_options, get_explicit_env_file(args_dict)
)
effective_runtime = None if args_dict["ignore_runtime_environment"] else runtime_environment
_write_resolved_workflow(
args_dict["config_file"],
configurations,
effective_runtime,
collection_config,
provenance_path=args_dict.get("provenance_path"),
)
if args_dict["ignore_runtime_environment"]:
run_time = []
elif run_time is None:
run_time = read_runtime_environment(runtime_environment)
log_file = Path(log_file)
log_file.parent.mkdir(parents=True, exist_ok=True)
with log_file.open("w", encoding="utf-8") as file:
file.write("Running simtools applications\n")
try:
versions = dependencies.get_version_string(
run_time, include_software_versions=True, context=context
)
except FileNotFoundError as exc:
logger.info("Host simulation versions unavailable; using runtime identity: %s", exc)
versions = dependencies.get_version_string(
run_time, include_software_versions=False, context=context
)
file.write(versions)
try:
manifest = dependencies.get_dependency_manifest(run_time or None, context=context)
file.write(f"Runtime simtools: {manifest.get('simtools', {})}\n")
file.write(f"Runtime build options: {manifest.get('build_options', {})}\n")
except (OSError, ValueError, subprocess.SubprocessError) as exc:
file.write(f"Runtime identity unavailable: {exc}\n")
try:
for config in configurations:
app = config.get("application")
if not config.get("run_application"):
logger.info(f"Skipping application: {app}")
continue
app_configuration = config.get("configuration", {})
app_activity_id = app_configuration.get("activity_id") or gen.get_uuid()
app_configuration["activity_id"] = app_activity_id
app_configuration.setdefault("label", app)
app_configuration["disable_log_file"] = True
associated_activities.append({"activity_name": app, "activity_id": app_activity_id})
result = _submit_application_and_collect_metadata(
app=app,
app_configuration=app_configuration,
runtime_environment=run_time,
model_parameter_metadata_files=model_parameter_metadata_files,
)
file.write("=" * 80 + "\n")
file.write(
f"Application: {app}\nSTDOUT:\n{result.output}\n"
f"STDERR:\n{result.error_output}\n"
)
_copy_collection_files(
configurations,
collection_config,
overwrite_files=args_dict.get("overwrite_collection_files", False),
)
finally:
_update_workflow_metadata_files(
args_dict=args_dict,
workflow_activity_id=workflow_activity_id,
workflow_start=workflow_start,
runtime_environment_snapshot=runtime_environment_snapshot,
configurations=configurations,
model_parameter_metadata_files=model_parameter_metadata_files,
associated_activities=associated_activities,
context=context,
)
[docs]
def get_explicit_env_file(args_dict):
"""Return the runner env file when it was explicitly supplied on the CLI."""
configuration_sources = args_dict.get("_metadata_configuration_sources", {})
if configuration_sources.get("env_file") != "cli":
return None
return args_dict.get("env_file")
def _copy_collection_files(configurations, collection_config, overwrite_files=False):
"""Copy listed files to collection output paths, raising FileExistsError on basename clashes."""
if not collection_config:
return
entries = [collection_config] if isinstance(collection_config, dict) else collection_config
for entry in entries:
output_path = entry.get("output_path")
files = entry.get("files") or []
if output_path is None or not files:
continue
source_directories = _collect_source_directories(
configurations, source_directory=entry.get("source_directory")
)
collection_output_path = Path(output_path)
collection_output_path.mkdir(parents=True, exist_ok=True)
inventory = []
for file_spec in files:
inventory.extend(
_copy_pattern_files(
file_spec,
source_directories,
collection_output_path,
overwrite_files=overwrite_files,
preserve_relative_paths=entry.get("preserve_relative_paths", False),
)
)
if entry.get("write_inventory"):
inventory_path = Path(
entry.get("inventory_file", collection_output_path / "inventory.json")
)
inventory_path.parent.mkdir(parents=True, exist_ok=True)
inventory_path.write_text(json.dumps(inventory, indent=2) + "\n", encoding="utf-8")
def _copy_pattern_files(
pattern, source_directories, destination, overwrite_files=False, preserve_relative_paths=False
):
"""Copy all files matching a pattern from the source directories into a destination."""
destination_name = None
if isinstance(pattern, dict):
destination_name = pattern.get("destination")
pattern = pattern["source"]
_validate_collection_destination_name(destination_name)
if pattern == "**/comparison_statistics.meta.yml" and any(
(source_directory / product_filename("comparison_statistics")).is_file()
for source_directory in source_directories
):
return []
source_files = _find_collection_files(pattern, source_directories)
if destination_name is not None and len(source_files) != 1:
raise FileExistsError(
f"Collection destination '{destination_name}' requires exactly one source file, "
f"but pattern '{pattern}' matched {len(source_files)} files."
)
inventory = []
for source_file in source_files:
relative_path = _collection_relative_path(source_file, source_directories)
name = relative_path if preserve_relative_paths else source_file.name
dest = destination / (destination_name or name)
if not overwrite_files and dest.exists() and dest.resolve() != source_file.resolve():
raise FileExistsError(
f"Filename collision in collection: '{source_file.name}' would be "
f"overwritten by '{source_file}'. Ensure output files have unique names."
)
if dest.resolve() != source_file.resolve():
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source_file, dest)
inventory.append(
{
"source": str(relative_path),
"destination": str(dest.relative_to(destination)),
"sha256": _sha256(source_file),
}
)
return inventory
def _collection_relative_path(source_file, source_directories):
"""Return a portable path within the matching collection source root."""
for root in source_directories:
if source_file.resolve().is_relative_to(root.resolve()):
return source_file.resolve().relative_to(root.resolve())
raise ValueError(f"Collection source is outside its declared roots: {source_file}")
def _sha256(path):
"""Return the SHA-256 checksum of one collected artifact."""
with Path(path).open("rb") as file:
return hashlib.file_digest(file, "sha256").hexdigest()
def _validate_collection_destination_name(destination_name):
"""Validate an optional collection destination as a plain filename."""
if destination_name is None:
return
if (
not isinstance(destination_name, str)
or destination_name in {"", ".", ".."}
or Path(destination_name).name != destination_name
or "\\" in destination_name
):
raise ValueError(
"Collection destination must be a non-empty filename without path separators: "
f"{destination_name!r}"
)
def _collect_source_directories(configurations, source_directory=None):
"""Return unique source directories from application configurations."""
if source_directory is None:
keys = ["output_path"]
elif isinstance(source_directory, str):
keys = [source_directory]
else:
keys = list(source_directory)
source_directories = []
for config in configurations:
app_configuration = config.get("configuration", {})
for source_key in keys:
source_dir = app_configuration.get(source_key)
if source_dir is not None and Path(source_dir) not in source_directories:
source_directories.append(Path(source_dir))
return source_directories
def _find_collection_files(pattern, source_directories):
"""Find files matching a filename or glob pattern in the source directories."""
if glob.has_magic(pattern):
matched = sorted(f for d in source_directories for f in d.rglob(pattern) if f.is_file())
if not matched:
logger.warning(
f"No files matched collection pattern '{pattern}' in {source_directories}."
)
return matched
recursive_matches = []
for source_directory in source_directories:
candidate = source_directory / pattern
if candidate.exists():
return [candidate]
recursive_matches.extend(
file_path for file_path in source_directory.rglob(pattern) if file_path.is_file()
)
if len(recursive_matches) == 1:
return recursive_matches
if len(recursive_matches) > 1:
raise FileExistsError(
f"Collection file '{pattern}' matched multiple nested files in {source_directories}: "
f"{recursive_matches}. Use a more specific path or glob pattern."
)
raise FileNotFoundError(f"Could not find collection file '{pattern}' in {source_directories}.")
[docs]
def model_source_options(args_dict):
"""Return configured model-source options for child applications."""
return {key: args_dict[key] for key in _MODEL_SOURCE_OPTIONS if args_dict.get(key) is not None}
def _application_argument_names(application):
"""Return the argument names of a simtools application, or None if it is not one."""
if not application or not application.startswith("simtools-"):
return None
module_name = "simtools.applications." + application.removeprefix("simtools-").replace("-", "_")
try:
definition = importlib.import_module(module_name).APPLICATION
except ImportError, AttributeError:
return None
return {argument.name for argument in definition.all_arguments}
def _apply_model_source_options(application, configuration, source_options):
"""Apply inherited model-source options to applications that accept them."""
argument_names = _application_argument_names(application)
if argument_names is None or not set(source_options) <= argument_names:
return
if configuration.get(_SIMULATION_MODELS_PATH):
return
for key, value in source_options.items():
if key == _SIMULATION_MODELS_PATH and configuration.get(_SIMULATION_MODELS_GIT_PATH):
continue
if configuration.get(key) is None:
configuration[key] = value
def _submit_application_and_collect_metadata(
app,
app_configuration,
runtime_environment,
model_parameter_metadata_files,
):
"""Submit one application and collect metadata file before/after submission."""
metadata_file = _get_model_parameter_metadata_file(app_configuration)
if metadata_file is not None:
model_parameter_metadata_files.append(metadata_file)
logger.info(f"Running application: {app}")
result = execution.run_command(
app,
stdout=None,
stderr=None,
configuration=app_configuration,
runtime_environment=runtime_environment,
)
if metadata_file is None:
metadata_file = _get_model_parameter_metadata_file(app_configuration)
if metadata_file is not None:
model_parameter_metadata_files.append(metadata_file)
return result
def _update_workflow_metadata_files(
args_dict,
workflow_activity_id,
workflow_start,
runtime_environment_snapshot,
configurations,
model_parameter_metadata_files,
associated_activities,
context=None,
):
"""Update collected model-parameter metadata files with workflow metadata."""
if not model_parameter_metadata_files:
return
workflow_activity = workflow_metadata.build_workflow_activity_metadata(
args_dict=args_dict,
workflow_activity_id=workflow_activity_id,
workflow_start=workflow_start,
workflow_end=max(datetime.now(UTC), workflow_start),
runtime_environment=(
runtime_environment_snapshot if not args_dict["ignore_runtime_environment"] else None
),
workflow_context=_get_workflow_context(configurations),
context=context,
)
for metadata_file in model_parameter_metadata_files:
workflow_metadata.update_model_parameter_metadata_file(
metadata_file=metadata_file,
workflow_activity=workflow_activity,
associated_activities=associated_activities,
)
def _read_application_configuration(
configuration_file, steps, workflow_activity_id=None, replacements=None
):
"""Read a workflow file and resolve its placeholders; return configurations and metadata."""
job_configuration = ascii_handler.collect_data_from_file(configuration_file)
job_configuration = value_conversion.replace_placeholders_recursively(
job_configuration, replacements or {}
)
workflow_activity_id = (
gen.extract_uuid7_from_path(configuration_file) or workflow_activity_id or gen.get_uuid()
)
derived_output_path, setting_workflow = _set_input_output_directories(configuration_file)
configurations = job_configuration.get("applications")
collection_config = value_conversion.replace_placeholders_recursively(
job_configuration.get("collection"),
{"__SETTING_WORKFLOW__": setting_workflow},
)
output_path_used_as_default = False
for step_count, config in enumerate(configurations, start=1):
config["run_application"] = step_count in steps if steps else True
config = value_conversion.change_dict_keys_case(config, True)
app_config = config.get("configuration", {})
if "output_path" not in app_config:
output_path_used_as_default = True
config["configuration"] = _prepare_application_configuration(
app_config,
derived_output_path,
setting_workflow,
)
if config["configuration"].get("activity_id") is None:
config["configuration"]["activity_id"] = gen.get_uuid()
configurations[step_count - 1] = config
if output_path_used_as_default or not configurations:
log_path = derived_output_path
else:
first_app_output = configurations[0].get("configuration", {}).get("output_path")
log_path = Path(first_app_output) if first_app_output else derived_output_path
logger.debug(f"Setting workflow output path to {log_path}")
return (
configurations,
job_configuration.get("runtime_environment"),
log_path / "simtools.log",
workflow_activity_id,
collection_config,
)
def _validate_resolved_configuration(configurations, runtime_environment, collection_config):
"""Reject unresolved workflow placeholders before starting an application."""
values = [configurations, runtime_environment, collection_config]
unresolved = sorted(
{match.group(0) for value in values for match in _iter_placeholder_matches(value)}
)
if unresolved:
raise ValueError("Unresolved workflow placeholder(s): " + ", ".join(unresolved))
[docs]
def resolve_nested_configuration_files(configurations, replacements):
"""Resolve placeholders in referenced backend configuration files."""
for config in configurations:
app_configuration = config.get("configuration", {})
backend_config = app_configuration.get("backend_config")
if not isinstance(backend_config, (str, Path)):
continue
backend_path = Path(backend_config)
if not backend_path.is_absolute():
backend_path = Path(replacements["__CONFIG_DIRECTORY__"]) / backend_path
if not backend_path.is_file():
raise FileNotFoundError(f"Backend configuration not found: {backend_path}")
backend_values = ascii_handler.collect_data_from_file(backend_path)
if not isinstance(backend_values, dict):
raise ValueError(f"Backend configuration must contain a mapping: {backend_path}")
app_configuration["backend_config"] = value_conversion.replace_placeholders_recursively(
backend_values,
replacements,
)
def _iter_placeholder_matches(value):
"""Yield placeholder matches from nested workflow values."""
if isinstance(value, str):
yield from _SCIENCE_PLACEHOLDER.finditer(value)
elif isinstance(value, dict):
for item in value.values():
yield from _iter_placeholder_matches(item)
elif isinstance(value, (list, tuple)):
for item in value:
yield from _iter_placeholder_matches(item)
def _write_resolved_workflow(
configuration_file, configurations, runtime_environment, collection, provenance_path=None
):
"""Write execution provenance at an explicit path or below the first output."""
if provenance_path is False:
return
output_path = next(
(
config.get("configuration", {}).get("output_path")
for config in configurations
if config.get("configuration", {}).get("output_path")
),
None,
)
if output_path is None and provenance_path is None:
return
provenance_path = (
Path(provenance_path)
if provenance_path
else Path(output_path) / "provenance" / "resolved-workflow.yml"
)
provenance_path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"configuration_file": Path(configuration_file).name,
"applications": configurations,
"runtime_environment": runtime_environment,
"collection": collection,
}
with provenance_path.open("w", encoding="utf-8") as file:
yaml.safe_dump(
ascii_handler.to_builtin(payload), file, sort_keys=False, explicit_start=True
)
def _get_model_parameter_metadata_file(app_configuration):
"""Return the expected metadata file of a model-parameter submission application."""
output_path = app_configuration.get("output_path")
if not output_path:
return None
output_path = Path(output_path)
parameter = app_configuration.get("parameter")
parameter_version = app_configuration.get("parameter_version")
if parameter and parameter_version:
return output_path / parameter / f"{parameter}-{parameter_version}.meta.yml"
candidates = sorted(output_path.rglob("*.meta.yml"))
if len(candidates) == 1:
return candidates[0]
matching = [
f
for f in candidates
if parameter_version and f.name.endswith(f"-{parameter_version}.meta.yml")
]
return matching[0] if len(matching) == 1 else None
def _get_workflow_context(configurations):
"""Return the site and instrument of the workflow (first non-empty value)."""
def first_value(key):
return next(
(
value
for value in (config.get("configuration", {}).get(key) for config in configurations)
if value is not None
),
None,
)
return {
"site": first_value("site"),
"instrument": first_value("instrument") or first_value("telescope"),
}
def _replace_placeholders_in_configuration(
configuration, output_path, setting_workflow, place_holder="__SETTING_WORKFLOW__"
):
"""Replace the workflow placeholder and default the output path of a configuration."""
configuration = value_conversion.replace_placeholders_recursively(
configuration,
{place_holder: setting_workflow},
)
if output_path:
configuration.setdefault("output_path", str(output_path))
return configuration
def _prepare_application_configuration(configuration, output_path, setting_workflow):
"""Resolve placeholders and version-dependent values in a workflow configuration."""
configuration = _replace_placeholders_in_configuration(
configuration,
output_path,
setting_workflow,
)
model_version = configuration.get("model_version")
if model_version is not None:
configuration = simtools_version.resolve_by_version(configuration, model_version)
return configuration
def _set_input_output_directories(path):
"""Return the output directory and workflow subdirectory for a configuration file."""
path = Path(path)
try:
setting_workflow = gen.extract_subdirectories_from_path(path, anchor="input")
except ValueError:
setting_workflow = str(path.parent) if path.parent != Path() else path.stem
return Path("simtools-output") / Path(setting_workflow), setting_workflow
[docs]
def read_runtime_environment(runtime_environment):
"""Read the runtime environment (e.g. docker runtime) and return the required command."""
if runtime_environment is None:
return []
engine = runtime_environment.get("container_engine", "docker")
if shutil.which(engine) is None:
raise RuntimeError(f"Container engine '{engine}' not found.")
if Path(engine).name == "apptainer":
if runtime_environment.get("network"):
raise ValueError("Apptainer runtime environments do not support the network option.")
cmd = [engine, "exec"]
for option in runtime_environment.get("options", []):
cmd.extend(option.split())
if environment_file := runtime_environment.get("environment_file"):
cmd.extend(["--env-file", environment_file])
return [*cmd, runtime_environment["image"]]
cmd = [engine, "run", "--rm"]
if options := runtime_environment.get("options"):
for opt in options:
cmd.extend(os.path.expandvars(opt).split())
if env := runtime_environment.get("environment_file"):
cmd += ["--env-file", env]
if net := runtime_environment.get("network"):
cmd += ["--network", net]
cmd.append(runtime_environment["image"])
_pull_image(engine, runtime_environment["image"])
return cmd
[docs]
def prepare_runtime_environment(runtime_environment_file):
"""
Read and prepare a standalone runtime environment.
Parameters
----------
runtime_environment_file : str or pathlib.Path
YAML file with a top-level ``runtime_environment`` mapping.
Returns
-------
tuple[dict, list[str]]
Runtime-environment configuration and prepared runtime command.
Raises
------
ValueError
If the YAML content is not a mapping or lacks ``runtime_environment``.
jsonschema.ValidationError
If the configuration does not conform to the runtime-environment schema.
"""
runtime_config = ascii_handler.collect_data_from_file(runtime_environment_file)
if not isinstance(runtime_config, dict):
raise ValueError("Runtime configuration must be a YAML mapping.")
runtime_environment = runtime_config.get("runtime_environment")
if runtime_environment is None:
raise ValueError("Runtime configuration must contain a 'runtime_environment' block.")
schema.validate_dict_using_schema(
runtime_config, schema_file=RUN_TIME_ENVIRONMENT_SCHEMA, offline=True
)
return runtime_environment, read_runtime_environment(runtime_environment)
def _pull_image(engine, image):
"""Pull an image with the container engine unless it is already available locally."""
inspect_result = execution.run_command([engine, "image", "inspect", image], check=False)
if inspect_result and inspect_result.return_code == 0:
return
try:
execution.run_command([engine, "pull", image], capture_output=False)
except JobExecutionError as exc:
raise RuntimeError(f"Failed to pull image '{image}' using '{engine}': {exc}") from exc
[docs]
def apply_execution_overrides(configurations, args, model_source, env_file):
"""Apply inherited settings before recording or running applications."""
for config in configurations:
configuration = config.get("configuration", {})
application = config.get("application")
_apply_model_source_options(application, configuration, model_source)
if args.get(_IGNORE_EXISTING_PARAMETER_VERSION):
argument_names = _application_argument_names(application)
if argument_names is None or _IGNORE_EXISTING_PARAMETER_VERSION in argument_names:
configuration[_IGNORE_EXISTING_PARAMETER_VERSION] = True
if env_file is not None:
configuration["env_file"] = env_file