1#!/usr/bin/env python3 2""" 3# Created: Mon Jun 20 16:50:07 2022 (-0400) 4# @author: Jacob Faibussowitsch 5""" 6from __future__ import annotations 7 8import copy 9import enum 10import inspect 11import contextlib 12 13from .._typing import * 14 15_T = TypeVar('_T') 16 17from ..util._color import Color 18 19from ._src_pos import SourceLocation, SourceRange 20 21class DiagnosticMapProxy: 22 __slots__ = '_diag_map', '_mro' 23 24 def __init__(self, diag_map: DiagnosticMap, mro: tuple[type, ...]) -> None: 25 self._diag_map = diag_map 26 self._mro = mro 27 return 28 29 def _fuzzy_get_attribute(self, in_diags: dict[str, str], in_attr: str) -> tuple[bool, str]: 30 try: 31 return True, in_diags[in_attr] 32 except KeyError: 33 pass 34 attr_items = [v for k, v in in_diags.items() if k.endswith(in_attr)] 35 if len(attr_items) == 1: 36 return True, attr_items[0] 37 return False, '' 38 39 def __getattr__(self, attr: str) -> str: 40 diag_map = self._diag_map 41 try: 42 return TYPE_CAST(str, getattr(diag_map, attr)) 43 except AttributeError: 44 pass 45 diag_map_diags = diag_map._diags 46 for cls in self._mro: 47 try: 48 sub_diag_map = diag_map_diags[cls.__qualname__] 49 except KeyError: 50 continue 51 success, ret = self._fuzzy_get_attribute(sub_diag_map, attr) 52 if success: 53 return ret 54 raise AttributeError(attr) 55 56class DiagnosticMap: 57 r""" 58 A dict-like object that allows 'DiagnosticMap.my_diagnostic_name' to return 'my-diagnostic-name' 59 """ 60 __slots__ = ('_diags',) 61 62 _diags: dict[str, dict[str, str]] 63 64 @staticmethod 65 def _sanitize_input(input_it: Iterable[str]) -> dict[str, str]: 66 return {attr.replace('-', '_') : attr for attr in input_it} 67 68 def __init__(self) -> None: 69 self._diags = {'__general' : {}} 70 return 71 72 def __getattr__(self, attr: str) -> str: 73 diags = self._diags['__general'] 74 try: 75 return diags[attr] 76 except KeyError: 77 attr_items = [v for k, v in diags.items() if k.endswith(attr)] 78 if len(attr_items) == 1: 79 return attr_items[0] 80 raise AttributeError(attr) 81 82 def __get__(self, obj: Any, objtype: Optional[type] = None) -> DiagnosticMapProxy: 83 r"""We need to do MRO-aware fuzzy lookup. In order to do that we need know about the calling class's 84 type, which is not passed to the regular __getattr__(). But type information *is* passed to 85 __get__() (which is called on attribute access), so the workaround is to create a proxy object 86 that ends up calling our own __getattr__(). 87 88 The motivating example is as follows. Suppose you have: 89 90 @DiagnosticsManager.register('some-diag', ...) 91 class Foo: 92 def baz(): 93 diag = self.diags.some_diag 94 95 @DiagnosticsManager.register('some-diag', ...) 96 class Bar: 97 def baz(): 98 diag = self.diags.some_diag 99 100 In doing this we effectively want to create bar-some-diag and foo-some-diag, which works as 101 intended. However in Foo.baz() when it searches for the some_diag (transformed to 'some-diag') 102 attribute, it will fuzzy match to 'bar-some-diag'. 103 104 So we need to first search our own classes namespace, and then search each of our base classes 105 namespaces before finally considering children. 106 """ 107 assert objtype is not None 108 return DiagnosticMapProxy(self, inspect.getmro(objtype)) 109 110 def update(self, obj: Any, other: Iterable[str], **kwargs) -> None: 111 if not isinstance(other, (list, tuple)) or inspect.isgenerator(other): 112 raise ValueError(type(other)) 113 114 dmap = self._sanitize_input(other) 115 self._diags['__general'].update(dmap, **kwargs) 116 qual_name = obj.__qualname__ 117 if qual_name not in self._diags: 118 self._diags[qual_name] = {} 119 self._diags[qual_name].update(dmap, **kwargs) 120 return 121 122class DiagnosticsManagerCls: 123 __slots__ = 'disabled', 'flagprefix' 124 _registered: dict[str, str] = {} 125 126 disabled: set[str] 127 flagprefix: str 128 129 @classmethod 130 def registered(cls) -> dict[str, str]: 131 r"""Return the registered diagnostics 132 133 Returns 134 ------- 135 registered : 136 the set of registered diagnostics 137 """ 138 return cls._registered 139 140 @staticmethod 141 def _expand_flag(flag: Union[Iterable[str], str]) -> str: 142 r"""Expand a flag 143 144 Transforms `['foo', 'bar', 'baz']` into `'foo-bar-baz'` 145 146 Parameters 147 ---------- 148 flag : 149 the flag parts to expand 150 151 Returns 152 ------- 153 flag : 154 the expanded flag 155 156 Raises 157 ------ 158 ValueError 159 if flag is an iterable, but cannot be joined 160 """ 161 if not isinstance(flag, str): 162 try: 163 flag = '-'.join(flag) 164 except Exception as ex: 165 raise ValueError(type(flag)) from ex 166 return flag 167 168 @classmethod 169 def flag_prefix(cls, obj: object) -> Callable[[str], str]: 170 r"""Return the flag prefix 171 172 Parameters 173 ---------- 174 obj : 175 a class instance which may or may implement `__diagnostic_prefix__(flag: str) -> str` 176 177 Returns 178 ------- 179 prefix : 180 the prefix 181 182 Notes 183 ----- 184 Implementing `__diagnostic_prefix__()` is optional, in which case this routine returns an identity 185 lambda 186 """ 187 return getattr(obj, '__diagnostic_prefix__', lambda f: f) 188 189 @classmethod 190 def check_flag(cls, flag: str) -> str: 191 r"""Check a flag for validity and expand it 192 193 Parameters 194 ---------- 195 flag : 196 the flag to expand 197 198 Returns 199 ------- 200 flag : 201 the expanded flag 202 203 Raises 204 ------ 205 ValueError 206 if the flag is not registered with the `DiagnosticManager` 207 """ 208 flag = cls._expand_flag(flag) 209 if flag not in cls._registered: 210 raise ValueError(f'Flag \'{flag}\' is not registered with {cls}') 211 return flag 212 213 @classmethod 214 def _inject_diag_map(cls, symbol: _T, diag_pairs: Iterable[tuple[str, str]]) -> _T: 215 r"""Does the registering and injecting of the `DiagnosticMap` into some symbol 216 217 Parameters 218 ---------- 219 symbol : 220 the symbol to inject the `DiagnosticMap` into 221 diag_pairs : 222 an iterable of pairs of flag - description which should be injected 223 224 Returns 225 ------- 226 symbol : 227 the symbol with the injected map 228 229 Notes 230 ----- 231 This registeres the flags in `diag_pairs` will be registered with the `DiagnosticsManager`. After 232 this returns `symbol` will have a member `diags` through which the diagnostics can be accessed. So 233 if do 234 ``` 235 DiagnosticManager.register('foo-bar-baz', 'check a foo, a bar, and a baz') 236 def MyClass: 237 ... 238 def some_func(self, ...): 239 ... 240 diag = self.diags.foo_bar_baz # can access by replacing '-' with '_' in flag 241 ``` 242 243 This function appears to return a `_T` unchanged. But what we really want to do is 244 ``` 245 _T = TypeVar('_T') 246 247 class HasDiagMap(Protocol): 248 diags: DiagnosticMap 249 250 def _inject_diag_map(symbol: _T, ...) -> Intersection[HasDiagMap, [_T]]: 251 ... 252 ``` 253 I.e. the returned type is *both* whatever it was before, but it now also obeys the diag-map 254 protocol, i.e. it has a member `diags` which is a `DiagnosticMap`. But unfortunately Python has 255 no such 'Intersection' type yet so we need to annotate all the types by hand... 256 """ 257 diag_attr = 'diags' 258 symbol_flag_prefix = cls.flag_prefix(symbol) 259 expanded_diags = [ 260 (cls._expand_flag(symbol_flag_prefix(d)), h.casefold()) for d, h in diag_pairs 261 ] 262 if not hasattr(symbol, diag_attr): 263 setattr(symbol, diag_attr, DiagnosticMap()) 264 getattr(symbol, diag_attr).update(symbol, [d for d, _ in expanded_diags]) 265 cls._registered.update(expanded_diags) 266 return symbol 267 268 @classmethod 269 def register(cls, *args: tuple[str, str]) -> Callable[[_T], _T]: 270 def decorator(symbol: _T) -> _T: 271 return cls._inject_diag_map(symbol, args) 272 return decorator 273 274 def __init__(self, flagprefix: str = '-f') -> None: 275 r"""Construct the `DiagnosticManager` 276 277 Parameters 278 ---------- 279 flagprefix : '-f', optional 280 the base flag prefix to prepend to all flags 281 """ 282 self.disabled = set() 283 self.flagprefix = flagprefix if flagprefix.startswith('-') else '-' + flagprefix 284 return 285 286 def disable(self, flag: str) -> None: 287 r"""Disable a flag 288 289 Parameters 290 ---------- 291 flag : 292 the flag to disable 293 """ 294 self.disabled.add(self.check_flag(flag)) 295 return 296 297 def enable(self, flag: str) -> None: 298 r"""Enable a flag 299 300 Parameters 301 ---------- 302 flag : 303 the flag to enable 304 """ 305 self.disabled.discard(self.check_flag(flag)) 306 return 307 308 def set(self, flag: str, value: bool) -> None: 309 r"""Set enablement of a flag 310 311 Parameters 312 ---------- 313 flag : 314 the flag to set 315 value : 316 True to enable, False to disable 317 """ 318 if value: 319 self.enable(flag) 320 else: 321 self.disable(flag) 322 return 323 324 def disabled_for(self, flag: str) -> bool: 325 r"""Is `flag` disabled? 326 327 Parameters 328 ---------- 329 flag : 330 the flag to check 331 332 Returns 333 ------- 334 disabled : 335 True if `flag` is disabled, False otherwise 336 """ 337 return self.check_flag(flag) in self.disabled 338 339 def enabled_for(self, flag: str) -> bool: 340 r"""Is `flag` enabled? 341 342 Parameters 343 ---------- 344 flag : 345 the flag to check 346 347 Returns 348 ------- 349 enabled : 350 True if `flag` is enabled, False otherwise 351 """ 352 return not self.disabled_for(flag) 353 354 def make_command_line_flag(self, flag: str) -> str: 355 r"""Build a command line flag 356 357 Parameters 358 ---------- 359 flag : 360 the flag to build for 361 362 Returns 363 ------- 364 ret : 365 the full command line flag 366 """ 367 return f'{self.flagprefix}{self.check_flag(flag)}' 368 369 @contextlib.contextmanager 370 def push_from(self, dict_like: Mapping[str, Collection[re.Pattern[str]]]): 371 r"""Temporarily enable or disable flags based on `dict_like` 372 373 Parameters 374 ---------- 375 dict_like : 376 a dictionary of actions to take 377 378 Yields 379 ------ 380 self : 381 the object 382 383 Raises 384 ------ 385 ValueError 386 if an unknown key is encountered 387 """ 388 if dict_like: 389 dispatcher = { 390 'disable' : self.disabled.update, 391 'ignore' : self.disabled.update 392 } 393 reg = self.registered().keys() 394 old_disabled = copy.deepcopy(self.disabled) 395 for key, values in dict_like.items(): 396 mod_flags = [f for f in reg for matcher in values if matcher.match(f)] 397 try: 398 dispatcher[key](mod_flags) 399 except KeyError as ke: 400 raise ValueError( 401 f'Unknown pragma key \'{key}\', expected one of: {list(dispatcher.keys())}' 402 ) from ke 403 try: 404 yield self 405 finally: 406 if dict_like: 407 self.disabled = old_disabled 408 409DiagnosticManager = DiagnosticsManagerCls() 410 411@enum.unique 412class DiagnosticKind(enum.Enum): 413 ERROR = enum.auto() 414 WARNING = enum.auto() 415 416 def color(self) -> str: 417 if self == DiagnosticKind.ERROR: 418 return Color.bright_red() 419 elif self == DiagnosticKind.WARNING: 420 return Color.bright_yellow() 421 else: 422 raise ValueError(str(self)) 423 424class Diagnostic: 425 FLAG_SUBST = r'%DIAG_FLAG%' 426 Kind = DiagnosticKind 427 __slots__ = 'flag', 'message', 'location', 'patch', 'clflag', 'notes', 'kind' 428 429 flag: str 430 message: str 431 location: SourceLocation 432 patch: Optional[Patch] 433 clflag: str 434 notes: list[tuple[SourceLocationLike, str]] 435 kind: DiagnosticKind 436 437 def __init__(self, kind: DiagnosticKind, flag: str, message: str, location: SourceLocationLike, patch: Optional[Patch] = None, notes: Optional[list[tuple[SourceLocationLike, str]]] = None) -> None: 438 r"""Construct a `Diagnostic` 439 440 Parameters 441 ---------- 442 kind : 443 the kind of `Diagnostic` to create 444 flag : 445 the flag to attribute the diagnostic to 446 message : 447 the informative message 448 location : 449 the location to attribute the diagnostic to 450 patch : 451 a patch to automatically fix the diagnostic 452 notes : 453 a list of notes to initialize the diagnostic with 454 """ 455 if notes is None: 456 notes = [] 457 458 self.flag = DiagnosticManager.check_flag(flag) 459 self.message = str(message) 460 self.location = SourceLocation.cast(location) 461 self.patch = patch 462 self.clflag = f' [{DiagnosticManager.make_command_line_flag(self.flag)}]' 463 self.notes = notes 464 self.kind = kind 465 return 466 467 @staticmethod 468 def make_message_from_formattable(message: str, crange: Optional[Formattable] = None, num_context: int = 2, **kwargs) -> str: 469 r"""Make a formatted error message from a formattable object 470 471 Parameters 472 ---------- 473 message : 474 the base message 475 crange : optional 476 the formattable object, which must have a method `formatted(num_context: int, **kwargs) -> str` 477 whose formatted text is optionally appended to the message 478 num_context : optional 479 if crange is given, the number of context lines to append 480 **kwargs : optional 481 if crange is given, additional keyword arguments to pass to `SourceRange.formatted()` 482 483 Returns 484 ------- 485 mess : 486 the error message 487 """ 488 if crange is None: 489 return message 490 return f'{message}:\n{crange.formatted(num_context=num_context, **kwargs)}' 491 492 @classmethod 493 def from_source_range(cls, kind: DiagnosticKind, diag_flag: str, msg: str, src_range: SourceRangeLike, patch: Optional[Patch] = None, **kwargs) -> Diagnostic: 494 r"""Construct a `Diagnostic` from a source_range 495 496 Parameters 497 ---------- 498 kind : 499 the `DiagnostiKind` 500 diag_flag : 501 the diagnostic flag to display 502 msg : 503 the base message text 504 src_range : 505 the source range to generate the message from 506 patch : optional 507 the patch to create a fixit form 508 **kwargs : 509 additional keyword arguments to pass to `src_range.formatted()` 510 511 Returns 512 ------- 513 diag : 514 the constructed `Diagnostic` 515 516 Notes 517 ----- 518 This is the de-facto standard factory for creating `Diagnostic`s as it ensures that the messages 519 are all similarly formatted and displayed. The vast majority of `Diagnostic`s are created via this 520 function 521 """ 522 src_range = SourceRange.cast(src_range) 523 return cls( 524 kind, diag_flag, 525 cls.make_message_from_formattable(msg, crange=src_range, **kwargs), 526 src_range.start, 527 patch=patch 528 ) 529 530 def __repr__(self) -> str: 531 return f'<flag: {self.clflag}, patch: {self.patch}, message: {self.message}, notes: {self.notes}>' 532 533 def formatted_header(self) -> str: 534 r"""Return the formatted header for this diagnostic, suitable for output 535 536 Returns 537 ------- 538 hdr : 539 the formatted header 540 """ 541 return f'{self.kind.color()}{self.location}: {self.kind.name.casefold()}:{Color.reset()} {self.format_message()}' 542 543 def add_note(self, note: str, location: Optional[SourceLocationLike] = None) -> Diagnostic: 544 r"""Add a note to a diagnostic 545 546 Parameters 547 ---------- 548 note : 549 a useful additional message 550 location : optional 551 a location to attribute the note to, if not given, the location of the diagnostic is used 552 553 Returns 554 ------- 555 self : 556 the diagnostic object 557 """ 558 if location is None: 559 location = self.location 560 else: 561 location = SourceLocation.cast(location) 562 563 self.notes.append((location, note)) 564 return self 565 566 def format_message(self) -> str: 567 r"""Format the diagnostic 568 569 Returns 570 ------- 571 ret : 572 the formatted diagnostic message, suitable for display to the user 573 """ 574 message = self.message 575 clflag = self.clflag 576 if self.FLAG_SUBST in message: 577 message = message.replace(self.FLAG_SUBST, clflag.lstrip()) 578 else: 579 sub = ':\n' 580 pos = message.find(sub) 581 if pos == -1: 582 message += clflag 583 else: 584 assert not message[pos - 1].isdigit(), f'message[pos - 1] (pos = {pos}) -> {message[pos - 1]} is a digit when it should not be' 585 message = message.replace(sub, clflag + sub, 1) 586 587 if self.notes: 588 notes_tmp = '\n\n'.join(f'{loc} Note: {note}' for loc, note in self.notes) 589 message = f'{message}\n\n{notes_tmp}' 590 assert not message.endswith('\n') 591 return message 592 593 def disabled(self) -> bool: 594 r"""Is the flag for this diagnostic disabled? 595 596 Returns 597 ------- 598 disabled : 599 True if this diagnostic is disabled, False otherwise 600 """ 601 return DiagnosticManager.disabled_for(self.flag.replace('_', '-')) 602