Skip to content

Commit

Permalink
add configuration management for SAMMY execution backends
Browse files Browse the repository at this point in the history
  • Loading branch information
KedoKudo committed Dec 20, 2024
1 parent 987c743 commit f097c11
Show file tree
Hide file tree
Showing 3 changed files with 353 additions and 42 deletions.
177 changes: 177 additions & 0 deletions src/pleiades/sammy/backends/local.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
#!/usr/env/bin python
"""Local backend implementation for SAMMY execution."""
import subprocess
import textwrap
from datetime import datetime
from pathlib import Path
from uuid import uuid4
import logging
from typing import List

from pleiades.sammy.interface import (
SammyRunner,
SammyFiles,
SammyExecutionResult,
EnvironmentPreparationError,
SammyExecutionError,
OutputCollectionError,
)
from pleiades.sammy.config import LocalSammyConfig

logger = logging.getLogger(__name__)

# Known SAMMY output file patterns
SAMMY_OUTPUT_FILES = {
'SAMMY.LPT', # Log file
'SAMMIE.ODF', # Output data file
'SAMNDF.PAR', # Updated parameter file
'SAMRESOLVED.PAR' # Additional parameter file
}

class LocalSammyRunner(SammyRunner):
"""Implementation of SAMMY runner for local installation."""

def __init__(self, config: LocalSammyConfig):
super().__init__(config)
self.config: LocalSammyConfig = config
self._moved_files: List[Path] = []

def prepare_environment(self, files: SammyFiles) -> None:
"""Prepare environment for local SAMMY execution."""
try:
logger.debug("Validating input files")
files.validate()

# No need to validate directories as this is done in config validation
logger.debug("Environment preparation complete")

except Exception as e:
raise EnvironmentPreparationError(
f"Environment preparation failed: {str(e)}"
)

def execute_sammy(self, files: SammyFiles) -> SammyExecutionResult:
"""Execute SAMMY using local installation."""
execution_id = str(uuid4())
start_time = datetime.now()

logger.info(f"Starting SAMMY execution {execution_id}")
logger.debug(f"Working directory: {self.config.working_dir}")

sammy_command = textwrap.dedent(f"""\
{self.config.sammy_executable} <<EOF
{files.input_file}
{files.parameter_file}
{files.data_file}
EOF""")

try:
process = subprocess.run(
sammy_command,
shell=True,
executable=str(self.config.shell_path),
env=self.config.env_vars,
cwd=str(self.config.working_dir),
capture_output=True,
text=True
)

end_time = datetime.now()
console_output = process.stdout + process.stderr
success = " Normal finish to SAMMY" in console_output

if not success:
logger.error(f"SAMMY execution failed for {execution_id}")
error_message = (
f"SAMMY execution failed with return code {process.returncode}. "
"Check console output for details."
)
else:
logger.info(f"SAMMY execution completed successfully for {execution_id}")
error_message = None

return SammyExecutionResult(
success=success,
execution_id=execution_id,
start_time=start_time,
end_time=end_time,
console_output=console_output,
error_message=error_message
)

except Exception as e:
logger.exception(f"SAMMY execution failed for {execution_id}")
raise SammyExecutionError(f"SAMMY execution failed: {str(e)}")

def collect_outputs(self, result: SammyExecutionResult) -> None:
"""Collect and validate output files after execution."""
collection_start = datetime.now()
logger.info(f"Collecting outputs for execution {result.execution_id}")

try:
self._moved_files = [] # Reset moved files list
found_outputs = set()

# First check for known output files
for known_file in SAMMY_OUTPUT_FILES:
output_file = self.config.working_dir / known_file
if output_file.is_file():
found_outputs.add(output_file)
logger.debug(f"Found known output file: {known_file}")

# Then look for any additional SAM* files
for output_file in self.config.working_dir.glob("SAM*"):
if output_file.is_file() and output_file not in found_outputs:
found_outputs.add(output_file)
logger.debug(f"Found additional output file: {output_file.name}")

if not found_outputs:
logger.warning("No SAMMY output files found")
if result.success:
logger.error("SAMMY reported success but produced no output files")
return

# Move all found outputs
for output_file in found_outputs:
dest = self.config.output_dir / output_file.name
try:
if dest.exists():
logger.debug(f"Removing existing output file: {dest}")
dest.unlink()

output_file.rename(dest)
self._moved_files.append(dest)
logger.debug(f"Moved {output_file} to {dest}")

except OSError as e:
self._rollback_moves()
raise OutputCollectionError(
f"Failed to move output file {output_file}: {str(e)}"
)

logger.info(
f"Successfully collected {len(self._moved_files)} output files in "
f"{(datetime.now() - collection_start).total_seconds():.2f} seconds"
)

except Exception as e:
self._rollback_moves()
raise OutputCollectionError(f"Output collection failed: {str(e)}")

def _rollback_moves(self) -> None:
"""Rollback any moved files in case of error."""
for moved_file in self._moved_files:
try:
original = self.config.working_dir / moved_file.name
moved_file.rename(original)
except Exception as e:
logger.error(f"Failed to rollback move for {moved_file}: {str(e)}")

def cleanup(self) -> None:
"""Clean up after execution."""
logger.debug("Performing cleanup for local backend")
self._moved_files = []

def validate_config(self) -> bool:
"""Validate the configuration."""
return self.config.validate()
99 changes: 99 additions & 0 deletions src/pleiades/sammy/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/env/bin python
"""
Configuration management for SAMMY execution backends.
This module provides concrete configuration classes for each SAMMY backend type,
inheriting from the base configuration defined in the interface module.
"""

from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Optional
from urllib.parse import urlparse
import shutil

from pleiades.sammy.interface import BaseSammyConfig, ConfigurationError

@dataclass
class LocalSammyConfig(BaseSammyConfig):
"""Configuration for local SAMMY installation."""
sammy_executable: Path
shell_path: Path = Path("/bin/bash")
env_vars: Dict[str, str] = field(default_factory=dict)

def validate(self) -> bool:
"""Validate local SAMMY configuration."""
# Validate base configuration first
super().validate()

# Validate SAMMY executable exists and is executable
sammy_path = shutil.which(str(self.sammy_executable))
if not sammy_path:
raise ConfigurationError(f"SAMMY executable not found: {self.sammy_executable}")
self.sammy_executable = Path(sammy_path)

# Validate shell exists
if not self.shell_path.exists():
raise ConfigurationError(f"Shell not found: {self.shell_path}")

return True

@dataclass
class DockerSammyConfig(BaseSammyConfig):
"""Configuration for Docker-based SAMMY execution."""
image_name: str
container_working_dir: Path = Path("/sammy")
volume_mappings: Dict[Path, Path] = field(default_factory=dict)
network: Optional[str] = None

def validate(self) -> bool:
"""Validate Docker SAMMY configuration."""
# Validate base configuration first
super().validate()

# Validate image name format
if not self.image_name:
raise ConfigurationError("Docker image name cannot be empty")

# Validate container working directory is absolute
if not self.container_working_dir.is_absolute():
raise ConfigurationError("Container working directory must be absolute path")

# Validate volume mappings exist on host
for host_path in self.volume_mappings:
if not host_path.exists():
raise ConfigurationError(f"Host path does not exist: {host_path}")

return True

@dataclass
class NovaSammyConfig(BaseSammyConfig):
"""Configuration for NOVA web service SAMMY execution."""
url: str
api_key: str
tool_id: str = "neutrons_imaging_sammy"
timeout: int = 3600 # Default 1 hour timeout
verify_ssl: bool = True

def validate(self) -> bool:
"""Validate NOVA SAMMY configuration."""
# First validate base configuration
super().validate()

# Validate URL format
try:
parsed_url = urlparse(self.url)
if not all([parsed_url.scheme, parsed_url.netloc]):
raise ConfigurationError(f"Invalid URL format: {self.url}")
except Exception as e:
raise ConfigurationError(f"URL validation failed: {str(e)}")

# Validate API key format
if not self.api_key or len(self.api_key) < 32:
raise ConfigurationError("Invalid API key format")

# Validate timeout
if self.timeout <= 0:
raise ConfigurationError(f"Invalid timeout value: {self.timeout}")

return True
Loading

0 comments on commit f097c11

Please sign in to comment.