Skip to content
Merged
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
12 changes: 9 additions & 3 deletions precicecasegenerate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from precicecasegenerate import helper
from precicecasegenerate import cli_helper
from precicecasegenerate.state_tracker import StateTracker
from precicecasegenerate.logging_setup import setup_logging
from precicecasegenerate.input_handler.topology_reader import TopologyReader
from precicecasegenerate.input_handler.topology_processor import TopologyProcessor
Expand Down Expand Up @@ -68,6 +69,10 @@ def generate_case(input_file: Path, output_root: Path) -> int:
output_root.mkdir(parents=True, exist_ok=True)
logger.debug(f"Created output directory at {output_root}")

# Track generated files
state_tracker = StateTracker(output_root)
state_tracker.backup_modified_files()

logger.debug("Starting topology reader.")
# Read the topology file
topology_reader = TopologyReader(input_file.resolve())
Expand Down Expand Up @@ -98,9 +103,7 @@ def generate_case(input_file: Path, output_root: Path) -> int:
for participant in participant_solver_map:
participant_directory: Path = helper.get_participant_solver_directory(output_root, participant.name,
participant_solver_map[participant])
# The directory will be overwritten if it already exists and is of the form "_generated/name-solver/"
if participant_directory.exists():
shutil.rmtree(participant_directory, ignore_errors=True)
# Create the participant directory if it does not exist
participant_directory.mkdir(parents=True, exist_ok=True)
logger.debug(f"Created participant directory at {participant_directory}")

Expand All @@ -114,6 +117,9 @@ def generate_case(input_file: Path, output_root: Path) -> int:
logger.debug("Starting utility file creator.")
utility_file_creator: UtilityFileCreator = UtilityFileCreator(participant_solver_map)
utility_file_creator.create_utility_files(parent_directory=output_root)

state_tracker.save_new_state()

return 0


Expand Down
94 changes: 94 additions & 0 deletions precicecasegenerate/state_tracker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import json
import hashlib
import shutil
import logging
from datetime import datetime
from pathlib import Path

logger = logging.getLogger(__name__)


class StateTracker:
def __init__(self, output_root: Path):
"""
Initialize the StateTracker.
:param output_root: The root directory of the generated project.
"""
self.output_root = output_root
self.state_file = output_root / ".file-state.json"
self.state = self._load_state()

def _load_state(self) -> dict:
"""
Load the previous state hashes if the file exists.
:return: A dict mapping file paths to their hashes.
"""
if self.state_file.exists():
try:
with open(self.state_file, "r") as f:
return json.load(f)
except Exception as e:
logger.warning(f"Could not read state file: {e}")
return {}

def _hash_file(self, filepath: Path) -> str:
"""
Generate a SHA-256 hash of a file.
:param filepath: The path to the file.
:return: The SHA-256 hash of the file as a hex string.
"""
hasher = hashlib.sha256()
with open(filepath, "rb") as f:
hasher.update(f.read())
return hasher.hexdigest()

def backup_modified_files(self) -> None:
"""
Check all previously generated files. If their current hash differs from the saved hash,
back them up before the generator overwrites them.
"""
backup_dir: Path = None
for rel_path_str, saved_hash in self.state.items():
filepath: Path = self.output_root / rel_path_str
if filepath.exists() and filepath.is_file():
current_hash: str = self._hash_file(filepath)

if current_hash != saved_hash:
# The user modified this file manually
if backup_dir is None:
timestamp: str = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_dir = self.output_root / "backups" / f"{timestamp}"
backup_dir.mkdir(parents=True, exist_ok=True)

# Keep the relative folder structure inside the backup folder
backup_path: Path = backup_dir / rel_path_str
backup_path.parent.mkdir(parents=True, exist_ok=True)

# Move the file out of harm's way
shutil.move(str(filepath), str(backup_path))
logger.warning(f"File '{rel_path_str}' was manually modified. "
f"Backed up to '{backup_path.relative_to(self.output_root)}'.")

def save_new_state(self) -> None:
"""
Hash all generated configuration and utility files and save the state.
"""
new_state = {}
# Track configurations and scripts (ignore preCICE logs, backups, etc.)
tracked_extensions = {".xml", ".json", ".sh", ".md"}

for filepath in self.output_root.rglob("*"):
if filepath.is_file():
rel_path_str = str(filepath.relative_to(self.output_root))

# Skip the state file itself and anything in a backup folder
if rel_path_str == self.state_file.name or "backup_" in rel_path_str:
continue

if filepath.suffix in tracked_extensions:
new_state[rel_path_str] = self._hash_file(filepath)

# Write the new state invisibly
with open(self.state_file, "w") as f:
json.dump(new_state, f, indent=4)
logger.debug("File generation state tracked and saved.")
65 changes: 65 additions & 0 deletions tests/state_tracker/test_state_tracker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""
This file tests the functionality of the StateTracker and the file generation process.
"""
import shutil
from pathlib import Path

from precicecasegenerate.cli import generate_case


def test_state_tracker():
"""
Check that running the generator again preserves custom files
and correctly backs up modified generated files before overwriting.
"""
test_directory: Path = Path(__file__).parent
input_file: Path = test_directory / "topology.yaml"
generated_directory: Path = test_directory / "_generated"

# Remove any previously generated files
if generated_directory.exists():
shutil.rmtree(generated_directory, ignore_errors=True)

# Generate initial files
assert 0 == generate_case(input_file, generated_directory), "Initial case generation failed."

# Find the solver directory dynamically
solver_dirs: list[Path] = [d for d in generated_directory.iterdir()
if d.is_dir() and "-" in d.name and not d.name.startswith("backup")]
assert len(solver_dirs) > 0, "No solver directory found."
solver_dir = solver_dirs[0]

# Add a custom file to the first solver directory
custom_file: Path = solver_dir / "info.txt"
custom_file.write_text("This is my custom mesh data.")

# Modify a generated file to simulate a manual edit
run_script: Path = solver_dir / "run.sh"
modified_content: str = run_script.read_text() + "\n# USER MODIFIED CONTENT\n"
run_script.write_text(modified_content)

# Run the generator a second time
assert 0 == generate_case(input_file, generated_directory), "Second case generation failed."

# Check that the custom file survived
assert custom_file.exists(), "Custom file was destroyed."
assert custom_file.read_text() == "This is my custom mesh data.", "Custom file content was altered."

# Check that the original file was overwritten with the template
new_run_script_content: str = run_script.read_text()
assert "# USER MODIFIED CONTENT" not in new_run_script_content, "The run.sh file was not overwritten with the template."

backups_root: Path = generated_directory / "backups"
assert backups_root.exists(), "The 'backups' directory was not created."

# Check that exactly one backup folder was created by the StateTracker
backup_dirs: list[Path] = list(backups_root.glob("*"))
assert len(backup_dirs) == 1, f"Expected 1 backup directory, found {len(backup_dirs)}."
backup_dir = backup_dirs[0]

# D) Check that the modified file was moved to the backup folder properly
# The relative structure is preserved, so it should be inside _generated/backups/<timestamp>/generator-asolver/run.sh
backed_up_file: Path = backup_dir / solver_dir.name / "run.sh"

assert backed_up_file.exists(), f"Modified file was not backed up to {backed_up_file}."
assert "# USER MODIFIED CONTENT" in backed_up_file.read_text(), "Backed up file does not contain manual edits."
15 changes: 15 additions & 0 deletions tests/state_tracker/topology.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
participants:
- name: Generator
solver: ASolver
dimensionality: 2
- name: Propagator
solver: BSolver
dimensionality: 2
exchanges:
- from: Generator
to: Propagator
from-location-names: [interface]
to-location-names: [interface]
data: Color
type: weak
data-type: scalar
Loading