33from __future__ import annotations
44
55import json
6- from typing import TYPE_CHECKING , Any , Generic , Self , TypeVar
6+ from datetime import UTC , datetime
7+ from pathlib import Path
8+ from typing import TYPE_CHECKING , Any , Final , Generic , Self , TypeVar
9+ from uuid import uuid4
710
811from pydantic import BaseModel , ValidationError
912
13+ from fmu .settings ._logging import null_logger
1014from fmu .settings ._migrations import (
1115 MigrationError ,
1216 MigrationManager ,
2428 from collections .abc import Mapping
2529
2630 # Avoid circular dependency for type hint in __init__ only
27- from pathlib import Path
28-
2931 from fmu .settings ._fmu_dir import FMUDirectoryBase
3032
3133PydanticResource = TypeVar ("PydanticResource" , bound = BaseModel )
3234MutablePydanticResource = TypeVar ("MutablePydanticResource" , bound = ResettableBaseModel )
3335
36+ MIGRATION_BACKUP_DIRECTORY : Final = Path ("migration-backups" )
37+ logger : Final = null_logger (__name__ )
38+
3439
3540class PydanticResourceManager (Generic [PydanticResource ]):
3641 """Base class for managing resources represented by Pydantic models."""
@@ -168,7 +173,7 @@ def save(
168173 model: Validated Pydantic model instance.
169174 """
170175 self .fmu_dir ._lock .ensure_can_write ()
171- self ._store_pre_migration_revision ()
176+ self ._store_migration_backup ()
172177
173178 json_data = model .model_dump_json (by_alias = True , indent = 2 )
174179 self .fmu_dir .write_text_file (self .relative_path , json_data )
@@ -178,12 +183,15 @@ def save(
178183
179184 self ._cache = model
180185
181- def _store_pre_migration_revision (self : Self ) -> None :
182- """Preserve current disk content before replacing it with migrated data .
186+ def _store_migration_backup (self : Self ) -> None :
187+ """Try to back up older data before saving the migrated version .
183188
184- Invalid JSON and non-object content are not stored as pre-migration revisions.
189+ The backup is best effort. Invalid JSON and non-object content are skipped.
190+ File-system errors while writing the backup are logged, but do not stop the
191+ save.
185192 """
186- if self .migration_manager is None :
193+ migration_manager = self .migration_manager
194+ if migration_manager is None :
187195 return
188196
189197 try :
@@ -196,15 +204,47 @@ def _store_pre_migration_revision(self: Self) -> None:
196204 return
197205
198206 try :
199- requires_migration = self . migration_manager .requires_migration (data )
207+ requires_migration = migration_manager .requires_migration (data )
200208 except MigrationError as e :
201209 raise MigrationError (
202210 f"Failed to check migration requirements for resource file "
203211 f"'{ self .__class__ .__name__ } ' at '{ self .path } ': { e } "
204212 ) from e
205213
206- if requires_migration :
207- self .fmu_dir .cache .store_revision (self .relative_path , content )
214+ if not requires_migration :
215+ return
216+
217+ source_version = data .get ("schema_version" , 1 )
218+ self ._write_migration_backup (content , source_version )
219+
220+ def _write_migration_backup (self : Self , content : str , source_version : int ) -> None :
221+ """Write a best-effort migration backup of the original content.
222+
223+ For example, a ``config.json`` backup can be stored under
224+ ``.fmu/migration-backups/config/`` as
225+ ``<timestamp>-<token>-ProjectConfig-v1.json``.
226+ """
227+ timestamp = datetime .now (UTC ).strftime ("%Y%m%dT%H%M%S.%fZ" )
228+ token = uuid4 ().hex [:8 ]
229+ backup_directory = (
230+ MIGRATION_BACKUP_DIRECTORY
231+ / self .relative_path .parent
232+ / self .relative_path .stem
233+ )
234+ backup_filename = (
235+ f"{ timestamp } -{ token } -{ self .model_class .__name__ } -v{ source_version } "
236+ f"{ self .relative_path .suffix } "
237+ )
238+ backup_path = backup_directory / backup_filename
239+
240+ try :
241+ self .fmu_dir .write_text_file (backup_path , content )
242+ except OSError as e :
243+ logger .warning (
244+ f"Failed to save migration backup for '{ self .path } ' at "
245+ f"'{ self .fmu_dir .get_file_path (backup_path )} '. "
246+ f"Continuing without it: { e } "
247+ )
208248
209249 def _migrate_and_validate_data (self : Self , data : Any ) -> PydanticResource :
210250 """Migrate decoded data with registered functions and validate the result."""
0 commit comments