| # Copyright 2022 The Pigweed Authors |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); you may not |
| # use this file except in compliance with the License. You may obtain a copy of |
| # the License at |
| # |
| # https://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| # License for the specific language governing permissions and limitations under |
| # the License. |
| """Framework for configuring code editors for Pigweed projects. |
| |
| Editors and IDEs vary in the way they're configured and the options they |
| provide for configuration. As long as an editor uses files we can parse to |
| store its settings, this framework can be used to provide a consistent |
| interface to managing those settings in the context of a Pigweed project. |
| |
| Ideally, we want to provide three levels of editor settings for a project: |
| |
| - User settings (specific to the user's checkout) |
| - Project settings (included in source control, consistent for all users) |
| - Default settings (defined by Pigweed) |
| |
| ... where the settings on top can override (or cascade over) settings defined |
| below. |
| |
| Some editors already provide mechanisms for achieving this, but in ways that |
| are particular to that editor, and many other editors don't provide this |
| mechanism at all. So we provide it in a uniform way here by adding a fourth |
| settings level, active settings, which are the actual settings files the editor |
| uses. Active settings are *built* (rather than edited or cloned) by looking for |
| user, project, and default settings (which are defined by Pigweed and ignored |
| by the editor) and combining them in the order described above. In this way, |
| Pigweed can provide sensible defaults, projects can define additional settings |
| to provide a uniform development experience, and users have the freedom to make |
| their own changes. |
| """ |
| |
| # TODO(chadnorvell): Import collections.OrderedDict when we don't need to |
| # support Python 3.8 anymore. |
| from collections import defaultdict |
| from contextlib import contextmanager |
| from dataclasses import dataclass |
| import enum |
| import json |
| from pathlib import Path |
| import time |
| from typing import ( |
| Any, |
| Callable, |
| Dict, |
| Generator, |
| Generic, |
| Literal, |
| Optional, |
| OrderedDict, |
| TypeVar, |
| ) |
| |
| import json5 # type: ignore |
| |
| from pw_ide.settings import PigweedIdeSettings |
| |
| |
| class _StructuredFileFormat: |
| """Base class for structured settings file formats.""" |
| @property |
| def ext(self) -> str: |
| return 'null' |
| |
| def load(self, *args, **kwargs) -> OrderedDict: |
| raise ValueError( |
| f'Cannot load from file with {self.__class__.__name__}!') |
| |
| def dump(self, data: OrderedDict, *args, **kwargs) -> None: |
| raise ValueError( |
| f'Cannot dump to file with {self.__class__.__name__}!') |
| |
| |
| class JsonFileFormat(_StructuredFileFormat): |
| """JSON file format.""" |
| @property |
| def ext(self) -> str: |
| return 'json' |
| |
| def load(self, *args, **kwargs) -> OrderedDict: |
| """Load JSON into an ordered 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.""" |
| kwargs['indent'] = 2 |
| json.dump(data, *args, **kwargs) |
| |
| |
| class Json5FileFormat(_StructuredFileFormat): |
| """JSON5 file format. |
| |
| Supports parsing files with comments and trailing commas. |
| """ |
| @property |
| def ext(self) -> str: |
| return 'json' |
| |
| def load(self, *args, **kwargs) -> OrderedDict: |
| """Load JSON into an ordered 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.""" |
| kwargs['indent'] = 2 |
| kwargs['quote_keys'] = True |
| json5.dump(data, *args, **kwargs) |
| |
| |
| # Allows constraining to dicts and dict subclasses, while also constraining to |
| # the *same* dict subclass. |
| _TDictLike = TypeVar('_TDictLike', bound=Dict) |
| |
| |
| def dict_deep_merge( |
| src: _TDictLike, |
| dest: _TDictLike, |
| ctor: Optional[Callable[[], _TDictLike]] = None) -> _TDictLike: |
| """Deep merge dict-like `src` into dict-like `dest`. |
| |
| `dest` is mutated in place and also returned. |
| |
| `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. |
| """ |
| # Ensure that src and dest are the same type of dict. |
| # These kinds of direct class comparisons are un-Pythonic, but the invariant |
| # here really is that they be exactly the same class, rather than "same" in |
| # the polymorphic sense. |
| if dest.__class__ != src.__class__: |
| raise TypeError('Cannot merge dicts of different subclasses!\n' |
| f'src={src.__class__.__name__}, ' |
| f'dest={dest.__class__.__name__}') |
| |
| # If a constructor for this subclass wasn't provided, try using a |
| # zero-arg constructor for the provided dicts. |
| if ctor is None: |
| ctor = lambda: src.__class__() # pylint: disable=unnecessary-lambda |
| |
| # Ensure that we have a way to construct an empty dict of the same type. |
| try: |
| empty_dict = ctor() |
| except TypeError: |
| # The constructor has required arguments. |
| raise TypeError('When merging a dict subclass, you must provide a ' |
| 'constructor for the subclass that produces an empty ' |
| 'dict.\n' |
| f'src/dest={src.__class__.__name__}') |
| |
| if empty_dict.__class__ != src.__class__: |
| # The constructor returns something of the wrong type. |
| raise TypeError('When merging a dict subclass, you must provide a ' |
| 'constructor for the subclass that produces an empty ' |
| 'dict.\n' |
| f'src/dest={src.__class__.__name__}, ' |
| f'constructor={ctor().__class__.__name__}') |
| |
| for key, value in src.items(): |
| empty_dict = ctor() |
| # The value is a nested dict; recursively merge. |
| if isinstance(value, src.__class__): |
| node = dest.setdefault(key, empty_dict) |
| dict_deep_merge(value, node, 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 |
| |
| 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 |
| # need to be JSON serializable, and any embedded dicts should also be |
| # OrderedDicts. |
| EditorSettingsDict = OrderedDict[str, Any] |
| |
| # A callback that provides default settings in dict form when given ``pw_ide`` |
| # settings (which may be ignored in many cases). |
| DefaultSettingsCallback = Callable[[PigweedIdeSettings], EditorSettingsDict] |
| |
| |
| class EditorSettingsDefinition: |
| """Provides access to a particular group of editor settings. |
| |
| A particular editor may have one or more settings *types* (e.g., editor |
| settings vs. automated tasks settings, or separate settings files for |
| each supported language). ``pw_ide`` also supports multiple settings |
| *levels*, where the "active" settings are built from default, project, |
| and user settings. Each combination of settings type and level will have |
| one ``EditorSettingsDefinition``, which may be in memory (e.g., for default |
| settings defined in code) or may be backed by a file (see |
| ``EditorSettingsFile``). |
| |
| Settings are accessed using the ``modify`` context manager, which provides |
| you a dict-like data structure to manipulate. |
| |
| Initial settings can be provided in the constructor via a callback that |
| takes an instance of ``PigweedIdeSettings`` and returns a settings dict. |
| This allows the initial settings to be dependent on overall IDE features |
| settings. |
| """ |
| def __init__(self, |
| pw_ide_settings: Optional[PigweedIdeSettings] = None, |
| data: Optional[DefaultSettingsCallback] = None): |
| self._data: EditorSettingsDict = OrderedDict() |
| |
| if data is not None and pw_ide_settings is not None: |
| self._data = data(pw_ide_settings) |
| |
| def __repr__(self) -> str: |
| return f'<{self.__class__.__name__}: (in memory)>' |
| |
| def get(self) -> EditorSettingsDict: |
| """Return the settings as an ordered dict.""" |
| return self._data |
| |
| @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 sync_to(self, settings: EditorSettingsDict) -> None: |
| """Merge this set of settings on top of the provided settings.""" |
| self_settings = self.get() |
| settings = dict_deep_merge(self_settings, settings) |
| |
| def is_present(self) -> bool: # pylint: disable=no-self-use |
| return True |
| |
| def delete(self) -> None: |
| pass |
| |
| def delete_backups(self) -> None: |
| pass |
| |
| |
| class EditorSettingsFile(EditorSettingsDefinition): |
| """Provides access to an editor settings defintion stored in a file. |
| |
| It's assumed that the editor's settings are stored in a file format that |
| can be deserialized to Python dicts. The settings are represented by |
| an ordered dict to make the diff that results from modifying the settings |
| as easy to read as possible (assuming it has a plain text representation). |
| |
| This represents the concept of a file; the file may not actually be |
| present on disk yet. |
| """ |
| def __init__(self, settings_dir: Path, name: str, |
| file_format: _StructuredFileFormat) -> None: |
| self._name = name |
| self._format = file_format |
| self._path = settings_dir / f'{name}.{self._format.ext}' |
| super().__init__() |
| |
| 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. |
| |
| This does not keep the file context open, so while the dict is |
| mutable, any changes will not be written to disk. |
| """ |
| try: |
| with self._path.open() as file: |
| settings: OrderedDict = self._format.load(file) |
| except FileNotFoundError: |
| settings = OrderedDict() |
| |
| return settings |
| |
| @contextmanager |
| def modify(self, reinit: bool = False): |
| """Modify a settings file via an ordered dict. |
| |
| 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: |
| |
| .. code-block:: python |
| |
| with settings_file.modify() as settings: |
| settings[foo] = bar |
| |
| 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. |
| """ |
| 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') |
| |
| try: |
| self._format.dump(settings, file) |
| except TypeError: |
| # 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 |
| # 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: |
| file.close() |
| |
| def is_present(self) -> bool: |
| return self._path.exists() |
| |
| def delete(self) -> None: |
| try: |
| self._path.unlink() |
| 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'] |
| |
| |
| @dataclass(frozen=True) |
| class SettingsLevelData: |
| name: _SettingsLevelName |
| is_user_configurable: bool |
| is_file: bool |
| |
| |
| class SettingsLevel(enum.Enum): |
| """Cascading set of settings. |
| |
| This provides a unified mechanism for having active settings (those |
| actually used by an editor) be built from default settings in Pigweed, |
| project settings checked into the project's repository, and user settings |
| particular to one checkout of the project, each of which can override |
| settings higher up in the chain. |
| """ |
| DEFAULT = SettingsLevelData('default', |
| is_user_configurable=False, |
| is_file=False) |
| PROJECT = SettingsLevelData('project', |
| is_user_configurable=True, |
| is_file=True) |
| USER = SettingsLevelData('user', is_user_configurable=True, is_file=True) |
| ACTIVE = SettingsLevelData('active', |
| is_user_configurable=False, |
| is_file=True) |
| |
| @property |
| def is_user_configurable(self) -> bool: |
| return self.value.is_user_configurable |
| |
| @property |
| def is_file(self) -> bool: |
| return self.value.is_file |
| |
| @classmethod |
| def all_levels(cls) -> Generator['SettingsLevel', None, None]: |
| return (level for level in cls) |
| |
| @classmethod |
| def all_not_default(cls) -> Generator['SettingsLevel', None, None]: |
| return (level for level in cls if level is not cls.DEFAULT) |
| |
| @classmethod |
| def all_user_configurable(cls) -> Generator['SettingsLevel', None, None]: |
| return (level for level in cls if level.is_user_configurable) |
| |
| @classmethod |
| def all_files(cls) -> Generator['SettingsLevel', None, None]: |
| return (level for level in cls if level.is_file) |
| |
| |
| # A map of configurable settings levels and the string that will be prepended |
| # to their files to indicate their settings level. |
| SettingsFilePrefixes = Dict[SettingsLevel, str] |
| |
| # Each editor will have one or more settings types that typically reflect each |
| # of the files used to define their settings. So each editor should have an |
| # enum type that defines each of those settings types, and this type var |
| # represents that generically. The value of each enum case should be the file |
| # name of that settings file, without the extension. |
| # TODO(chadnorvell): Would be great to constrain this to enums, but bound= |
| # doesn't do what we want with Enum or EnumMeta. |
| _TSettingsType = TypeVar('_TSettingsType') |
| |
| # Maps each settings type with the callback that generates the default settings |
| # for that settings type. |
| EditorSettingsTypesWithDefaults = Dict[_TSettingsType, DefaultSettingsCallback] |
| |
| |
| class EditorSettingsManager(Generic[_TSettingsType]): |
| """Manages all settings for a particular editor. |
| |
| This is where you interact with an editor's settings (actually in a |
| subclass of this class, not here). Initializing this class sets up access |
| to one or more settings files for an editor (determined by |
| ``_TSettingsType``, fulfilled by an enum that defines each of an editor's |
| settings files), along with the cascading settings levels. |
| """ |
| # Prefixes should only be defined for settings that will be stored on disk |
| # and are not the active settings file, which will use the name without a |
| # prefix. This may be overridden in child classes, but typically should |
| # not be. |
| prefixes: SettingsFilePrefixes = { |
| SettingsLevel.PROJECT: 'pw_project_', |
| SettingsLevel.USER: 'pw_user_', |
| } |
| |
| # These must be overridden in child classes. |
| default_settings_dir: Path = None # type: ignore |
| file_format: _StructuredFileFormat = _StructuredFileFormat() |
| types_with_defaults: EditorSettingsTypesWithDefaults[_TSettingsType] = {} |
| |
| def __init__(self, |
| pw_ide_settings: PigweedIdeSettings, |
| settings_dir: Optional[Path] = None, |
| file_format: Optional[_StructuredFileFormat] = None, |
| types_with_defaults: Optional[ |
| EditorSettingsTypesWithDefaults[_TSettingsType]] = None): |
| if SettingsLevel.ACTIVE in self.__class__.prefixes: |
| raise ValueError('You cannot assign a file name prefix to ' |
| 'an active settings file.') |
| |
| # This lets us use ``self._prefixes`` transparently for any file, |
| # including active settings files, since it will provide an empty |
| # string prefix for those files. In other words, while the class |
| # attribute `prefixes` can only be defined for configurable settings, |
| # `self._prefixes` extends it to work for any settings file. |
| self._prefixes = defaultdict(str, self.__class__.prefixes) |
| |
| # The default settings directory is defined by the subclass attribute |
| # `default_settings_dir`, and that value is used the vast majority of |
| # the time. But you can inject an alternative directory in the |
| # constructor if needed (e.g. for tests). |
| self._settings_dir = (settings_dir if settings_dir is not None else |
| self.__class__.default_settings_dir) |
| |
| # The backing file format should normally be defined by the class |
| # attribute ``file_format``, but can be overridden in the constructor. |
| self._file_format: _StructuredFileFormat = (file_format if file_format |
| is not None else |
| self.__class__.file_format) |
| |
| # The settings types with their defaults should normally be defined by |
| # the class attribute ``types_with_defaults``, but can be overridden |
| # in the constructor. |
| self._types_with_defaults = (types_with_defaults |
| if types_with_defaults is not None else |
| self.__class__.types_with_defaults) |
| |
| # For each of the settings levels, there is a settings definition for |
| # each settings type. Those settings definitions may be stored in files |
| # or not. |
| self._settings_definitions: Dict[SettingsLevel, |
| Dict[_TSettingsType, |
| EditorSettingsDefinition]] = {} |
| |
| self._settings_types = tuple(self._types_with_defaults.keys()) |
| |
| # Initialize the default settings level for each settings type, which |
| # defined in code, not files. |
| self._settings_definitions[SettingsLevel.DEFAULT] = {} |
| |
| for settings_type in self._types_with_defaults: # pylint: disable=consider-using-dict-items |
| self._settings_definitions[SettingsLevel.DEFAULT][ |
| settings_type] = EditorSettingsDefinition( |
| pw_ide_settings, self._types_with_defaults[settings_type]) |
| |
| # Initialize the settings definitions for each settings type for each |
| # settings level that's stored on disk. |
| for level in SettingsLevel.all_files(): |
| self._settings_definitions[level] = {} |
| |
| for settings_type in self._types_with_defaults: |
| name = f'{self._prefixes[level]}{settings_type.value}' |
| self._settings_definitions[level][ |
| settings_type] = EditorSettingsFile( |
| self._settings_dir, name, self._file_format) |
| |
| def default(self, settings_type: _TSettingsType): |
| """Default settings for the provided settings type.""" |
| return self._settings_definitions[SettingsLevel.DEFAULT][settings_type] |
| |
| def project(self, settings_type: _TSettingsType): |
| """Project settings for the provided settings type.""" |
| return self._settings_definitions[SettingsLevel.PROJECT][settings_type] |
| |
| def user(self, settings_type: _TSettingsType): |
| """User settings for the provided settings type.""" |
| return self._settings_definitions[SettingsLevel.USER][settings_type] |
| |
| def active(self, settings_type: _TSettingsType): |
| """Active settings for the provided settings type.""" |
| return self._settings_definitions[SettingsLevel.ACTIVE][settings_type] |
| |
| def delete_all_active_settings(self) -> None: |
| """Delete all active settings files.""" |
| for settings_type in self._settings_types: |
| self.project(settings_type).delete() |
| self.user(settings_type).delete() |
| self.active(settings_type).delete() |
| |
| def delete_all_backups(self) -> None: |
| """Delete all backup files.""" |
| for settings_type in self._settings_types: |
| self.project(settings_type).delete_backups() |
| self.user(settings_type).delete_backups() |
| self.active(settings_type).delete_backups() |