aboutsummaryrefslogtreecommitdiff
path: root/pw_ide/py/pw_ide/editors.py
diff options
context:
space:
mode:
Diffstat (limited to 'pw_ide/py/pw_ide/editors.py')
-rw-r--r--pw_ide/py/pw_ide/editors.py213
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']