Skip to content

Commit fc901df

Browse files
committed
MAINT: Address feedbacks
1 parent 911bd2e commit fc901df

7 files changed

Lines changed: 91 additions & 122 deletions

File tree

ARCHITECTURE.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,9 @@ model declares its current schema version, and each resource manager has a
165165
`MigrationManager` with the migration registry for that model.
166166

167167
Migration is forward-only. Data without `schema_version` is treated as version 1.
168-
Each registered function increments the schema version by one. The manager rejects a
169-
newer schema, a missing migration step, an invalid version, or data that does not
170-
validate as the current model.
168+
Each registered migration function increments the schema version by one. The manager
169+
rejects a newer schema, a missing migration step, an invalid version, or data that
170+
does not validate as the current model.
171171

172172
The migration boundary depends on the resource operation:
173173

@@ -192,8 +192,9 @@ The migration boundary depends on the resource operation:
192192
There is no backward migration. After a current-schema resource is saved, an older
193193
`fmu-settings` release can reject it as newer than supported. If the pre-migration
194194
cache revision is still retained, restore it with the older release. Migration
195-
backups are not loaded or restored by the library. Users must copy one back manually
196-
when the cache revision is no longer available.
195+
backups are not loaded or restored by the library. If the cache revision is no longer
196+
available, we should help users copy the appropriate migration backup back to the
197+
resource file before running the older release.
197198

198199
See the
199200
[schema migration guide](src/fmu/settings/_migrations/README.md)

src/fmu/settings/_migrations/README.md

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,9 @@ The returned data must:
146146

147147
- Preserve all relevant stored values.
148148
- Set `schema_version` to the next version.
149-
- Be valid input for the next migration or the current model.
149+
- Be valid input for the next migration function or the current model.
150150

151-
### 3. Register the migration
151+
### 3. Register the migration function
152152

153153
Update `project_config/__init__.py`:
154154

@@ -162,10 +162,10 @@ PROJECT_CONFIG_MIGRATIONS: dict[int, MigrationFunction] = {
162162
}
163163
```
164164

165-
The registry key is the source schema version. Key `1` registers the migration from
166-
version 1 to version 2.
165+
The registry key is the source schema version. Key `1` registers the migration
166+
function from version 1 to version 2.
167167

168-
Keep every migration when later versions are added:
168+
Keep every migration function when later versions are added:
169169

170170
```python
171171
PROJECT_CONFIG_MIGRATIONS: dict[int, MigrationFunction] = {
@@ -185,7 +185,7 @@ When you add a migration:
185185
- Add resource-specific tests under `tests/test_migrations/`. For example, a
186186
`ProjectConfig` migration can use `test_project_config_migration.py`.
187187

188-
The resource-specific tests must cover conversion, load, save, cache restore, and
188+
The resource-specific tests must cover migration, load, save, cache restore, and
189189
invalid data. Update the complete current version fixture used by
190190
`tests/test_resources/test_migratable_models_up_to_date.py`. Keep the previous
191191
version input with the resource-specific migration tests.
@@ -205,7 +205,7 @@ uv run mypy src tests
205205
Migration is automatic during normal use:
206206

207207
1. The resource manager reads stored data from an older schema.
208-
2. The migration manager converts it in memory.
208+
2. The migration manager migrates it in memory.
209209
3. The resource manager returns the current validated model.
210210
4. The stored file remains unchanged until a save occurs.
211211

@@ -223,21 +223,27 @@ On the first save:
223223
Loading a resource does not create a changelog entry. A later user update or
224224
restore uses the existing changelog behavior.
225225

226+
### Cache restore
227+
226228
When an old cache revision is restored by the current release, it is migrated before
227229
it is written. The resource file therefore uses the current schema after the restore.
228-
If you roll back to an older release while the pre-migration revision is retained,
229-
restore that revision with the older release to return the resource file to the older
230-
schema. Migration backups are not read or restored by the library, copy one back
231-
manually when the cache revision is no longer available.
230+
231+
### Rollback to an older release
232232

233233
Migrations are forward-only. After current-schema data is saved, an older
234-
`fmu-settings` release can reject it as newer than its supported schema.
234+
`fmu-settings` release can reject it as newer than its supported schema. If the
235+
pre-migration revision is retained, restore that revision with the older release to
236+
return the resource file to the older schema. Migration backups are not read or
237+
restored by the library. If the cache revision is no longer available, we should help
238+
users copy the appropriate migration backup back to the resource file before running
239+
the older release.
235240

236241
## Release checklist for an `fmu-settings` schema version
237242

238-
Use this checklist when releasing an `fmu-settings` package that contains a new
239-
stored schema version. First prepare and publish `fmu-settings`. Then update the
240-
downstream applications so that users receive the new package.
243+
Use this checklist when releasing an `fmu-settings` package with an updated
244+
schema version in one of the migratable models. First prepare and publish
245+
`fmu-settings`. Then update the downstream applications so that users receive
246+
the new package.
241247

242248
### 1. Prepare the `fmu-settings` package
243249

src/fmu/settings/_migrations/manager.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def migrate_resource(self, data: Any) -> MigratableResource:
6060
"""
6161
if not isinstance(data, dict):
6262
raise MigrationError(
63-
f"{self.model_class.__name__} resource must be a JSON object"
63+
f"Stored {self.model_class.__name__} data must be a JSON object"
6464
)
6565
source_schema_version = self._get_source_schema_version(data)
6666
migration_steps = self._get_migration_steps(source_schema_version)
@@ -71,37 +71,37 @@ def migrate_resource(self, data: Any) -> MigratableResource:
7171
migrated_data = migration_function(migrated_data)
7272
except Exception as e:
7373
raise MigrationError(
74-
f"{self.model_class.__name__} migration from schema version "
75-
f"{version} to {version + 1} failed"
74+
f"Failed to migrate stored {self.model_class.__name__} data "
75+
f"from schema version {version} to {version + 1}"
7676
) from e
7777

7878
result_version = self._validate_version(migrated_data.get("schema_version"))
7979
expected_version = version + 1
8080
if result_version != expected_version:
8181
raise MigrationError(
82-
f"{self.model_class.__name__} migration from schema version "
83-
f"{version} must set schema_version to {expected_version}, "
84-
f"but set it to {result_version}"
82+
f"Migration of stored {self.model_class.__name__} data from "
83+
f"schema version {version} must set schema_version to "
84+
f"{expected_version}, but set it to {result_version}"
8585
)
8686

8787
try:
8888
validated_model = self.model_class.model_validate(migrated_data)
8989
except ValidationError as e:
9090
raise MigrationError(
91-
f"{self.model_class.__name__} data does not validate against current "
92-
f"schema version {self.current_version}"
91+
f"Stored {self.model_class.__name__} data does not validate against "
92+
f"current schema version {self.current_version}"
9393
) from e
9494

9595
return validated_model
9696

9797
def requires_migration(self, data: dict[str, Any]) -> bool:
98-
"""Return whether stored data needs migration before a write.
98+
"""Return whether stored data needs migration.
9999
100100
This check verifies that all required forward migration functions exist and
101101
that the stored schema version is older than the current schema version.
102102
103103
Args:
104-
data: Existing stored data that a write would replace.
104+
data: Existing stored data to inspect.
105105
106106
Returns:
107107
Whether the stored data requires migration.
@@ -117,11 +117,11 @@ def requires_migration(self, data: dict[str, Any]) -> bool:
117117
def _get_migration_steps(
118118
self, source_schema_version: int
119119
) -> list[tuple[int, MigrationFunction]]:
120-
"""Return and validate migration steps needed by a source schema version."""
120+
"""Return migration functions from source to current schema version."""
121121
if source_schema_version > self.current_version:
122122
raise MigrationError(
123-
f"{self.model_class.__name__} schema version {source_schema_version} "
124-
"is newer than supported version "
123+
f"Stored {self.model_class.__name__} data has schema version "
124+
f"{source_schema_version}, which is newer than supported version "
125125
f"{self.current_version}; downgrade migration "
126126
"is not supported"
127127
)
@@ -131,9 +131,8 @@ def _get_migration_steps(
131131
migration_function = self.migrations.get(version)
132132
if migration_function is None:
133133
raise MigrationError(
134-
f"Missing {self.model_class.__name__} migration function from "
135-
"schema "
136-
f"version {version} to {version + 1}"
134+
f"Missing {self.model_class.__name__} migration function for "
135+
f"stored data from schema version {version} to {version + 1}"
137136
)
138137
steps.append((version, migration_function))
139138
return steps

src/fmu/settings/_resources/cache_manager.py

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -172,11 +172,11 @@ def get_revision_content(
172172
content_str = self._fmu_dir.read_text_file(cache_relative)
173173

174174
try:
175-
return self._migrate_and_validate_content(
176-
content_str,
177-
model_class,
178-
migration_manager,
179-
)
175+
if migration_manager is not None and migration_manager.requires_migration(
176+
json.loads(content_str)
177+
):
178+
return migration_manager.migrate_resource(json.loads(content_str))
179+
return model_class.model_validate_json(content_str)
180180
except MigrationError as e:
181181
raise MigrationError(
182182
f"Cannot migrate cached content for '{resource_file_path}': {e}"
@@ -227,11 +227,15 @@ def restore_revision(
227227
current_content = self._fmu_dir.read_text_file(resource_file_path)
228228

229229
try:
230-
self._migrate_and_validate_content(
231-
current_content,
232-
model_class,
233-
migration_manager,
234-
)
230+
if (
231+
migration_manager is not None
232+
and migration_manager.requires_migration(
233+
json.loads(current_content)
234+
)
235+
):
236+
migration_manager.migrate_resource(json.loads(current_content))
237+
else:
238+
model_class.model_validate_json(current_content)
235239
except (json.JSONDecodeError, MigrationError, ValidationError) as e:
236240
logger.warning(
237241
"Skipped caching current state of "
@@ -246,20 +250,6 @@ def restore_revision(
246250

247251
logger.info(f"Restored {resource_file_path} from cache revision {revision_id}")
248252

249-
@staticmethod
250-
def _migrate_and_validate_content(
251-
content: str,
252-
model_class: type[RequestedModel],
253-
migration_manager: MigrationManager[RequestedModel] | None,
254-
) -> RequestedModel:
255-
"""Migrate versioned content and validate it, or validate current content."""
256-
if migration_manager is None:
257-
return model_class.model_validate_json(content)
258-
if migration_manager.model_class is not model_class:
259-
raise TypeError("Migration manager model must match the requested model")
260-
261-
return migration_manager.migrate_resource(json.loads(content))
262-
263253
def _ensure_resource_cache_dir(self: Self, resource_file_path: Path) -> Path:
264254
"""Create (if needed) and return the cache directory for resource file."""
265255
self._cache_root_path(create=True)

src/fmu/settings/_resources/pydantic_resource_manager.py

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,13 @@ def load(
145145
try:
146146
content = self.fmu_dir.read_text_file(self.relative_path)
147147
data = json.loads(content)
148-
validated_model = self._migrate_and_validate_data(data)
148+
if (
149+
self.migration_manager is not None
150+
and self.migration_manager.requires_migration(data)
151+
):
152+
validated_model = self.migration_manager.migrate_resource(data)
153+
else:
154+
validated_model = self.model_class.model_validate(data)
149155
if store_cache:
150156
self._cache = validated_model
151157
else:
@@ -184,11 +190,11 @@ def save(
184190
self._cache = model
185191

186192
def _store_migration_backup(self: Self) -> None:
187-
"""Try to back up older data before saving the migrated version.
193+
"""Try to back up older stored data.
188194
189195
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.
196+
File-system errors while writing the backup are logged without interrupting
197+
the caller.
192198
"""
193199
migration_manager = self.migration_manager
194200
if migration_manager is None:
@@ -214,12 +220,14 @@ def _store_migration_backup(self: Self) -> None:
214220
if not requires_migration:
215221
return
216222

217-
source_version = data.get("schema_version", 1)
218-
self._write_migration_backup(content, source_version)
223+
source_schema_version = data.get("schema_version", 1)
224+
self._write_migration_backup(content, source_schema_version)
219225
if self.automatic_caching:
220226
self.fmu_dir.cache.store_revision(self.relative_path, content)
221227

222-
def _write_migration_backup(self: Self, content: str, source_version: int) -> None:
228+
def _write_migration_backup(
229+
self: Self, content: str, source_schema_version: int
230+
) -> None:
223231
"""Write a best-effort migration backup of the original content.
224232
225233
For example, a ``config.json`` backup can be stored under
@@ -234,7 +242,7 @@ def _write_migration_backup(self: Self, content: str, source_version: int) -> No
234242
/ self.relative_path.stem
235243
)
236244
backup_filename = (
237-
f"{timestamp}-{token}-{self.model_class.__name__}-v{source_version}"
245+
f"{timestamp}-{token}-{self.model_class.__name__}-v{source_schema_version}"
238246
f"{self.relative_path.suffix}"
239247
)
240248
backup_path = backup_directory / backup_filename
@@ -248,18 +256,6 @@ def _write_migration_backup(self: Self, content: str, source_version: int) -> No
248256
f"Continuing without it: {e}"
249257
)
250258

251-
def _migrate_and_validate_data(self: Self, data: Any) -> PydanticResource:
252-
"""Migrate decoded data with registered functions and validate the result."""
253-
if self.migration_manager is None:
254-
return self.model_class.model_validate(data)
255-
try:
256-
return self.migration_manager.migrate_resource(data)
257-
except MigrationError as e:
258-
raise MigrationError(
259-
f"Failed to migrate resource file for "
260-
f"'{self.__class__.__name__}' at '{self.path}': {e}"
261-
) from e
262-
263259
def get_model_diff(
264260
self: Self,
265261
current_model: BaseModel,

tests/test_migrations/test_migration_manager.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,10 @@ def test_migration_manager_rejects_missing_step() -> None:
117117

118118
with pytest.raises(
119119
MigrationError,
120-
match="Missing VersionThreeModel migration function from schema version 1 to 2",
120+
match=(
121+
"Missing VersionThreeModel migration function for stored data from "
122+
"schema version 1 to 2"
123+
),
121124
):
122125
manager.migrate_resource(
123126
{
@@ -134,7 +137,10 @@ def test_migration_manager_rejects_newer_schema() -> None:
134137

135138
with pytest.raises(
136139
MigrationError,
137-
match=("VersionThreeModel schema version 4 is newer than supported version 3"),
140+
match=(
141+
"Stored VersionThreeModel data has schema version 4, which is newer "
142+
"than supported version 3"
143+
),
138144
):
139145
manager.migrate_resource(
140146
{
@@ -206,7 +212,9 @@ def failing_migration(data: dict[str, Any]) -> dict[str, Any]:
206212

207213
with pytest.raises(
208214
MigrationError,
209-
match=("VersionThreeModel migration from schema version 1 to 2 failed"),
215+
match=(
216+
"Failed to migrate stored VersionThreeModel data from schema version 1 to 2"
217+
),
210218
) as error:
211219
manager.migrate_resource(
212220
{
@@ -237,7 +245,8 @@ def remove_required_value(data: dict[str, Any]) -> dict[str, Any]:
237245
with pytest.raises(
238246
MigrationError,
239247
match=(
240-
"VersionThreeModel data does not validate against current schema version 3"
248+
"Stored VersionThreeModel data does not validate against current schema "
249+
"version 3"
241250
),
242251
):
243252
manager.migrate_resource(

0 commit comments

Comments
 (0)