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