diff --git a/precicecasegenerate/cli.py b/precicecasegenerate/cli.py index 0943f61..e86a495 100644 --- a/precicecasegenerate/cli.py +++ b/precicecasegenerate/cli.py @@ -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 @@ -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()) @@ -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}") @@ -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 diff --git a/precicecasegenerate/state_tracker.py b/precicecasegenerate/state_tracker.py new file mode 100644 index 0000000..6301f84 --- /dev/null +++ b/precicecasegenerate/state_tracker.py @@ -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.") diff --git a/tests/state_tracker/test_state_tracker.py b/tests/state_tracker/test_state_tracker.py new file mode 100644 index 0000000..7db7c7f --- /dev/null +++ b/tests/state_tracker/test_state_tracker.py @@ -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//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." diff --git a/tests/state_tracker/topology.yaml b/tests/state_tracker/topology.yaml new file mode 100644 index 0000000..e848294 --- /dev/null +++ b/tests/state_tracker/topology.yaml @@ -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 \ No newline at end of file