diff --git a/pw_ide/py/editors_test.py b/pw_ide/py/editors_test.py index cce3722ff0..6f751de3cf 100644 --- a/pw_ide/py/editors_test.py +++ b/pw_ide/py/editors_test.py @@ -19,9 +19,13 @@ from pw_ide.editors import ( dict_deep_merge, + dict_swap_type, EditorSettingsFile, EditorSettingsManager, JsonFileFormat, + Json5FileFormat, + YamlFileFormat, + _StructuredFileFormat, ) from test_cases import PwIdeTestCase @@ -30,8 +34,8 @@ class TestDictDeepMerge(unittest.TestCase): """Tests dict_deep_merge""" + # pylint: disable=unnecessary-lambda def test_invariants_with_dict_success(self): - # pylint: disable=unnecessary-lambda dict_a = {'hello': 'world'} dict_b = {'foo': 'bar'} @@ -44,7 +48,6 @@ def test_invariants_with_dict_success(self): self.assertEqual(result, expected) def test_invariants_with_dict_implicit_ctor_success(self): - # pylint: disable=unnecessary-lambda dict_a = {'hello': 'world'} dict_b = {'foo': 'bar'} @@ -57,7 +60,6 @@ def test_invariants_with_dict_implicit_ctor_success(self): self.assertEqual(result, expected) def test_invariants_with_dict_fails_wrong_ctor_type(self): - # pylint: disable=unnecessary-lambda dict_a = {'hello': 'world'} dict_b = {'foo': 'bar'} @@ -65,7 +67,6 @@ def test_invariants_with_dict_fails_wrong_ctor_type(self): dict_deep_merge(dict_b, dict_a, lambda: OrderedDict()) def test_invariants_with_ordered_dict_success(self): - # pylint: disable=unnecessary-lambda dict_a = OrderedDict({'hello': 'world'}) dict_b = OrderedDict({'foo': 'bar'}) @@ -80,7 +81,6 @@ def test_invariants_with_ordered_dict_success(self): self.assertEqual(result, expected) def test_invariants_with_ordered_dict_implicit_ctor_success(self): - # pylint: disable=unnecessary-lambda dict_a = OrderedDict({'hello': 'world'}) dict_b = OrderedDict({'foo': 'bar'}) @@ -95,247 +95,372 @@ def test_invariants_with_ordered_dict_implicit_ctor_success(self): self.assertEqual(result, expected) def test_invariants_with_ordered_dict_fails_wrong_ctor_type(self): - # pylint: disable=unnecessary-lambda dict_a = OrderedDict({'hello': 'world'}) dict_b = OrderedDict({'foo': 'bar'}) with self.assertRaises(TypeError): dict_deep_merge(dict_b, dict_a, lambda: dict()) + # pylint: enable=unnecessary-lambda -class TestEditorSettingsFile(PwIdeTestCase): - """Tests EditorSettingsFile""" - def test_open_new_file_and_write(self): - name = 'settings' - json_fmt = JsonFileFormat() - settings_file = EditorSettingsFile(self.temp_dir_path, name, json_fmt) +class TestDictSwapType(unittest.TestCase): + """Tests dict_swap_type""" - with settings_file.modify() as settings: - settings['hello'] = 'world' + def test_ordereddict_to_dict(self): + """Test converting an OrderedDict to a plain dict""" - with open(self.temp_dir_path / f'{name}.{json_fmt.ext}') as file: - settings_dict = json_fmt.load(file) + ordered_dict = OrderedDict( + { + 'hello': 'world', + 'foo': 'bar', + 'nested': OrderedDict( + { + 'lorem': 'ipsum', + 'dolor': 'sit amet', + } + ), + } + ) - self.assertEqual(settings_dict['hello'], 'world') + plain_dict = dict_swap_type(ordered_dict, dict) - def test_open_new_file_and_get(self): - name = 'settings' - json_fmt = JsonFileFormat() - settings_file = EditorSettingsFile(self.temp_dir_path, name, json_fmt) + expected_plain_dict = { + 'hello': 'world', + 'foo': 'bar', + 'nested': { + 'lorem': 'ipsum', + 'dolor': 'sit amet', + }, + } + + # The returned dict has the content and type we expect + self.assertDictEqual(plain_dict, expected_plain_dict) + self.assertIsInstance(plain_dict, dict) + self.assertIsInstance(plain_dict['nested'], dict) + + # The original OrderedDict is unchanged + self.assertIsInstance(ordered_dict, OrderedDict) + self.assertIsInstance(ordered_dict['nested'], OrderedDict) - with settings_file.modify() as settings: - settings['hello'] = 'world' - settings_dict = settings_file.get() - self.assertEqual(settings_dict['hello'], 'world') +class EditorSettingsTestType(Enum): + SETTINGS = 'settings' + - def test_open_new_file_no_backup(self): - name = 'settings' - json_fmt = JsonFileFormat() - settings_file = EditorSettingsFile(self.temp_dir_path, name, json_fmt) +class TestCasesGenericOnFileFormat: + """Container for tests generic on FileFormat. - with settings_file.modify() as settings: - settings['hello'] = 'world' + This misdirection is needed to prevent the base test class cases from being + run as actual tests. + """ - backup_files = [ - path - for path in self.temp_dir_path.iterdir() - if path.name != f'{name}.{json_fmt.ext}' - ] + class EditorSettingsFileTestCase(PwIdeTestCase): + """Test case for EditorSettingsFile with a provided FileFormat""" - self.assertEqual(len(backup_files), 0) + def setUp(self): + if not hasattr(self, 'file_format'): + self.file_format = _StructuredFileFormat() + return super().setUp() - def test_open_existing_file_and_backup(self): - name = 'settings' - json_fmt = JsonFileFormat() - settings_file = EditorSettingsFile(self.temp_dir_path, name, json_fmt) + def test_open_new_file_and_write(self): + name = 'settings' + settings_file = EditorSettingsFile( + self.temp_dir_path, name, self.file_format + ) - with settings_file.modify() as settings: - settings['hello'] = 'world' + with settings_file.modify() as settings: + settings['hello'] = 'world' - with settings_file.modify() as settings: - settings['hello'] = 'mundo' + with open( + self.temp_dir_path / f'{name}.{self.file_format.ext}' + ) as file: + settings_dict = self.file_format.load(file) - settings_dict = settings_file.get() - self.assertEqual(settings_dict['hello'], 'mundo') + self.assertEqual(settings_dict['hello'], 'world') - backup_files = [ - path - for path in self.temp_dir_path.iterdir() - if path.name != f'{name}.{json_fmt.ext}' - ] + def test_open_new_file_and_get(self): + name = 'settings' + settings_file = EditorSettingsFile( + self.temp_dir_path, name, self.file_format + ) - self.assertEqual(len(backup_files), 1) + with settings_file.modify() as settings: + settings['hello'] = 'world' - with open(backup_files[0]) as file: - settings_dict = json_fmt.load(file) + settings_dict = settings_file.get() + self.assertEqual(settings_dict['hello'], 'world') - self.assertEqual(settings_dict['hello'], 'world') + def test_open_new_file_no_backup(self): + name = 'settings' + settings_file = EditorSettingsFile( + self.temp_dir_path, name, self.file_format + ) - def test_open_existing_file_with_reinit_and_backup(self): - name = 'settings' - json_fmt = JsonFileFormat() - settings_file = EditorSettingsFile(self.temp_dir_path, name, json_fmt) + with settings_file.modify() as settings: + settings['hello'] = 'world' - with settings_file.modify() as settings: - settings['hello'] = 'world' + backup_files = [ + path + for path in self.temp_dir_path.iterdir() + if path.name != f'{name}.{self.file_format.ext}' + ] - with settings_file.modify(reinit=True) as settings: - settings['hello'] = 'mundo' + self.assertEqual(len(backup_files), 0) - settings_dict = settings_file.get() - self.assertEqual(settings_dict['hello'], 'mundo') + def test_open_existing_file_and_backup(self): + name = 'settings' + settings_file = EditorSettingsFile( + self.temp_dir_path, name, self.file_format + ) - backup_files = [ - path - for path in self.temp_dir_path.iterdir() - if path.name != f'{name}.{json_fmt.ext}' - ] + with settings_file.modify() as settings: + settings['hello'] = 'world' - self.assertEqual(len(backup_files), 1) + with settings_file.modify() as settings: + settings['hello'] = 'mundo' - with open(backup_files[0]) as file: - settings_dict = json_fmt.load(file) + settings_dict = settings_file.get() + self.assertEqual(settings_dict['hello'], 'mundo') - self.assertEqual(settings_dict['hello'], 'world') + backup_files = [ + path + for path in self.temp_dir_path.iterdir() + if path.name != f'{name}.{self.file_format.ext}' + ] - def open_existing_file_no_change_no_backup(self): - name = 'settings' - json_fmt = JsonFileFormat() - settings_file = EditorSettingsFile(self.temp_dir_path, name, json_fmt) + self.assertEqual(len(backup_files), 1) - with settings_file.modify() as settings: - settings['hello'] = 'world' + with open(backup_files[0]) as file: + settings_dict = self.file_format.load(file) - with settings_file.modify() as settings: - settings['hello'] = 'world' + self.assertEqual(settings_dict['hello'], 'world') - settings_dict = settings_file.get() - self.assertEqual(settings_dict['hello'], 'world') + def test_open_existing_file_with_reinit_and_backup(self): + name = 'settings' + settings_file = EditorSettingsFile( + self.temp_dir_path, name, self.file_format + ) - backup_files = [ - path - for path in self.temp_dir_path.iterdir() - if path.name != f'{name}.{json_fmt.ext}' - ] + with settings_file.modify() as settings: + settings['hello'] = 'world' - self.assertEqual(len(backup_files), 0) + with settings_file.modify(reinit=True) as settings: + settings['hello'] = 'mundo' - with open(backup_files[0]) as file: - settings_dict = json_fmt.load(file) + settings_dict = settings_file.get() + self.assertEqual(settings_dict['hello'], 'mundo') - self.assertEqual(settings_dict['hello'], 'world') + backup_files = [ + path + for path in self.temp_dir_path.iterdir() + if path.name != f'{name}.{self.file_format.ext}' + ] - def test_write_bad_file_restore_backup(self): - name = 'settings' - json_fmt = JsonFileFormat() - settings_file = EditorSettingsFile(self.temp_dir_path, name, json_fmt) + self.assertEqual(len(backup_files), 1) - with settings_file.modify() as settings: - settings['hello'] = 'world' + with open(backup_files[0]) as file: + settings_dict = self.file_format.load(file) - with self.assertRaises(TypeError): - with settings_file.modify() as settings: - settings['hello'] = object() + self.assertEqual(settings_dict['hello'], 'world') - settings_dict = settings_file.get() - self.assertEqual(settings_dict['hello'], 'world') + def test_open_existing_file_no_change_no_backup(self): + name = 'settings' + settings_file = EditorSettingsFile( + self.temp_dir_path, name, self.file_format + ) - backup_files = [ - path - for path in self.temp_dir_path.iterdir() - if path.name != f'{name}.{json_fmt.ext}' - ] + with settings_file.modify() as settings: + settings['hello'] = 'world' - self.assertEqual(len(backup_files), 0) + with settings_file.modify() as settings: + settings['hello'] = 'world' + settings_dict = settings_file.get() + self.assertEqual(settings_dict['hello'], 'world') -class EditorSettingsTestType(Enum): - SETTINGS = 'settings' + backup_files = [ + path + for path in self.temp_dir_path.iterdir() + if path.name != f'{name}.{self.file_format.ext}' + ] + self.assertEqual(len(backup_files), 0) -class TestEditorSettingsManager(PwIdeTestCase): - """Tests EditorSettingsManager""" + def test_write_bad_file_restore_backup(self): + name = 'settings' + settings_file = EditorSettingsFile( + self.temp_dir_path, name, self.file_format + ) - def test_settings_merge(self): - """Test that settings merge as expected in isolation.""" - default_settings = OrderedDict( - { - 'foo': 'bar', - 'baz': 'qux', - 'lorem': OrderedDict( - { - 'ipsum': 'dolor', - } - ), + with settings_file.modify() as settings: + settings['hello'] = 'world' + + with self.assertRaises(self.file_format.unserializable_error): + with settings_file.modify() as settings: + settings['hello'] = object() + + settings_dict = settings_file.get() + self.assertEqual(settings_dict['hello'], 'world') + + backup_files = [ + path + for path in self.temp_dir_path.iterdir() + if path.name != f'{name}.{self.file_format.ext}' + ] + + self.assertEqual(len(backup_files), 0) + + class EditorSettingsManagerTestCase(PwIdeTestCase): + """Test case for EditorSettingsManager with a provided FileFormat""" + + def setUp(self): + if not hasattr(self, 'file_format'): + self.file_format = _StructuredFileFormat() + return super().setUp() + + def test_settings_merge(self): + """Test that settings merge as expected in isolation.""" + default_settings = OrderedDict( + { + 'foo': 'bar', + 'baz': 'qux', + 'lorem': OrderedDict( + { + 'ipsum': 'dolor', + } + ), + } + ) + + types_with_defaults = { + EditorSettingsTestType.SETTINGS: lambda _: default_settings } - ) - types_with_defaults = { - EditorSettingsTestType.SETTINGS: lambda _: default_settings - } + ide_settings = self.make_ide_settings() + manager = EditorSettingsManager( + ide_settings, + self.temp_dir_path, + self.file_format, + types_with_defaults, + ) - ide_settings = self.make_ide_settings() - json_fmt = JsonFileFormat() - manager = EditorSettingsManager( - ide_settings, self.temp_dir_path, json_fmt, types_with_defaults - ) + project_settings = OrderedDict( + { + 'alpha': 'beta', + 'baz': 'xuq', + 'foo': 'rab', + } + ) - project_settings = OrderedDict( - { + with manager.project( + EditorSettingsTestType.SETTINGS + ).modify() as settings: + dict_deep_merge(project_settings, settings) + + user_settings = OrderedDict( + { + 'baz': 'xqu', + 'lorem': OrderedDict( + { + 'ipsum': 'sit amet', + 'consectetur': 'adipiscing', + } + ), + } + ) + + with manager.user( + EditorSettingsTestType.SETTINGS + ).modify() as settings: + dict_deep_merge(user_settings, settings) + + expected = { 'alpha': 'beta', - 'baz': 'xuq', 'foo': 'rab', + 'baz': 'xqu', + 'lorem': { + 'ipsum': 'sit amet', + 'consectetur': 'adipiscing', + }, } - ) - with manager.project( - EditorSettingsTestType.SETTINGS - ).modify() as settings: - dict_deep_merge(project_settings, settings) + with manager.active( + EditorSettingsTestType.SETTINGS + ).modify() as active_settings: + manager.default(EditorSettingsTestType.SETTINGS).sync_to( + active_settings + ) + manager.project(EditorSettingsTestType.SETTINGS).sync_to( + active_settings + ) + manager.user(EditorSettingsTestType.SETTINGS).sync_to( + active_settings + ) + + self.assertCountEqual( + manager.active(EditorSettingsTestType.SETTINGS).get(), expected + ) - user_settings = OrderedDict( - { - 'baz': 'xqu', - 'lorem': OrderedDict( - { - 'ipsum': 'sit amet', - 'consectetur': 'adipiscing', - } - ), - } - ) - with manager.user(EditorSettingsTestType.SETTINGS).modify() as settings: - dict_deep_merge(user_settings, settings) +class TestEditorSettingsFileJsonFormat( + TestCasesGenericOnFileFormat.EditorSettingsFileTestCase +): + """Test EditorSettingsFile with JsonFormat""" - expected = { - 'alpha': 'beta', - 'foo': 'rab', - 'baz': 'xqu', - 'lorem': { - 'ipsum': 'sit amet', - 'consectetur': 'adipiscing', - }, - } + def setUp(self): + self.file_format = JsonFileFormat() + return super().setUp() - with manager.active( - EditorSettingsTestType.SETTINGS - ).modify() as active_settings: - manager.default(EditorSettingsTestType.SETTINGS).sync_to( - active_settings - ) - manager.project(EditorSettingsTestType.SETTINGS).sync_to( - active_settings - ) - manager.user(EditorSettingsTestType.SETTINGS).sync_to( - active_settings - ) - self.assertCountEqual( - manager.active(EditorSettingsTestType.SETTINGS).get(), expected - ) +class TestEditorSettingsManagerJsonFormat( + TestCasesGenericOnFileFormat.EditorSettingsManagerTestCase +): + """Test EditorSettingsManager with JsonFormat""" + + def setUp(self): + self.file_format = JsonFileFormat() + return super().setUp() + + +class TestEditorSettingsFileJson5Format( + TestCasesGenericOnFileFormat.EditorSettingsFileTestCase +): + """Test EditorSettingsFile with Json5Format""" + + def setUp(self): + self.file_format = Json5FileFormat() + return super().setUp() + + +class TestEditorSettingsManagerJson5Format( + TestCasesGenericOnFileFormat.EditorSettingsManagerTestCase +): + """Test EditorSettingsManager with Json5Format""" + + def setUp(self): + self.file_format = Json5FileFormat() + return super().setUp() + + +class TestEditorSettingsFileYamlFormat( + TestCasesGenericOnFileFormat.EditorSettingsFileTestCase +): + """Test EditorSettingsFile with YamlFormat""" + + def setUp(self): + self.file_format = YamlFileFormat() + return super().setUp() + + +class TestEditorSettingsManagerYamlFormat( + TestCasesGenericOnFileFormat.EditorSettingsManagerTestCase +): + """Test EditorSettingsManager with YamlFormat""" + + def setUp(self): + self.file_format = YamlFileFormat() + return super().setUp() if __name__ == '__main__': diff --git a/pw_ide/py/pw_ide/editors.py b/pw_ide/py/pw_ide/editors.py index f59e353461..b38cab231a 100644 --- a/pw_ide/py/pw_ide/editors.py +++ b/pw_ide/py/pw_ide/editors.py @@ -57,8 +57,10 @@ Literal, Optional, OrderedDict, + Type, TypeVar, ) +import yaml import json5 # type: ignore @@ -70,8 +72,18 @@ class _StructuredFileFormat: @property def ext(self) -> str: + """The file extension for this file format.""" return 'null' + @property + def unserializable_error(self) -> Type[Exception]: + """The error class that will be raised when writing unserializable data. + + This allows us to generically catch serialization errors without needing + to know which file format we're using. + """ + return TypeError + def load(self, *args, **kwargs) -> OrderedDict: raise ValueError( f'Cannot load from file with {self.__class__.__name__}!' @@ -90,11 +102,13 @@ def ext(self) -> str: def load(self, *args, **kwargs) -> OrderedDict: """Load JSON into an ordered dict.""" + # Load into an OrderedDict instead of a plain dict kwargs['object_pairs_hook'] = OrderedDict return json.load(*args, **kwargs) def dump(self, data: OrderedDict, *args, **kwargs) -> None: """Dump JSON in a readable format.""" + # Ensure the output is human-readable kwargs['indent'] = 2 json.dump(data, *args, **kwargs) @@ -111,20 +125,55 @@ def ext(self) -> str: def load(self, *args, **kwargs) -> OrderedDict: """Load JSON into an ordered dict.""" + # Load into an OrderedDict instead of a plain dict kwargs['object_pairs_hook'] = OrderedDict return json5.load(*args, **kwargs) def dump(self, data: OrderedDict, *args, **kwargs) -> None: """Dump JSON in a readable format.""" + # Ensure the output is human-readable kwargs['indent'] = 2 + # Prevent unquoting keys that don't strictly need to be quoted kwargs['quote_keys'] = True json5.dump(data, *args, **kwargs) +class YamlFileFormat(_StructuredFileFormat): + """YAML file format.""" + + @property + def ext(self) -> str: + return 'yaml' + + @property + def unserializable_error(self) -> Type[Exception]: + return yaml.representer.RepresenterError + + def load(self, *args, **kwargs) -> OrderedDict: + """Load YAML into an ordered dict.""" + return OrderedDict(yaml.safe_load(*args, **kwargs)) + + def dump(self, data: OrderedDict, *args, **kwargs) -> None: + """Dump YAML in a readable format.""" + # Ensure the output is human-readable + kwargs['indent'] = 2 + # Always use the "block" style (i.e. the dict-like style) + kwargs['default_flow_style'] = False + # Don't infere with ordering + kwargs['sort_keys'] = False + # The yaml module doesn't understand OrderedDicts + data_to_dump = dict_swap_type(data, dict) + yaml.safe_dump(data_to_dump, *args, **kwargs) + + # Allows constraining to dicts and dict subclasses, while also constraining to # the *same* dict subclass. _DictLike = TypeVar('_DictLike', bound=Dict) +# Likewise, constrain to a specific dict subclass, but one that can be different +# from that of _DictLike. +_AnotherDictLike = TypeVar('_AnotherDictLike', bound=Dict) + def dict_deep_merge( src: _DictLike, @@ -192,6 +241,27 @@ def dict_deep_merge( return dest +def dict_swap_type( + src: _DictLike, + ctor: Callable[[], _AnotherDictLike], +) -> _AnotherDictLike: + """Change the dict subclass of all dicts in a nested dict-like structure. + + This returns new data and does not mutate the original data structure. + """ + dest = ctor() + + for key, value in src.items(): + # The value is a nested dict; recursively construct. + if isinstance(value, src.__class__): + dest[key] = dict_swap_type(value, ctor) + # The value is something else; copy it over. + else: + dest[key] = value + + return dest + + # Editor settings are manipulated via this dict-like data structure. We use # OrderedDict to avoid non-deterministic changes to settings files and to make # diffs more readable. Note that the values here can't really be "Any". They @@ -381,9 +451,9 @@ def modify(self, reinit: bool = False): try: self._format.dump(settings, file) - except TypeError: + except self._format.unserializable_error: # We'll get this error if we try to sneak something in that's - # not JSON-serializable. Unless we handle this, we'll end up + # not serializable. Unless we handle this, we may end up # with a partially-written file that can't be parsed. So we # delete that and restore the backup. file.close()