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