• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2022 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14"""Framework for configuring code editors for Pigweed projects.
15
16Editors and IDEs vary in the way they're configured and the options they
17provide for configuration. As long as an editor uses files we can parse to
18store its settings, this framework can be used to provide a consistent
19interface to managing those settings in the context of a Pigweed project.
20
21Ideally, we want to provide three levels of editor settings for a project:
22
23- User settings (specific to the user's checkout)
24- Project settings (included in source control, consistent for all users)
25- Default settings (defined by Pigweed)
26
27... where the settings on top can override (or cascade over) settings defined
28below.
29
30Some editors already provide mechanisms for achieving this, but in ways that
31are particular to that editor, and many other editors don't provide this
32mechanism at all. So we provide it in a uniform way here by adding a fourth
33settings level, active settings, which are the actual settings files the editor
34uses. Active settings are *built* (rather than edited or cloned) by looking for
35user, project, and default settings (which are defined by Pigweed and ignored
36by the editor) and combining them in the order described above. In this way,
37Pigweed can provide sensible defaults, projects can define additional settings
38to provide a uniform development experience, and users have the freedom to make
39their own changes.
40"""
41
42# TODO(chadnorvell): Import collections.OrderedDict when we don't need to
43# support Python 3.8 anymore.
44from collections import defaultdict
45from contextlib import contextmanager
46from dataclasses import dataclass
47import enum
48from hashlib import sha1
49import json
50from pathlib import Path
51from typing import (
52    Any,
53    Callable,
54    Generator,
55    Generic,
56    Literal,
57    OrderedDict,
58    Type,
59    TypeVar,
60)
61import yaml
62
63import json5  # type: ignore
64
65from pw_ide.settings import PigweedIdeSettings
66
67
68class _StructuredFileFormat:
69    """Base class for structured settings file formats."""
70
71    @property
72    def ext(self) -> str:
73        """The file extension for this file format."""
74        return 'null'
75
76    @property
77    def unserializable_error(self) -> Type[Exception]:
78        """The error class that will be raised when writing unserializable data.
79
80        This allows us to generically catch serialization errors without needing
81        to know which file format we're using.
82        """
83        return TypeError
84
85    def load(self, *args, **kwargs) -> OrderedDict:
86        raise ValueError(
87            f'Cannot load from file with {self.__class__.__name__}!'
88        )
89
90    def dump(self, data: OrderedDict, *args, **kwargs) -> None:
91        raise ValueError(f'Cannot dump to file with {self.__class__.__name__}!')
92
93
94class JsonFileFormat(_StructuredFileFormat):
95    """JSON file format."""
96
97    @property
98    def ext(self) -> str:
99        return 'json'
100
101    def load(self, *args, **kwargs) -> OrderedDict:
102        """Load JSON into an ordered dict."""
103        # Load into an OrderedDict instead of a plain dict
104        kwargs['object_pairs_hook'] = OrderedDict
105        return json.load(*args, **kwargs)
106
107    def dump(self, data: OrderedDict, *args, **kwargs) -> None:
108        """Dump JSON in a readable format."""
109        # Ensure the output is human-readable
110        kwargs['indent'] = 2
111        json.dump(data, *args, **kwargs)
112
113
114class Json5FileFormat(_StructuredFileFormat):
115    """JSON5 file format.
116
117    Supports parsing files with comments and trailing commas.
118    """
119
120    @property
121    def ext(self) -> str:
122        return 'json'
123
124    def load(self, *args, **kwargs) -> OrderedDict:
125        """Load JSON into an ordered dict."""
126        # Load into an OrderedDict instead of a plain dict
127        kwargs['object_pairs_hook'] = OrderedDict
128        return json5.load(*args, **kwargs)
129
130    def dump(self, data: OrderedDict, *args, **kwargs) -> None:
131        """Dump JSON in a readable format."""
132        # Ensure the output is human-readable
133        kwargs['indent'] = 2
134        # Prevent unquoting keys that don't strictly need to be quoted
135        kwargs['quote_keys'] = True
136        json5.dump(data, *args, **kwargs)
137
138
139class YamlFileFormat(_StructuredFileFormat):
140    """YAML file format."""
141
142    @property
143    def ext(self) -> str:
144        return 'yaml'
145
146    @property
147    def unserializable_error(self) -> Type[Exception]:
148        return yaml.representer.RepresenterError
149
150    def load(self, *args, **kwargs) -> OrderedDict:
151        """Load YAML into an ordered dict."""
152        # This relies on the fact that in Python 3.6+, dicts are stored in
153        # order, as an implementation detail rather than by design contract.
154        data = yaml.safe_load(*args, **kwargs)
155        return dict_swap_type(data, OrderedDict)
156
157    def dump(self, data: OrderedDict, *args, **kwargs) -> None:
158        """Dump YAML in a readable format."""
159        # Ensure the output is human-readable
160        kwargs['indent'] = 2
161        # Always use the "block" style (i.e. the dict-like style)
162        kwargs['default_flow_style'] = False
163        # Don't infere with ordering
164        kwargs['sort_keys'] = False
165        # The yaml module doesn't understand OrderedDicts
166        data_to_dump = dict_swap_type(data, dict)
167        yaml.safe_dump(data_to_dump, *args, **kwargs)
168
169
170# Allows constraining to dicts and dict subclasses, while also constraining to
171# the *same* dict subclass.
172_DictLike = TypeVar('_DictLike', bound=dict)
173
174# Likewise, constrain to a specific dict subclass, but one that can be different
175# from that of _DictLike.
176_AnotherDictLike = TypeVar('_AnotherDictLike', bound=dict)
177
178
179def dict_deep_merge(
180    src: _DictLike,
181    dest: _DictLike,
182    ctor: Callable[[], _DictLike] | None = None,
183) -> _DictLike:
184    """Deep merge dict-like `src` into dict-like `dest`.
185
186    `dest` is mutated in place and also returned.
187
188    `src` and `dest` need to be the same subclass of dict. If they're anything
189    other than basic dicts, you need to also provide a constructor that returns
190    an empty dict of the same subclass.
191
192    This is only intended to support dicts of JSON-serializable values, i.e.,
193    numbers, booleans, strings, lists, and dicts, all of which will be copied.
194    All other object types will be rejected with an exception.
195    """
196    # Ensure that src and dest are the same type of dict.
197    # These kinds of direct class comparisons are un-Pythonic, but the invariant
198    # here really is that they be exactly the same class, rather than "same" in
199    # the polymorphic sense.
200    if dest.__class__ != src.__class__:
201        raise TypeError(
202            'Cannot merge dicts of different subclasses!\n'
203            f'src={src.__class__.__name__}, '
204            f'dest={dest.__class__.__name__}'
205        )
206
207    # If a constructor for this subclass wasn't provided, try using a
208    # zero-arg constructor for the provided dicts.
209    if ctor is None:
210        ctor = lambda: src.__class__()  # pylint: disable=unnecessary-lambda
211
212    # Ensure that we have a way to construct an empty dict of the same type.
213    try:
214        empty_dict = ctor()
215    except TypeError:
216        # The constructor has required arguments.
217        raise TypeError(
218            'When merging a dict subclass, you must provide a '
219            'constructor for the subclass that produces an empty '
220            'dict.\n'
221            f'src/dest={src.__class__.__name__}'
222        )
223
224    if empty_dict.__class__ != src.__class__:
225        # The constructor returns something of the wrong type.
226        raise TypeError(
227            'When merging a dict subclass, you must provide a '
228            'constructor for the subclass that produces an empty '
229            'dict.\n'
230            f'src/dest={src.__class__.__name__}, '
231            f'constructor={ctor().__class__.__name__}'
232        )
233
234    for key, value in src.items():
235        empty_dict = ctor()
236        # The value is a nested dict; recursively merge.
237        if isinstance(value, src.__class__):
238            node = dest.setdefault(key, empty_dict)
239            dict_deep_merge(value, node, ctor)
240        # The value is a list; merge if the corresponding dest value is a list.
241        elif isinstance(value, list) and isinstance(dest.get(key, []), list):
242            # Disallow duplicates arising from the same value appearing in both.
243            try:
244                dest[key] += [x for x in value if x not in dest[key]]
245            except KeyError:
246                dest[key] = list(value)
247        # The value is a string; copy the value.
248        elif isinstance(value, str):
249            dest[key] = f'{value}'
250        # The value is scalar (int, float, bool); copy it over.
251        elif isinstance(value, (int, float, bool)):
252            dest[key] = value
253        # The value is some other object type; it's not supported.
254        else:
255            raise TypeError(f'Cannot merge value of type {type(value)}')
256
257    return dest
258
259
260def dict_swap_type(
261    src: _DictLike,
262    ctor: Callable[[], _AnotherDictLike],
263) -> _AnotherDictLike:
264    """Change the dict subclass of all dicts in a nested dict-like structure.
265
266    This returns new data and does not mutate the original data structure.
267    """
268    dest = ctor()
269
270    for key, value in src.items():
271        # The value is a nested dict; recursively construct.
272        if isinstance(value, src.__class__):
273            dest[key] = dict_swap_type(value, ctor)
274        # The value is something else; copy it over.
275        else:
276            dest[key] = value
277
278    return dest
279
280
281# Editor settings are manipulated via this dict-like data structure. We use
282# OrderedDict to avoid non-deterministic changes to settings files and to make
283# diffs more readable. Note that the values here can't really be "Any". They
284# need to be JSON serializable, and any embedded dicts should also be
285# OrderedDicts.
286EditorSettingsDict = OrderedDict[str, Any]
287
288# A callback that provides default settings in dict form when given ``pw_ide``
289# settings (which may be ignored in many cases).
290DefaultSettingsCallback = Callable[[PigweedIdeSettings], EditorSettingsDict]
291
292
293class EditorSettingsDefinition:
294    """Provides access to a particular group of editor settings.
295
296    A particular editor may have one or more settings *types* (e.g., editor
297    settings vs. automated tasks settings, or separate settings files for
298    each supported language). ``pw_ide`` also supports multiple settings
299    *levels*, where the "active" settings are built from default, project,
300    and user settings. Each combination of settings type and level will have
301    one ``EditorSettingsDefinition``, which may be in memory (e.g., for default
302    settings defined in code) or may be backed by a file (see
303    ``EditorSettingsFile``).
304
305    Settings are accessed using the ``modify`` context manager, which provides
306    you a dict-like data structure to manipulate.
307
308    Initial settings can be provided in the constructor via a callback that
309    takes an instance of ``PigweedIdeSettings`` and returns a settings dict.
310    This allows the initial settings to be dependent on overall IDE features
311    settings.
312    """
313
314    def __init__(
315        self,
316        pw_ide_settings: PigweedIdeSettings | None = None,
317        data: DefaultSettingsCallback | None = None,
318    ):
319        self._data: EditorSettingsDict = OrderedDict()
320
321        if data is not None and pw_ide_settings is not None:
322            self._data = data(pw_ide_settings)
323
324    def __repr__(self) -> str:
325        return f'<{self.__class__.__name__}: (in memory)>'
326
327    def get(self) -> EditorSettingsDict:
328        """Return the settings as an ordered dict."""
329        return self._data
330
331    def hash(self) -> str:
332        return sha1(json.dumps(self.get()).encode('utf-8')).hexdigest()
333
334    @contextmanager
335    def build(self) -> Generator[OrderedDict[str, Any], None, None]:
336        """Expose a settings file builder.
337
338        You get an empty dict when entering the content, then you can build
339        up settings by using ``sync_to`` to merge other settings dicts into this
340        one, as long as everything is JSON-serializable. Example:
341
342        .. code-block:: python
343
344            with settings_definition.modify() as settings:
345                some_other_settings.sync_to(settings)
346
347        This data is not persisted to disk.
348        """
349        new_data: OrderedDict[str, Any] = OrderedDict()
350        yield new_data
351        self._data = new_data
352
353    def sync_to(self, settings: EditorSettingsDict) -> None:
354        """Merge this set of settings on top of the provided settings."""
355        self_settings = self.get()
356        settings = dict_deep_merge(self_settings, settings)
357
358    def is_present(self) -> bool:  # pylint: disable=no-self-use
359        return True
360
361    def delete(self) -> None:
362        pass
363
364    def delete_backups(self) -> None:
365        pass
366
367
368class EditorSettingsFile(EditorSettingsDefinition):
369    """Provides access to an editor settings defintion stored in a file.
370
371    It's assumed that the editor's settings are stored in a file format that
372    can be deserialized to Python dicts. The settings are represented by
373    an ordered dict to make the diff that results from modifying the settings
374    as easy to read as possible (assuming it has a plain text representation).
375
376    This represents the concept of a file; the file may not actually be
377    present on disk yet.
378    """
379
380    def __init__(
381        self, settings_dir: Path, name: str, file_format: _StructuredFileFormat
382    ) -> None:
383        self._name = name
384        self._format = file_format
385        self._path = settings_dir / f'{name}.{self._format.ext}'
386        super().__init__()
387
388    def __repr__(self) -> str:
389        return f'<{self.__class__.__name__}: {str(self._path)}>'
390
391    def get(self) -> EditorSettingsDict:
392        """Read a settings file into an ordered dict.
393
394        This does not keep the file context open, so while the dict is
395        mutable, any changes will not be written to disk.
396        """
397        try:
398            with self._path.open() as file:
399                settings: OrderedDict = self._format.load(file)
400        except ValueError as e:
401            raise ValueError(
402                f"Settings file {self} could not be parsed. "
403                "Check the file for syntax errors."
404            ) from e
405        except FileNotFoundError:
406            settings = OrderedDict()
407
408        return settings
409
410    @contextmanager
411    def build(self) -> Generator[OrderedDict[str, Any], None, None]:
412        """Expose a settings file builder.
413
414        You get an empty dict when entering the content, then you can build
415        up settings by using ``sync_to`` to merge other settings dicts into this
416        one, as long as everything is JSON-serializable. Example:
417
418        .. code-block:: python
419
420            with settings_file.modify() as settings:
421                some_other_settings.sync_to(settings)
422
423        After modifying the settings and leaving this context, the file will
424        be written. If a failure occurs while writing the new file, it will be
425        deleted.
426        """
427        new_data: OrderedDict[str, Any] = OrderedDict()
428        yield new_data
429        file = self._path.open('w')
430
431        try:
432            self._format.dump(new_data, file)
433        except self._format.unserializable_error:
434            # We'll get this error if we try to sneak something in that's
435            # not serializable. Unless we handle this, we may end up
436            # with a partially-written file that can't be parsed. So we
437            # delete that and restore the backup.
438            file.close()
439            self._path.unlink()
440
441            raise
442        finally:
443            if not file.closed:
444                file.close()
445
446    def is_present(self) -> bool:
447        return self._path.exists()
448
449    def delete(self) -> None:
450        try:
451            self._path.unlink()
452        except FileNotFoundError:
453            pass
454
455
456_SettingsLevelName = Literal['default', 'active', 'project', 'user']
457
458
459@dataclass(frozen=True)
460class SettingsLevelData:
461    name: _SettingsLevelName
462    is_user_configurable: bool
463    is_file: bool
464
465
466class SettingsLevel(enum.Enum):
467    """Cascading set of settings.
468
469    This provides a unified mechanism for having active settings (those
470    actually used by an editor) be built from default settings in Pigweed,
471    project settings checked into the project's repository, and user settings
472    particular to one checkout of the project, each of which can override
473    settings higher up in the chain.
474    """
475
476    DEFAULT = SettingsLevelData(
477        'default', is_user_configurable=False, is_file=False
478    )
479    PROJECT = SettingsLevelData(
480        'project', is_user_configurable=True, is_file=True
481    )
482    USER = SettingsLevelData('user', is_user_configurable=True, is_file=True)
483    ACTIVE = SettingsLevelData(
484        'active', is_user_configurable=False, is_file=True
485    )
486
487    @property
488    def is_user_configurable(self) -> bool:
489        return self.value.is_user_configurable
490
491    @property
492    def is_file(self) -> bool:
493        return self.value.is_file
494
495    @classmethod
496    def all_levels(cls) -> Generator['SettingsLevel', None, None]:
497        return (level for level in cls)
498
499    @classmethod
500    def all_not_default(cls) -> Generator['SettingsLevel', None, None]:
501        return (level for level in cls if level is not cls.DEFAULT)
502
503    @classmethod
504    def all_user_configurable(cls) -> Generator['SettingsLevel', None, None]:
505        return (level for level in cls if level.is_user_configurable)
506
507    @classmethod
508    def all_files(cls) -> Generator['SettingsLevel', None, None]:
509        return (level for level in cls if level.is_file)
510
511
512# A map of configurable settings levels and the string that will be prepended
513# to their files to indicate their settings level.
514SettingsFilePrefixes = dict[SettingsLevel, str]
515
516# Each editor will have one or more settings types that typically reflect each
517# of the files used to define their settings. So each editor should have an
518# enum type that defines each of those settings types, and this type var
519# represents that generically. The value of each enum case should be the file
520# name of that settings file, without the extension.
521# TODO(chadnorvell): Would be great to constrain this to enums, but bound=
522# doesn't do what we want with Enum or EnumMeta.
523_SettingsTypeT = TypeVar('_SettingsTypeT')
524
525# Maps each settings type with the callback that generates the default settings
526# for that settings type.
527EditorSettingsTypesWithDefaults = dict[_SettingsTypeT, DefaultSettingsCallback]
528
529
530class EditorSettingsManager(Generic[_SettingsTypeT]):
531    """Manages all settings for a particular editor.
532
533    This is where you interact with an editor's settings (actually in a
534    subclass of this class, not here). Initializing this class sets up access
535    to one or more settings files for an editor (determined by
536    ``_SettingsTypeT``, fulfilled by an enum that defines each of an editor's
537    settings files), along with the cascading settings levels.
538    """
539
540    # Prefixes should only be defined for settings that will be stored on disk
541    # and are not the active settings file, which will use the name without a
542    # prefix. This may be overridden in child classes, but typically should
543    # not be.
544    prefixes: SettingsFilePrefixes = {
545        SettingsLevel.PROJECT: 'pw_project_',
546        SettingsLevel.USER: 'pw_user_',
547    }
548
549    # These must be overridden in child classes.
550    default_settings_dir: Path = None  # type: ignore
551    file_format: _StructuredFileFormat = _StructuredFileFormat()
552    types_with_defaults: EditorSettingsTypesWithDefaults[_SettingsTypeT] = {}
553
554    def __init__(
555        self,
556        pw_ide_settings: PigweedIdeSettings,
557        settings_dir: Path | None = None,
558        file_format: _StructuredFileFormat | None = None,
559        types_with_defaults: (
560            EditorSettingsTypesWithDefaults[_SettingsTypeT] | None
561        ) = None,
562    ):
563        if SettingsLevel.ACTIVE in self.__class__.prefixes:
564            raise ValueError(
565                'You cannot assign a file name prefix to '
566                'an active settings file.'
567            )
568
569        # This lets us use ``self._prefixes`` transparently for any file,
570        # including active settings files, since it will provide an empty
571        # string prefix for those files. In other words, while the class
572        # attribute `prefixes` can only be defined for configurable settings,
573        # `self._prefixes` extends it to work for any settings file.
574        self._prefixes = defaultdict(str, self.__class__.prefixes)
575
576        # The default settings directory is defined by the subclass attribute
577        # `default_settings_dir`, and that value is used the vast majority of
578        # the time. But you can inject an alternative directory in the
579        # constructor if needed (e.g. for tests).
580        self._settings_dir = (
581            settings_dir
582            if settings_dir is not None
583            else self.__class__.default_settings_dir
584        )
585
586        # The backing file format should normally be defined by the class
587        # attribute ``file_format``, but can be overridden in the constructor.
588        self._file_format: _StructuredFileFormat = (
589            file_format
590            if file_format is not None
591            else self.__class__.file_format
592        )
593
594        # The settings types with their defaults should normally be defined by
595        # the class attribute ``types_with_defaults``, but can be overridden
596        # in the constructor.
597        self._types_with_defaults = (
598            types_with_defaults
599            if types_with_defaults is not None
600            else self.__class__.types_with_defaults
601        )
602
603        # For each of the settings levels, there is a settings definition for
604        # each settings type. Those settings definitions may be stored in files
605        # or not.
606        self._settings_definitions: dict[
607            SettingsLevel, dict[_SettingsTypeT, EditorSettingsDefinition]
608        ] = {}
609
610        self._settings_types = tuple(self._types_with_defaults.keys())
611
612        # Initialize the default settings level for each settings type, which
613        # defined in code, not files.
614        self._settings_definitions[SettingsLevel.DEFAULT] = {}
615
616        for (
617            settings_type
618        ) in (
619            self._types_with_defaults
620        ):  # pylint: disable=consider-using-dict-items
621            self._settings_definitions[SettingsLevel.DEFAULT][
622                settings_type
623            ] = EditorSettingsDefinition(
624                pw_ide_settings, self._types_with_defaults[settings_type]
625            )
626
627        # Initialize the settings definitions for each settings type for each
628        # settings level that's stored on disk.
629        for level in SettingsLevel.all_files():
630            self._settings_definitions[level] = {}
631
632            for settings_type in self._types_with_defaults:
633                name = f'{self._prefixes[level]}{settings_type.value}'
634                self._settings_definitions[level][
635                    settings_type
636                ] = EditorSettingsFile(
637                    self._settings_dir, name, self._file_format
638                )
639
640    def default(self, settings_type: _SettingsTypeT):
641        """Default settings for the provided settings type."""
642        return self._settings_definitions[SettingsLevel.DEFAULT][settings_type]
643
644    def project(self, settings_type: _SettingsTypeT):
645        """Project settings for the provided settings type."""
646        return self._settings_definitions[SettingsLevel.PROJECT][settings_type]
647
648    def user(self, settings_type: _SettingsTypeT):
649        """User settings for the provided settings type."""
650        return self._settings_definitions[SettingsLevel.USER][settings_type]
651
652    def active(self, settings_type: _SettingsTypeT):
653        """Active settings for the provided settings type."""
654        return self._settings_definitions[SettingsLevel.ACTIVE][settings_type]
655
656    def delete_all_active_settings(self) -> None:
657        """Delete all active settings files."""
658        for settings_type in self._settings_types:
659            self.active(settings_type).delete()
660
661    def delete_all_backups(self) -> None:
662        """Delete all backup files."""
663        for settings_type in self._settings_types:
664            self.project(settings_type).delete_backups()
665            self.user(settings_type).delete_backups()
666            self.active(settings_type).delete_backups()
667