xref: /petsc/lib/petsc/bin/maint/petsclinter/petsclinter/classes/_diag.py (revision 9c5460f9064ca60dd71a234a1f6faf93e7a6b0c9)
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