Skip to content

Commit d1331e5

Browse files
authored
Make autoloading more robust to bad yaml files (#114)
Having an incorrect yaml file in the default paths (root dir and config dir) can cause the plugin to crash completely. I ran into this in #104, which was particularly bad because the plugin wrote the inconsistent file in the first place. However, there's many reasons why bad yaml might exist in the default directories, including: - user incorrectly edits a file - the schema changes - valid action IDs change - the version of the plugin changes (schema and paths aren't pinned to versions) Non-yaml files are already ignored, so I don't think it's a big difference if we also ignore invalid yaml files. Until we provide utilities to clean up the directories, I think robustness to bad input is very important. In this PR, if we encounter any error while loading all models from given directories, we ignore the error and move on to the next file.
1 parent 8c39e6a commit d1331e5

File tree

2 files changed

+53
-13
lines changed

2 files changed

+53
-13
lines changed

midi_app_controller/models/_tests/test_utils.py

+27
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
from pathlib import Path
23
from typing import Optional
34

@@ -24,6 +25,11 @@ def yaml_data() -> dict:
2425
return {"key2": "value2", "key1": "value1", "key3": ["a", "b"], "key4": None}
2526

2627

28+
@pytest.fixture
29+
def other_yaml_data() -> dict:
30+
return {"other_key": "hello"}
31+
32+
2733
@pytest.fixture
2834
def yaml_str(yaml_data) -> str:
2935
dumped = yaml.safe_dump(yaml_data, default_flow_style=False, sort_keys=False)
@@ -51,6 +57,27 @@ def test_load_from_when_invalid_data(yaml_file, yaml_data):
5157
OtherTempYamlModel.load_from(yaml_file)
5258

5359

60+
def test_load_all_from_robustness(tmp_path, yaml_data, other_yaml_data):
61+
d1 = tmp_path / "1"
62+
os.mkdir(d1)
63+
d2 = tmp_path / "2"
64+
os.mkdir(d2)
65+
non_existent_dir = tmp_path / "none"
66+
with open(d1 / "m1.yaml", "w") as f:
67+
yaml.safe_dump(yaml_data, f)
68+
with open(d1 / "m2.yaml", "w") as f:
69+
yaml.safe_dump(other_yaml_data, f)
70+
with open(d2 / "m1.yml", "w") as f:
71+
yaml.safe_dump(yaml_data, f)
72+
with open(d2 / "distractor.txt", "w") as f:
73+
f.write("not relevant\n")
74+
with pytest.warns(UserWarning, match="Unable to load model"):
75+
models = TempYamlModel.load_all_from([d1, d2, non_existent_dir])
76+
assert len(models) == 2
77+
assert models[0][1] == d1 / "m1.yaml"
78+
assert models[1][1] == d2 / "m1.yml"
79+
80+
5481
def test_save_to(yaml_data, yaml_str, tmp_path):
5582
model = TempYamlModel(**yaml_data)
5683
yaml_file = tmp_path / "saved.yaml"

midi_app_controller/models/utils.py

+26-13
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import itertools
12
import os
23
import uuid
34
from pathlib import Path
45
from typing import Any, Optional
6+
from warnings import warn
57

68
import yaml
7-
from pydantic import BaseModel
9+
from pydantic import BaseModel, ValidationError
810

911
from midi_app_controller.config import Config
1012
from midi_app_controller.gui.utils import is_subpath
@@ -19,6 +21,11 @@ def _path_representer(dumper, data):
1921
yaml.SafeDumper.add_multi_representer(Path, _path_representer)
2022

2123

24+
def _abs_listdir(d: Path) -> list[Path]:
25+
"""List the contents of directory as absolute paths."""
26+
return [d / p for p in os.listdir(d)]
27+
28+
2229
class YamlBaseModel(BaseModel):
2330
@classmethod
2431
def load_from(cls, path: Path) -> "YamlBaseModel":
@@ -43,8 +50,9 @@ def load_from(cls, path: Path) -> "YamlBaseModel":
4350
def load_all_from(
4451
cls, directories: list[Path]
4552
) -> list[tuple["YamlBaseModel", Path]]:
46-
"""Creates models initialized with data from all YAML files in
47-
multiple directories.
53+
"""Return models with data from all YAML files in multiple directories.
54+
55+
If a yaml file fails to load, it is skipped and a warning is emitted.
4856
4957
Parameters
5058
----------
@@ -56,16 +64,21 @@ def load_all_from(
5664
list[tuple[cls, Path]]
5765
List of created models with paths to corresponding YAML files.
5866
"""
59-
return [
60-
(
61-
cls.load_from(directory / filename),
62-
directory / filename,
63-
)
64-
for directory in directories
65-
if directory.exists()
66-
for filename in os.listdir(directory)
67-
if filename.lower().endswith((".yaml", ".yml"))
68-
]
67+
all_models = []
68+
real_directories = filter(os.path.exists, directories)
69+
fns = itertools.chain(*map(_abs_listdir, real_directories))
70+
yamls = (fn for fn in fns if fn.suffix in {".yaml", ".yml"})
71+
for fn in yamls:
72+
try:
73+
model = cls.load_from(fn)
74+
all_models.append((model, fn))
75+
except (ValidationError, Exception) as e:
76+
warn(
77+
f"Unable to load model from file {fn}; got error:\n"
78+
f"{e.__class__}: {e}",
79+
stacklevel=2,
80+
)
81+
return all_models
6982

7083
def save_to(self, path: Path) -> None:
7184
"""Saves the model's data to a YAML file.

0 commit comments

Comments
 (0)