# 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. _DictLike = TypeVar('_DictLike', bound=Dict) def dict_deep_merge( src: _DictLike, dest: _DictLike, ctor: Optional[Callable[[], _DictLike]] = None, ) -> _DictLike: """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. _SettingsTypeT = TypeVar('_SettingsTypeT') # Maps each settings type with the callback that generates the default settings # for that settings type. EditorSettingsTypesWithDefaults = Dict[_SettingsTypeT, DefaultSettingsCallback] class EditorSettingsManager(Generic[_SettingsTypeT]): """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 ``_SettingsTypeT``, 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[_SettingsTypeT] = {} def __init__( self, pw_ide_settings: PigweedIdeSettings, settings_dir: Optional[Path] = None, file_format: Optional[_StructuredFileFormat] = None, types_with_defaults: Optional[ EditorSettingsTypesWithDefaults[_SettingsTypeT] ] = 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[_SettingsTypeT, 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: _SettingsTypeT): """Default settings for the provided settings type.""" return self._settings_definitions[SettingsLevel.DEFAULT][settings_type] def project(self, settings_type: _SettingsTypeT): """Project settings for the provided settings type.""" return self._settings_definitions[SettingsLevel.PROJECT][settings_type] def user(self, settings_type: _SettingsTypeT): """User settings for the provided settings type.""" return self._settings_definitions[SettingsLevel.USER][settings_type] def active(self, settings_type: _SettingsTypeT): """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.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()