• 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
48import json
49from pathlib import Path
50import time
51from typing import (
52    Any,
53    Callable,
54    Dict,
55    Generator,
56    Generic,
57    Literal,
58    Optional,
59    OrderedDict,
60    TypeVar,
61)
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        return 'null'
74
75    def load(self, *args, **kwargs) -> OrderedDict:
76        raise ValueError(
77            f'Cannot load from file with {self.__class__.__name__}!'
78        )
79
80    def dump(self, data: OrderedDict, *args, **kwargs) -> None:
81        raise ValueError(f'Cannot dump to file with {self.__class__.__name__}!')
82
83
84class JsonFileFormat(_StructuredFileFormat):
85    """JSON file format."""
86
87    @property
88    def ext(self) -> str:
89        return 'json'
90
91    def load(self, *args, **kwargs) -> OrderedDict:
92        """Load JSON into an ordered dict."""
93        kwargs['object_pairs_hook'] = OrderedDict
94        return json.load(*args, **kwargs)
95
96    def dump(self, data: OrderedDict, *args, **kwargs) -> None:
97        """Dump JSON in a readable format."""
98        kwargs['indent'] = 2
99        json.dump(data, *args, **kwargs)
100
101
102class Json5FileFormat(_StructuredFileFormat):
103    """JSON5 file format.
104
105    Supports parsing files with comments and trailing commas.
106    """
107
108    @property
109    def ext(self) -> str:
110        return 'json'
111
112    def load(self, *args, **kwargs) -> OrderedDict:
113        """Load JSON into an ordered dict."""
114        kwargs['object_pairs_hook'] = OrderedDict
115        return json5.load(*args, **kwargs)
116
117    def dump(self, data: OrderedDict, *args, **kwargs) -> None:
118        """Dump JSON in a readable format."""
119        kwargs['indent'] = 2
120        kwargs['quote_keys'] = True
121        json5.dump(data, *args, **kwargs)
122
123
124# Allows constraining to dicts and dict subclasses, while also constraining to
125# the *same* dict subclass.
126_DictLike = TypeVar('_DictLike', bound=Dict)
127
128
129def dict_deep_merge(
130    src: _DictLike,
131    dest: _DictLike,
132    ctor: Optional[Callable[[], _DictLike]] = None,
133) -> _DictLike:
134    """Deep merge dict-like `src` into dict-like `dest`.
135
136    `dest` is mutated in place and also returned.
137
138    `src` and `dest` need to be the same subclass of dict. If they're anything
139    other than basic dicts, you need to also provide a constructor that returns
140    an empty dict of the same subclass.
141    """
142    # Ensure that src and dest are the same type of dict.
143    # These kinds of direct class comparisons are un-Pythonic, but the invariant
144    # here really is that they be exactly the same class, rather than "same" in
145    # the polymorphic sense.
146    if dest.__class__ != src.__class__:
147        raise TypeError(
148            'Cannot merge dicts of different subclasses!\n'
149            f'src={src.__class__.__name__}, '
150            f'dest={dest.__class__.__name__}'
151        )
152
153    # If a constructor for this subclass wasn't provided, try using a
154    # zero-arg constructor for the provided dicts.
155    if ctor is None:
156        ctor = lambda: src.__class__()  # pylint: disable=unnecessary-lambda
157
158    # Ensure that we have a way to construct an empty dict of the same type.
159    try:
160        empty_dict = ctor()
161    except TypeError:
162        # The constructor has required arguments.
163        raise TypeError(
164            'When merging a dict subclass, you must provide a '
165            'constructor for the subclass that produces an empty '
166            'dict.\n'
167            f'src/dest={src.__class__.__name__}'
168        )
169
170    if empty_dict.__class__ != src.__class__:
171        # The constructor returns something of the wrong type.
172        raise TypeError(
173            'When merging a dict subclass, you must provide a '
174            'constructor for the subclass that produces an empty '
175            'dict.\n'
176            f'src/dest={src.__class__.__name__}, '
177            f'constructor={ctor().__class__.__name__}'
178        )
179
180    for key, value in src.items():
181        empty_dict = ctor()
182        # The value is a nested dict; recursively merge.
183        if isinstance(value, src.__class__):
184            node = dest.setdefault(key, empty_dict)
185            dict_deep_merge(value, node, ctor)
186        # The value is something else; copy it over.
187        # TODO(chadnorvell): This doesn't deep merge other data structures, e.g.
188        # lists, lists of dicts, dicts of lists, etc.
189        else:
190            dest[key] = value
191
192    return dest
193
194
195# Editor settings are manipulated via this dict-like data structure. We use
196# OrderedDict to avoid non-deterministic changes to settings files and to make
197# diffs more readable. Note that the values here can't really be "Any". They
198# need to be JSON serializable, and any embedded dicts should also be
199# OrderedDicts.
200EditorSettingsDict = OrderedDict[str, Any]
201
202# A callback that provides default settings in dict form when given ``pw_ide``
203# settings (which may be ignored in many cases).
204DefaultSettingsCallback = Callable[[PigweedIdeSettings], EditorSettingsDict]
205
206
207class EditorSettingsDefinition:
208    """Provides access to a particular group of editor settings.
209
210    A particular editor may have one or more settings *types* (e.g., editor
211    settings vs. automated tasks settings, or separate settings files for
212    each supported language). ``pw_ide`` also supports multiple settings
213    *levels*, where the "active" settings are built from default, project,
214    and user settings. Each combination of settings type and level will have
215    one ``EditorSettingsDefinition``, which may be in memory (e.g., for default
216    settings defined in code) or may be backed by a file (see
217    ``EditorSettingsFile``).
218
219    Settings are accessed using the ``modify`` context manager, which provides
220    you a dict-like data structure to manipulate.
221
222    Initial settings can be provided in the constructor via a callback that
223    takes an instance of ``PigweedIdeSettings`` and returns a settings dict.
224    This allows the initial settings to be dependent on overall IDE features
225    settings.
226    """
227
228    def __init__(
229        self,
230        pw_ide_settings: Optional[PigweedIdeSettings] = None,
231        data: Optional[DefaultSettingsCallback] = None,
232    ):
233        self._data: EditorSettingsDict = OrderedDict()
234
235        if data is not None and pw_ide_settings is not None:
236            self._data = data(pw_ide_settings)
237
238    def __repr__(self) -> str:
239        return f'<{self.__class__.__name__}: (in memory)>'
240
241    def get(self) -> EditorSettingsDict:
242        """Return the settings as an ordered dict."""
243        return self._data
244
245    @contextmanager
246    def modify(self, reinit: bool = False):
247        """Modify a settings file via an ordered dict."""
248        if reinit:
249            new_data: OrderedDict[str, Any] = OrderedDict()
250            yield new_data
251            self._data = new_data
252        else:
253            yield self._data
254
255    def sync_to(self, settings: EditorSettingsDict) -> None:
256        """Merge this set of settings on top of the provided settings."""
257        self_settings = self.get()
258        settings = dict_deep_merge(self_settings, settings)
259
260    def is_present(self) -> bool:  # pylint: disable=no-self-use
261        return True
262
263    def delete(self) -> None:
264        pass
265
266    def delete_backups(self) -> None:
267        pass
268
269
270class EditorSettingsFile(EditorSettingsDefinition):
271    """Provides access to an editor settings defintion stored in a file.
272
273    It's assumed that the editor's settings are stored in a file format that
274    can be deserialized to Python dicts. The settings are represented by
275    an ordered dict to make the diff that results from modifying the settings
276    as easy to read as possible (assuming it has a plain text representation).
277
278    This represents the concept of a file; the file may not actually be
279    present on disk yet.
280    """
281
282    def __init__(
283        self, settings_dir: Path, name: str, file_format: _StructuredFileFormat
284    ) -> None:
285        self._name = name
286        self._format = file_format
287        self._path = settings_dir / f'{name}.{self._format.ext}'
288        super().__init__()
289
290    def __repr__(self) -> str:
291        return f'<{self.__class__.__name__}: {str(self._path)}>'
292
293    def _backup_filename(self, glob=False):
294        timestamp = time.strftime('%Y%m%d_%H%M%S')
295        timestamp = '*' if glob else timestamp
296        backup_str = f'.{timestamp}.bak'
297        return f'{self._name}{backup_str}.{self._format.ext}'
298
299    def _make_backup(self) -> Path:
300        return self._path.replace(self._path.with_name(self._backup_filename()))
301
302    def _restore_backup(self, backup: Path) -> Path:
303        return backup.replace(self._path)
304
305    def get(self) -> EditorSettingsDict:
306        """Read a settings file into an ordered dict.
307
308        This does not keep the file context open, so while the dict is
309        mutable, any changes will not be written to disk.
310        """
311        try:
312            with self._path.open() as file:
313                settings: OrderedDict = self._format.load(file)
314        except FileNotFoundError:
315            settings = OrderedDict()
316
317        return settings
318
319    @contextmanager
320    def modify(self, reinit: bool = False):
321        """Modify a settings file via an ordered dict.
322
323        Get the dict when entering the context, then modify it like any
324        other dict, with the caveat that whatever goes into it needs to be
325        JSON-serializable. Example:
326
327        .. code-block:: python
328
329            with settings_file.modify() as settings:
330                settings[foo] = bar
331
332        After modifying the settings and leaving this context, the file will
333        be written. If the file already exists, a backup will be made. If a
334        failure occurs while writing the new file, it will be deleted and the
335        backup will be restored.
336
337        If the ``reinit`` argument is set, a new, empty file will be created
338        instead of modifying any existing file. If there is an existing file,
339        it will still be backed up.
340        """
341        if self._path.exists():
342            should_load_existing = True
343            should_backup = True
344        else:
345            should_load_existing = False
346            should_backup = False
347
348        if reinit:
349            should_load_existing = False
350
351        if should_load_existing:
352            with self._path.open() as file:
353                settings: OrderedDict = self._format.load(file)
354        else:
355            settings = OrderedDict()
356
357        prev_settings = settings.copy()
358
359        # TODO(chadnorvell): There's a subtle bug here where you can't assign
360        # to this var and have it take effect. You have to modify it in place.
361        # But you won't notice until things don't get written to disk.
362        yield settings
363
364        # If the settings haven't changed, don't create a backup.
365        if should_load_existing:
366            if settings == prev_settings:
367                should_backup = False
368
369        if should_backup:
370            # Move the current file to a new backup file. This frees the main
371            # file for open('x').
372            backup = self._make_backup()
373        else:
374            backup = None
375            # If the file exists and we didn't move it to a backup file, delete
376            # it so we can open('x') it again.
377            if self._path.exists():
378                self._path.unlink()
379
380        file = self._path.open('x')
381
382        try:
383            self._format.dump(settings, file)
384        except TypeError:
385            # We'll get this error if we try to sneak something in that's
386            # not JSON-serializable. Unless we handle this, we'll end up
387            # with a partially-written file that can't be parsed. So we
388            # delete that and restore the backup.
389            file.close()
390            self._path.unlink()
391
392            if backup is not None:
393                self._restore_backup(backup)
394
395            raise
396        finally:
397            if not file.closed:
398                file.close()
399
400    def is_present(self) -> bool:
401        return self._path.exists()
402
403    def delete(self) -> None:
404        try:
405            self._path.unlink()
406        except FileNotFoundError:
407            pass
408
409    def delete_backups(self) -> None:
410        glob = self._backup_filename(glob=True)
411
412        for path in self._path.glob(glob):
413            path.unlink()
414
415
416_SettingsLevelName = Literal['default', 'active', 'project', 'user']
417
418
419@dataclass(frozen=True)
420class SettingsLevelData:
421    name: _SettingsLevelName
422    is_user_configurable: bool
423    is_file: bool
424
425
426class SettingsLevel(enum.Enum):
427    """Cascading set of settings.
428
429    This provides a unified mechanism for having active settings (those
430    actually used by an editor) be built from default settings in Pigweed,
431    project settings checked into the project's repository, and user settings
432    particular to one checkout of the project, each of which can override
433    settings higher up in the chain.
434    """
435
436    DEFAULT = SettingsLevelData(
437        'default', is_user_configurable=False, is_file=False
438    )
439    PROJECT = SettingsLevelData(
440        'project', is_user_configurable=True, is_file=True
441    )
442    USER = SettingsLevelData('user', is_user_configurable=True, is_file=True)
443    ACTIVE = SettingsLevelData(
444        'active', is_user_configurable=False, is_file=True
445    )
446
447    @property
448    def is_user_configurable(self) -> bool:
449        return self.value.is_user_configurable
450
451    @property
452    def is_file(self) -> bool:
453        return self.value.is_file
454
455    @classmethod
456    def all_levels(cls) -> Generator['SettingsLevel', None, None]:
457        return (level for level in cls)
458
459    @classmethod
460    def all_not_default(cls) -> Generator['SettingsLevel', None, None]:
461        return (level for level in cls if level is not cls.DEFAULT)
462
463    @classmethod
464    def all_user_configurable(cls) -> Generator['SettingsLevel', None, None]:
465        return (level for level in cls if level.is_user_configurable)
466
467    @classmethod
468    def all_files(cls) -> Generator['SettingsLevel', None, None]:
469        return (level for level in cls if level.is_file)
470
471
472# A map of configurable settings levels and the string that will be prepended
473# to their files to indicate their settings level.
474SettingsFilePrefixes = Dict[SettingsLevel, str]
475
476# Each editor will have one or more settings types that typically reflect each
477# of the files used to define their settings. So each editor should have an
478# enum type that defines each of those settings types, and this type var
479# represents that generically. The value of each enum case should be the file
480# name of that settings file, without the extension.
481# TODO(chadnorvell): Would be great to constrain this to enums, but bound=
482# doesn't do what we want with Enum or EnumMeta.
483_SettingsTypeT = TypeVar('_SettingsTypeT')
484
485# Maps each settings type with the callback that generates the default settings
486# for that settings type.
487EditorSettingsTypesWithDefaults = Dict[_SettingsTypeT, DefaultSettingsCallback]
488
489
490class EditorSettingsManager(Generic[_SettingsTypeT]):
491    """Manages all settings for a particular editor.
492
493    This is where you interact with an editor's settings (actually in a
494    subclass of this class, not here). Initializing this class sets up access
495    to one or more settings files for an editor (determined by
496    ``_SettingsTypeT``, fulfilled by an enum that defines each of an editor's
497    settings files), along with the cascading settings levels.
498    """
499
500    # Prefixes should only be defined for settings that will be stored on disk
501    # and are not the active settings file, which will use the name without a
502    # prefix. This may be overridden in child classes, but typically should
503    # not be.
504    prefixes: SettingsFilePrefixes = {
505        SettingsLevel.PROJECT: 'pw_project_',
506        SettingsLevel.USER: 'pw_user_',
507    }
508
509    # These must be overridden in child classes.
510    default_settings_dir: Path = None  # type: ignore
511    file_format: _StructuredFileFormat = _StructuredFileFormat()
512    types_with_defaults: EditorSettingsTypesWithDefaults[_SettingsTypeT] = {}
513
514    def __init__(
515        self,
516        pw_ide_settings: PigweedIdeSettings,
517        settings_dir: Optional[Path] = None,
518        file_format: Optional[_StructuredFileFormat] = None,
519        types_with_defaults: Optional[
520            EditorSettingsTypesWithDefaults[_SettingsTypeT]
521        ] = None,
522    ):
523        if SettingsLevel.ACTIVE in self.__class__.prefixes:
524            raise ValueError(
525                'You cannot assign a file name prefix to '
526                'an active settings file.'
527            )
528
529        # This lets us use ``self._prefixes`` transparently for any file,
530        # including active settings files, since it will provide an empty
531        # string prefix for those files. In other words, while the class
532        # attribute `prefixes` can only be defined for configurable settings,
533        # `self._prefixes` extends it to work for any settings file.
534        self._prefixes = defaultdict(str, self.__class__.prefixes)
535
536        # The default settings directory is defined by the subclass attribute
537        # `default_settings_dir`, and that value is used the vast majority of
538        # the time. But you can inject an alternative directory in the
539        # constructor if needed (e.g. for tests).
540        self._settings_dir = (
541            settings_dir
542            if settings_dir is not None
543            else self.__class__.default_settings_dir
544        )
545
546        # The backing file format should normally be defined by the class
547        # attribute ``file_format``, but can be overridden in the constructor.
548        self._file_format: _StructuredFileFormat = (
549            file_format
550            if file_format is not None
551            else self.__class__.file_format
552        )
553
554        # The settings types with their defaults should normally be defined by
555        # the class attribute ``types_with_defaults``, but can be overridden
556        # in the constructor.
557        self._types_with_defaults = (
558            types_with_defaults
559            if types_with_defaults is not None
560            else self.__class__.types_with_defaults
561        )
562
563        # For each of the settings levels, there is a settings definition for
564        # each settings type. Those settings definitions may be stored in files
565        # or not.
566        self._settings_definitions: Dict[
567            SettingsLevel, Dict[_SettingsTypeT, EditorSettingsDefinition]
568        ] = {}
569
570        self._settings_types = tuple(self._types_with_defaults.keys())
571
572        # Initialize the default settings level for each settings type, which
573        # defined in code, not files.
574        self._settings_definitions[SettingsLevel.DEFAULT] = {}
575
576        for (
577            settings_type
578        ) in (
579            self._types_with_defaults
580        ):  # pylint: disable=consider-using-dict-items
581            self._settings_definitions[SettingsLevel.DEFAULT][
582                settings_type
583            ] = EditorSettingsDefinition(
584                pw_ide_settings, self._types_with_defaults[settings_type]
585            )
586
587        # Initialize the settings definitions for each settings type for each
588        # settings level that's stored on disk.
589        for level in SettingsLevel.all_files():
590            self._settings_definitions[level] = {}
591
592            for settings_type in self._types_with_defaults:
593                name = f'{self._prefixes[level]}{settings_type.value}'
594                self._settings_definitions[level][
595                    settings_type
596                ] = EditorSettingsFile(
597                    self._settings_dir, name, self._file_format
598                )
599
600    def default(self, settings_type: _SettingsTypeT):
601        """Default settings for the provided settings type."""
602        return self._settings_definitions[SettingsLevel.DEFAULT][settings_type]
603
604    def project(self, settings_type: _SettingsTypeT):
605        """Project settings for the provided settings type."""
606        return self._settings_definitions[SettingsLevel.PROJECT][settings_type]
607
608    def user(self, settings_type: _SettingsTypeT):
609        """User settings for the provided settings type."""
610        return self._settings_definitions[SettingsLevel.USER][settings_type]
611
612    def active(self, settings_type: _SettingsTypeT):
613        """Active settings for the provided settings type."""
614        return self._settings_definitions[SettingsLevel.ACTIVE][settings_type]
615
616    def delete_all_active_settings(self) -> None:
617        """Delete all active settings files."""
618        for settings_type in self._settings_types:
619            self.active(settings_type).delete()
620
621    def delete_all_backups(self) -> None:
622        """Delete all backup files."""
623        for settings_type in self._settings_types:
624            self.project(settings_type).delete_backups()
625            self.user(settings_type).delete_backups()
626            self.active(settings_type).delete_backups()
627