diff options
Diffstat (limited to 'pw_ide/py/pw_ide/editors.py')
-rw-r--r-- | pw_ide/py/pw_ide/editors.py | 213 |
1 files changed, 125 insertions, 88 deletions
diff --git a/pw_ide/py/pw_ide/editors.py b/pw_ide/py/pw_ide/editors.py index f59e35346..24d185b89 100644 --- a/pw_ide/py/pw_ide/editors.py +++ b/pw_ide/py/pw_ide/editors.py @@ -45,9 +45,9 @@ from collections import defaultdict from contextlib import contextmanager from dataclasses import dataclass import enum +from hashlib import sha1 import json from pathlib import Path -import time from typing import ( Any, Callable, @@ -57,8 +57,10 @@ from typing import ( 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 @@ class JsonFileFormat(_StructuredFileFormat): 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,58 @@ class Json5FileFormat(_StructuredFileFormat): 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.""" + # This relies on the fact that in Python 3.6+, dicts are stored in + # order, as an implementation detail rather than by design contract. + data = yaml.safe_load(*args, **kwargs) + return dict_swap_type(data, OrderedDict) + + 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, @@ -138,6 +190,10 @@ def dict_deep_merge( `src` and `dest` need to be the same subclass of dict. If they're anything other than basic dicts, you need to also provide a constructor that returns an empty dict of the same subclass. + + This is only intended to support dicts of JSON-serializable values, i.e., + numbers, booleans, strings, lists, and dicts, all of which will be copied. + All other object types will be rejected with an exception. """ # Ensure that src and dest are the same type of dict. # These kinds of direct class comparisons are un-Pythonic, but the invariant @@ -183,9 +239,41 @@ def dict_deep_merge( if isinstance(value, src.__class__): node = dest.setdefault(key, empty_dict) dict_deep_merge(value, node, ctor) + # The value is a list; merge if the corresponding dest value is a list. + elif isinstance(value, list) and isinstance(dest.get(key, []), list): + # Disallow duplicates arising from the same value appearing in both. + try: + dest[key] += [x for x in value if x not in dest[key]] + except KeyError: + dest[key] = list(value) + # The value is a string; copy the value. + elif isinstance(value, str): + dest[key] = f'{value}' + # The value is scalar (int, float, bool); copy it over. + elif isinstance(value, (int, float, bool)): + dest[key] = value + # The value is some other object type; it's not supported. + else: + raise TypeError(f'Cannot merge value of type {type(value)}') + + 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. - # TODO(chadnorvell): This doesn't deep merge other data structures, e.g. - # lists, lists of dicts, dicts of lists, etc. else: dest[key] = value @@ -242,15 +330,27 @@ class EditorSettingsDefinition: """Return the settings as an ordered dict.""" return self._data + def hash(self) -> str: + return sha1(json.dumps(self.get()).encode('utf-8')).hexdigest() + @contextmanager - def modify(self, reinit: bool = False): - """Modify a settings file via an ordered dict.""" - if reinit: - new_data: OrderedDict[str, Any] = OrderedDict() - yield new_data - self._data = new_data - else: - yield self._data + def build(self) -> Generator[OrderedDict[str, Any], None, None]: + """Expose a settings file builder. + + You get an empty dict when entering the content, then you can build + up settings by using ``sync_to`` to merge other settings dicts into this + one, as long as everything is JSON-serializable. Example: + + .. code-block:: python + + with settings_definition.modify() as settings: + some_other_settings.sync_to(settings) + + This data is not persisted to disk. + """ + new_data: OrderedDict[str, Any] = OrderedDict() + yield new_data + self._data = new_data def sync_to(self, settings: EditorSettingsDict) -> None: """Merge this set of settings on top of the provided settings.""" @@ -290,18 +390,6 @@ class EditorSettingsFile(EditorSettingsDefinition): def __repr__(self) -> str: return f'<{self.__class__.__name__}: {str(self._path)}>' - def _backup_filename(self, glob=False): - timestamp = time.strftime('%Y%m%d_%H%M%S') - timestamp = '*' if glob else timestamp - backup_str = f'.{timestamp}.bak' - return f'{self._name}{backup_str}.{self._format.ext}' - - def _make_backup(self) -> Path: - return self._path.replace(self._path.with_name(self._backup_filename())) - - def _restore_backup(self, backup: Path) -> Path: - return backup.replace(self._path) - def get(self) -> EditorSettingsDict: """Read a settings file into an ordered dict. @@ -317,81 +405,36 @@ class EditorSettingsFile(EditorSettingsDefinition): return settings @contextmanager - def modify(self, reinit: bool = False): - """Modify a settings file via an ordered dict. + def build(self) -> Generator[OrderedDict[str, Any], None, None]: + """Expose a settings file builder. - Get the dict when entering the context, then modify it like any - other dict, with the caveat that whatever goes into it needs to be - JSON-serializable. Example: + You get an empty dict when entering the content, then you can build + up settings by using ``sync_to`` to merge other settings dicts into this + one, as long as everything is JSON-serializable. Example: .. code-block:: python with settings_file.modify() as settings: - settings[foo] = bar + some_other_settings.sync_to(settings) After modifying the settings and leaving this context, the file will - be written. If the file already exists, a backup will be made. If a - failure occurs while writing the new file, it will be deleted and the - backup will be restored. - - If the ``reinit`` argument is set, a new, empty file will be created - instead of modifying any existing file. If there is an existing file, - it will still be backed up. + be written. If a failure occurs while writing the new file, it will be + deleted. """ - if self._path.exists(): - should_load_existing = True - should_backup = True - else: - should_load_existing = False - should_backup = False - - if reinit: - should_load_existing = False - - if should_load_existing: - with self._path.open() as file: - settings: OrderedDict = self._format.load(file) - else: - settings = OrderedDict() - - prev_settings = settings.copy() - - # TODO(chadnorvell): There's a subtle bug here where you can't assign - # to this var and have it take effect. You have to modify it in place. - # But you won't notice until things don't get written to disk. - yield settings - - # If the settings haven't changed, don't create a backup. - if should_load_existing: - if settings == prev_settings: - should_backup = False - - if should_backup: - # Move the current file to a new backup file. This frees the main - # file for open('x'). - backup = self._make_backup() - else: - backup = None - # If the file exists and we didn't move it to a backup file, delete - # it so we can open('x') it again. - if self._path.exists(): - self._path.unlink() - - file = self._path.open('x') + new_data: OrderedDict[str, Any] = OrderedDict() + yield new_data + file = self._path.open('w') try: - self._format.dump(settings, file) - except TypeError: + self._format.dump(new_data, file) + 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() self._path.unlink() - if backup is not None: - self._restore_backup(backup) - raise finally: if not file.closed: @@ -406,12 +449,6 @@ class EditorSettingsFile(EditorSettingsDefinition): except FileNotFoundError: pass - def delete_backups(self) -> None: - glob = self._backup_filename(glob=True) - - for path in self._path.glob(glob): - path.unlink() - _SettingsLevelName = Literal['default', 'active', 'project', 'user'] |