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 211 def ctor(): 212 return src.__class__() # pylint: disable=unnecessary-lambda 213 214 # Ensure that we have a way to construct an empty dict of the same type. 215 try: 216 empty_dict = ctor() 217 except TypeError: 218 # The constructor has required arguments. 219 raise TypeError( 220 'When merging a dict subclass, you must provide a ' 221 'constructor for the subclass that produces an empty ' 222 'dict.\n' 223 f'src/dest={src.__class__.__name__}' 224 ) 225 226 if empty_dict.__class__ != src.__class__: 227 # The constructor returns something of the wrong type. 228 raise TypeError( 229 'When merging a dict subclass, you must provide a ' 230 'constructor for the subclass that produces an empty ' 231 'dict.\n' 232 f'src/dest={src.__class__.__name__}, ' 233 f'constructor={ctor().__class__.__name__}' 234 ) 235 236 for key, value in src.items(): 237 empty_dict = ctor() 238 # The value is a nested dict; recursively merge. 239 if isinstance(value, src.__class__): 240 node = dest.setdefault(key, empty_dict) 241 dict_deep_merge(value, node, ctor) 242 # The value is a list; merge if the corresponding dest value is a list. 243 elif isinstance(value, list) and isinstance(dest.get(key, []), list): 244 # Disallow duplicates arising from the same value appearing in both. 245 try: 246 dest[key] += [x for x in value if x not in dest[key]] 247 except KeyError: 248 dest[key] = list(value) 249 # The value is a string; copy the value. 250 elif isinstance(value, str): 251 dest[key] = f'{value}' 252 # The value is scalar (int, float, bool); copy it over. 253 elif isinstance(value, (int, float, bool)): 254 dest[key] = value 255 # The value is some other object type; it's not supported. 256 else: 257 raise TypeError(f'Cannot merge value of type {type(value)}') 258 259 return dest 260 261 262def dict_swap_type( 263 src: _DictLike, 264 ctor: Callable[[], _AnotherDictLike], 265) -> _AnotherDictLike: 266 """Change the dict subclass of all dicts in a nested dict-like structure. 267 268 This returns new data and does not mutate the original data structure. 269 """ 270 dest = ctor() 271 272 for key, value in src.items(): 273 # The value is a nested dict; recursively construct. 274 if isinstance(value, src.__class__): 275 dest[key] = dict_swap_type(value, ctor) 276 # The value is something else; copy it over. 277 else: 278 dest[key] = value 279 280 return dest 281 282 283# Editor settings are manipulated via this dict-like data structure. We use 284# OrderedDict to avoid non-deterministic changes to settings files and to make 285# diffs more readable. Note that the values here can't really be "Any". They 286# need to be JSON serializable, and any embedded dicts should also be 287# OrderedDicts. 288EditorSettingsDict = OrderedDict[str, Any] 289 290# A callback that provides default settings in dict form when given ``pw_ide`` 291# settings (which may be ignored in many cases). 292DefaultSettingsCallback = Callable[[PigweedIdeSettings], EditorSettingsDict] 293 294 295class EditorSettingsDefinition: 296 """Provides access to a particular group of editor settings. 297 298 A particular editor may have one or more settings *types* (e.g., editor 299 settings vs. automated tasks settings, or separate settings files for 300 each supported language). ``pw_ide`` also supports multiple settings 301 *levels*, where the "active" settings are built from default, project, 302 and user settings. Each combination of settings type and level will have 303 one ``EditorSettingsDefinition``, which may be in memory (e.g., for default 304 settings defined in code) or may be backed by a file (see 305 ``EditorSettingsFile``). 306 307 Settings are accessed using the ``modify`` context manager, which provides 308 you a dict-like data structure to manipulate. 309 310 Initial settings can be provided in the constructor via a callback that 311 takes an instance of ``PigweedIdeSettings`` and returns a settings dict. 312 This allows the initial settings to be dependent on overall IDE features 313 settings. 314 """ 315 316 def __init__( 317 self, 318 pw_ide_settings: PigweedIdeSettings | None = None, 319 data: DefaultSettingsCallback | None = None, 320 ): 321 self._data: EditorSettingsDict = OrderedDict() 322 323 if data is not None and pw_ide_settings is not None: 324 self._data = data(pw_ide_settings) 325 326 def __repr__(self) -> str: 327 return f'<{self.__class__.__name__}: (in memory)>' 328 329 def __str__(self) -> str: 330 return json.dumps(self.get(), indent=2) 331 332 def get(self) -> EditorSettingsDict: 333 """Return the settings as an ordered dict.""" 334 return self._data 335 336 def hash(self) -> str: 337 return sha1(json.dumps(self.get()).encode('utf-8')).hexdigest() 338 339 @contextmanager 340 def build(self) -> Generator[OrderedDict[str, Any], None, None]: 341 """Expose a settings file builder. 342 343 You get an empty dict when entering the content, then you can build 344 up settings by using ``sync_to`` to merge other settings dicts into this 345 one, as long as everything is JSON-serializable. Example: 346 347 .. code-block:: python 348 349 with settings_definition.modify() as settings: 350 some_other_settings.sync_to(settings) 351 352 This data is not persisted to disk. 353 """ 354 new_data: OrderedDict[str, Any] = OrderedDict() 355 yield new_data 356 self._data = new_data 357 358 def sync_to(self, settings: EditorSettingsDict) -> None: 359 """Merge this set of settings on top of the provided settings.""" 360 self_settings = self.get() 361 settings = dict_deep_merge(self_settings, settings) 362 363 def is_present(self) -> bool: # pylint: disable=no-self-use 364 return True 365 366 def delete(self) -> None: 367 pass 368 369 def delete_backups(self) -> None: 370 pass 371 372 373class EditorSettingsFile(EditorSettingsDefinition): 374 """Provides access to an editor settings defintion stored in a file. 375 376 It's assumed that the editor's settings are stored in a file format that 377 can be deserialized to Python dicts. The settings are represented by 378 an ordered dict to make the diff that results from modifying the settings 379 as easy to read as possible (assuming it has a plain text representation). 380 381 This represents the concept of a file; the file may not actually be 382 present on disk yet. 383 """ 384 385 def __init__( 386 self, settings_dir: Path, name: str, file_format: _StructuredFileFormat 387 ) -> None: 388 self._name = name 389 self._format = file_format 390 self._path = settings_dir / f'{name}.{self._format.ext}' 391 super().__init__() 392 393 def __repr__(self) -> str: 394 return f'<{self.__class__.__name__}: {str(self._path)}>' 395 396 def get(self) -> EditorSettingsDict: 397 """Read a settings file into an ordered dict. 398 399 This does not keep the file context open, so while the dict is 400 mutable, any changes will not be written to disk. 401 """ 402 try: 403 with self._path.open() as file: 404 settings: OrderedDict = self._format.load(file) 405 except ValueError as e: 406 raise ValueError( 407 f"Settings file {self} could not be parsed. " 408 "Check the file for syntax errors." 409 ) from e 410 except FileNotFoundError: 411 settings = OrderedDict() 412 413 return settings 414 415 @contextmanager 416 def build(self) -> Generator[OrderedDict[str, Any], None, None]: 417 """Expose a settings file builder. 418 419 You get an empty dict when entering the content, then you can build 420 up settings by using ``sync_to`` to merge other settings dicts into this 421 one, as long as everything is JSON-serializable. Example: 422 423 .. code-block:: python 424 425 with settings_file.modify() as settings: 426 some_other_settings.sync_to(settings) 427 428 After modifying the settings and leaving this context, the file will 429 be written. If a failure occurs while writing the new file, it will be 430 deleted. 431 """ 432 new_data: OrderedDict[str, Any] = OrderedDict() 433 yield new_data 434 file = self._path.open('w') 435 436 try: 437 self._format.dump(new_data, file) 438 except self._format.unserializable_error: 439 # We'll get this error if we try to sneak something in that's 440 # not serializable. Unless we handle this, we may end up 441 # with a partially-written file that can't be parsed. So we 442 # delete that and restore the backup. 443 file.close() 444 self._path.unlink() 445 446 raise 447 finally: 448 if not file.closed: 449 file.close() 450 451 def is_present(self) -> bool: 452 return self._path.exists() 453 454 def delete(self) -> None: 455 try: 456 self._path.unlink() 457 except FileNotFoundError: 458 pass 459 460 461_SettingsLevelName = Literal['default', 'active', 'project', 'user'] 462 463 464@dataclass(frozen=True) 465class SettingsLevelData: 466 name: _SettingsLevelName 467 is_user_configurable: bool 468 is_file: bool 469 470 471class SettingsLevel(enum.Enum): 472 """Cascading set of settings. 473 474 This provides a unified mechanism for having active settings (those 475 actually used by an editor) be built from default settings in Pigweed, 476 project settings checked into the project's repository, and user settings 477 particular to one checkout of the project, each of which can override 478 settings higher up in the chain. 479 """ 480 481 DEFAULT = SettingsLevelData( 482 'default', is_user_configurable=False, is_file=False 483 ) 484 PROJECT = SettingsLevelData( 485 'project', is_user_configurable=True, is_file=True 486 ) 487 USER = SettingsLevelData('user', is_user_configurable=True, is_file=True) 488 ACTIVE = SettingsLevelData( 489 'active', is_user_configurable=False, is_file=True 490 ) 491 492 @property 493 def is_user_configurable(self) -> bool: 494 return self.value.is_user_configurable 495 496 @property 497 def is_file(self) -> bool: 498 return self.value.is_file 499 500 @classmethod 501 def all_levels(cls) -> Generator['SettingsLevel', None, None]: 502 return (level for level in cls) 503 504 @classmethod 505 def all_not_default(cls) -> Generator['SettingsLevel', None, None]: 506 return (level for level in cls if level is not cls.DEFAULT) 507 508 @classmethod 509 def all_user_configurable(cls) -> Generator['SettingsLevel', None, None]: 510 return (level for level in cls if level.is_user_configurable) 511 512 @classmethod 513 def all_files(cls) -> Generator['SettingsLevel', None, None]: 514 return (level for level in cls if level.is_file) 515 516 517# A map of configurable settings levels and the string that will be prepended 518# to their files to indicate their settings level. 519SettingsFilePrefixes = dict[SettingsLevel, str] 520 521# Each editor will have one or more settings types that typically reflect each 522# of the files used to define their settings. So each editor should have an 523# enum type that defines each of those settings types, and this type var 524# represents that generically. The value of each enum case should be the file 525# name of that settings file, without the extension. 526# TODO(chadnorvell): Would be great to constrain this to enums, but bound= 527# doesn't do what we want with Enum or EnumMeta. 528_SettingsTypeT = TypeVar('_SettingsTypeT') 529 530# Maps each settings type with the callback that generates the default settings 531# for that settings type. 532EditorSettingsTypesWithDefaults = dict[_SettingsTypeT, DefaultSettingsCallback] 533 534 535def undefined_default_settings_dir( 536 _pw_ide_settings: PigweedIdeSettings, 537) -> Path: 538 """Raise an error if a subclass doesn't define default_settings_dir.""" 539 540 raise NotImplementedError() 541 542 543class EditorSettingsManager(Generic[_SettingsTypeT]): 544 """Manages all settings for a particular editor. 545 546 This is where you interact with an editor's settings (actually in a 547 subclass of this class, not here). Initializing this class sets up access 548 to one or more settings files for an editor (determined by 549 ``_SettingsTypeT``, fulfilled by an enum that defines each of an editor's 550 settings files), along with the cascading settings levels. 551 """ 552 553 # Prefixes should only be defined for settings that will be stored on disk 554 # and are not the active settings file, which will use the name without a 555 # prefix. This may be overridden in child classes, but typically should 556 # not be. 557 prefixes: SettingsFilePrefixes = { 558 SettingsLevel.PROJECT: 'pw_project_', 559 SettingsLevel.USER: 'pw_user_', 560 } 561 562 # These must be overridden in child classes. 563 file_format: _StructuredFileFormat = _StructuredFileFormat() 564 types_with_defaults: EditorSettingsTypesWithDefaults[_SettingsTypeT] = {} 565 566 # The settings directory can be defined as a static path, or as a lambda 567 # that takes an instance of `PigweedIdeSettings` as an argument. 568 default_settings_dir: Path | Callable[ 569 [PigweedIdeSettings], Path 570 ] = undefined_default_settings_dir 571 572 def __init__( 573 self, 574 pw_ide_settings: PigweedIdeSettings, 575 settings_dir: Path | None = None, 576 file_format: _StructuredFileFormat | None = None, 577 types_with_defaults: ( 578 EditorSettingsTypesWithDefaults[_SettingsTypeT] | None 579 ) = None, 580 ): 581 if SettingsLevel.ACTIVE in self.__class__.prefixes: 582 raise ValueError( 583 'You cannot assign a file name prefix to ' 584 'an active settings file.' 585 ) 586 587 # This lets us use ``self._prefixes`` transparently for any file, 588 # including active settings files, since it will provide an empty 589 # string prefix for those files. In other words, while the class 590 # attribute `prefixes` can only be defined for configurable settings, 591 # `self._prefixes` extends it to work for any settings file. 592 self._prefixes = defaultdict(str, self.__class__.prefixes) 593 594 # The default settings directory is defined by the subclass attribute 595 # `default_settings_dir`, and that value is used the vast majority of 596 # the time. But you can inject an alternative directory in the 597 # constructor if needed (e.g. for tests). 598 if settings_dir is not None: 599 self._settings_dir = settings_dir 600 else: 601 if isinstance(self.__class__.default_settings_dir, Path): 602 self._settings_dir = self.__class__.default_settings_dir 603 else: 604 self._settings_dir = self.__class__.default_settings_dir( 605 pw_ide_settings 606 ) 607 608 if not self._settings_dir.exists(): 609 self._settings_dir.mkdir() 610 611 # The backing file format should normally be defined by the class 612 # attribute ``file_format``, but can be overridden in the constructor. 613 self._file_format: _StructuredFileFormat = ( 614 file_format 615 if file_format is not None 616 else self.__class__.file_format 617 ) 618 619 # The settings types with their defaults should normally be defined by 620 # the class attribute ``types_with_defaults``, but can be overridden 621 # in the constructor. 622 self._types_with_defaults = ( 623 types_with_defaults 624 if types_with_defaults is not None 625 else self.__class__.types_with_defaults 626 ) 627 628 # For each of the settings levels, there is a settings definition for 629 # each settings type. Those settings definitions may be stored in files 630 # or not. 631 self._settings_definitions: dict[ 632 SettingsLevel, dict[_SettingsTypeT, EditorSettingsDefinition] 633 ] = {} 634 635 self._settings_types = tuple(self._types_with_defaults.keys()) 636 637 # Initialize the default settings level for each settings type, which 638 # defined in code, not files. 639 self._settings_definitions[SettingsLevel.DEFAULT] = {} 640 641 for ( 642 settings_type 643 ) in ( 644 self._types_with_defaults 645 ): # pylint: disable=consider-using-dict-items 646 self._settings_definitions[SettingsLevel.DEFAULT][ 647 settings_type 648 ] = EditorSettingsDefinition( 649 pw_ide_settings, self._types_with_defaults[settings_type] 650 ) 651 652 # Initialize the settings definitions for each settings type for each 653 # settings level that's stored on disk. 654 for level in SettingsLevel.all_files(): 655 self._settings_definitions[level] = {} 656 657 for settings_type in self._types_with_defaults: 658 name = f'{self._prefixes[level]}{settings_type.value}' 659 self._settings_definitions[level][ 660 settings_type 661 ] = EditorSettingsFile( 662 self._settings_dir, name, self._file_format 663 ) 664 665 def default(self, settings_type: _SettingsTypeT): 666 """Default settings for the provided settings type.""" 667 return self._settings_definitions[SettingsLevel.DEFAULT][settings_type] 668 669 def project(self, settings_type: _SettingsTypeT): 670 """Project settings for the provided settings type.""" 671 return self._settings_definitions[SettingsLevel.PROJECT][settings_type] 672 673 def user(self, settings_type: _SettingsTypeT): 674 """User settings for the provided settings type.""" 675 return self._settings_definitions[SettingsLevel.USER][settings_type] 676 677 def active(self, settings_type: _SettingsTypeT): 678 """Active settings for the provided settings type.""" 679 return self._settings_definitions[SettingsLevel.ACTIVE][settings_type] 680 681 def delete_all_active_settings(self) -> None: 682 """Delete all active settings files.""" 683 for settings_type in self._settings_types: 684 self.active(settings_type).delete() 685 686 def delete_all_backups(self) -> None: 687 """Delete all backup files.""" 688 for settings_type in self._settings_types: 689 self.project(settings_type).delete_backups() 690 self.user(settings_type).delete_backups() 691 self.active(settings_type).delete_backups() 692