blob: 12ca4cc61b32664153f4f8ea130a55e0198ddb38 [file] [log] [blame]
# 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
from hashlib import sha1
import json
from pathlib import Path
from typing import (
Any,
Callable,
Generator,
Generic,
Literal,
OrderedDict,
Type,
TypeVar,
)
import yaml
import json5 # type: ignore
from pw_ide.settings import PigweedIdeSettings
class _StructuredFileFormat:
"""Base class for structured settings file formats."""
@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__}!'
)
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."""
# 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)
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."""
# 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,
dest: _DictLike,
ctor: Callable[[], _DictLike] | None = 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.
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
# 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 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.
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: PigweedIdeSettings | None = None,
data: DefaultSettingsCallback | None = 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
def hash(self) -> str:
return sha1(json.dumps(self.get()).encode('utf-8')).hexdigest()
@contextmanager
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."""
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 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 ValueError as e:
raise ValueError(
f"Settings file {self} could not be parsed. "
"Check the file for syntax errors."
) from e
except FileNotFoundError:
settings = OrderedDict()
return settings
@contextmanager
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_file.modify() as settings:
some_other_settings.sync_to(settings)
After modifying the settings and leaving this context, the file will
be written. If a failure occurs while writing the new file, it will be
deleted.
"""
new_data: OrderedDict[str, Any] = OrderedDict()
yield new_data
file = self._path.open('w')
try:
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 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()
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
_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: Path | None = None,
file_format: _StructuredFileFormat | None = None,
types_with_defaults: (
EditorSettingsTypesWithDefaults[_SettingsTypeT] | None
) = 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()