from collections import namedtuple import logging import os import os.path import re import textwrap from c_common.tables import build_table, resolve_columns from c_parser.parser._regexes import _ind from ._files import iter_header_files, resolve_filename from . import REPO_ROOT logger = logging.getLogger(__name__) INCLUDE_ROOT = os.path.join(REPO_ROOT, 'Include') INCLUDE_CPYTHON = os.path.join(INCLUDE_ROOT, 'cpython') INCLUDE_INTERNAL = os.path.join(INCLUDE_ROOT, 'internal') _MAYBE_NESTED_PARENS = textwrap.dedent(r''' (?: (?: [^(]* [(] [^()]* [)] )* [^(]* ) ''') CAPI_FUNC = textwrap.dedent(rf''' (?: ^ \s* PyAPI_FUNC \s* [(] {_ind(_MAYBE_NESTED_PARENS, 2)} [)] \s* (\w+) # \s* [(] ) ''') CAPI_DATA = textwrap.dedent(rf''' (?: ^ \s* PyAPI_DATA \s* [(] {_ind(_MAYBE_NESTED_PARENS, 2)} [)] \s* (\w+) # \b [^(] ) ''') CAPI_INLINE = textwrap.dedent(r''' (?: ^ \s* static \s+ inline \s+ .*? \s+ ( \w+ ) # \s* [(] ) ''') CAPI_MACRO = textwrap.dedent(r''' (?: (\w+) # [(] ) ''') CAPI_CONSTANT = textwrap.dedent(r''' (?: (\w+) # \s+ [^(] ) ''') CAPI_DEFINE = textwrap.dedent(rf''' (?: ^ \s* [#] \s* define \s+ (?: {_ind(CAPI_MACRO, 3)} | {_ind(CAPI_CONSTANT, 3)} | (?: # ignored \w+ # \s* $ ) ) ) ''') CAPI_RE = re.compile(textwrap.dedent(rf''' (?: {_ind(CAPI_FUNC, 2)} | {_ind(CAPI_DATA, 2)} | {_ind(CAPI_INLINE, 2)} | {_ind(CAPI_DEFINE, 2)} ) '''), re.VERBOSE) KINDS = [ 'func', 'data', 'inline', 'macro', 'constant', ] def _parse_line(line, prev=None): last = line if prev: if not prev.endswith(os.linesep): prev += os.linesep line = prev + line m = CAPI_RE.match(line) if not m: if not prev and line.startswith('static inline '): return line # the new "prev" #if 'PyAPI_' in line or '#define ' in line or ' define ' in line: # print(line) return None results = zip(KINDS, m.groups()) for kind, name in results: if name: clean = last.split('//')[0].rstrip() if clean.endswith('*/'): clean = clean.split('/*')[0].rstrip() if kind == 'macro' or kind == 'constant': if not clean.endswith('\\'): return name, kind elif kind == 'inline': if clean.endswith('}'): if not prev or clean == '}': return name, kind elif kind == 'func' or kind == 'data': if clean.endswith(';'): return name, kind else: # This should not be reached. raise NotImplementedError return line # the new "prev" # It was a plain #define. return None LEVELS = [ 'stable', 'cpython', 'private', 'internal', ] def _get_level(filename, name, *, _cpython=INCLUDE_CPYTHON + os.path.sep, _internal=INCLUDE_INTERNAL + os.path.sep, ): if filename.startswith(_internal): return 'internal' elif name.startswith('_'): return 'private' elif os.path.dirname(filename) == INCLUDE_ROOT: return 'stable' elif filename.startswith(_cpython): return 'cpython' else: raise NotImplementedError #return '???' GROUPINGS = { 'kind': KINDS, 'level': LEVELS, } class CAPIItem(namedtuple('CAPIItem', 'file lno name kind level')): @classmethod def from_line(cls, line, filename, lno, prev=None): parsed = _parse_line(line, prev) if not parsed: return None, None if isinstance(parsed, str): # incomplete return None, parsed name, kind = parsed level = _get_level(filename, name) self = cls(filename, lno, name, kind, level) if prev: self._text = (prev + line).rstrip().splitlines() else: self._text = [line.rstrip()] return self, None @property def relfile(self): return self.file[len(REPO_ROOT) + 1:] @property def text(self): try: return self._text except AttributeError: # XXX Actually ready the text from disk?. self._text = [] if self.kind == 'data': self._text = [ f'PyAPI_DATA(...) {self.name}', ] elif self.kind == 'func': self._text = [ f'PyAPI_FUNC(...) {self.name}(...);', ] elif self.kind == 'inline': self._text = [ f'static inline {self.name}(...);', ] elif self.kind == 'macro': self._text = [ f'#define {self.name}(...) \\', f' ...', ] elif self.kind == 'constant': self._text = [ f'#define {self.name} ...', ] else: raise NotImplementedError return self._text def _parse_groupby(raw): if not raw: raw = 'kind' if isinstance(raw, str): groupby = raw.replace(',', ' ').strip().split() else: raise NotImplementedError if not all(v in GROUPINGS for v in groupby): raise ValueError(f'invalid groupby value {raw!r}') return groupby def _resolve_full_groupby(groupby): if isinstance(groupby, str): groupby = [groupby] groupings = [] for grouping in groupby + list(GROUPINGS): if grouping not in groupings: groupings.append(grouping) return groupings def summarize(items, *, groupby='kind', includeempty=True, minimize=None): if minimize is None: if includeempty is None: minimize = True includeempty = False else: minimize = includeempty elif includeempty is None: includeempty = minimize elif minimize and includeempty: raise ValueError(f'cannot minimize and includeempty at the same time') groupby = _parse_groupby(groupby)[0] _outer, _inner = _resolve_full_groupby(groupby) outers = GROUPINGS[_outer] inners = GROUPINGS[_inner] summary = { 'totals': { 'all': 0, 'subs': {o: 0 for o in outers}, 'bygroup': {o: {i: 0 for i in inners} for o in outers}, }, } for item in items: outer = getattr(item, _outer) inner = getattr(item, _inner) # Update totals. summary['totals']['all'] += 1 summary['totals']['subs'][outer] += 1 summary['totals']['bygroup'][outer][inner] += 1 if not includeempty: subtotals = summary['totals']['subs'] bygroup = summary['totals']['bygroup'] for outer in outers: if subtotals[outer] == 0: del subtotals[outer] del bygroup[outer] continue for inner in inners: if bygroup[outer][inner] == 0: del bygroup[outer][inner] if minimize: if len(bygroup[outer]) == 1: del bygroup[outer] return summary def _parse_capi(lines, filename): if isinstance(lines, str): lines = lines.splitlines() prev = None for lno, line in enumerate(lines, 1): parsed, prev = CAPIItem.from_line(line, filename, lno, prev) if parsed: yield parsed if prev: parsed, prev = CAPIItem.from_line('', filename, lno, prev) if parsed: yield parsed if prev: print('incomplete match:') print(filename) print(prev) raise Exception def iter_capi(filenames=None): for filename in iter_header_files(filenames): with open(filename) as infile: for item in _parse_capi(infile, filename): yield item def resolve_filter(ignored): if not ignored: return None ignored = set(_resolve_ignored(ignored)) def filter(item, *, log=None): if item.name not in ignored: return True if log is not None: log(f'ignored {item.name!r}') return False return filter def _resolve_ignored(ignored): if isinstance(ignored, str): ignored = [ignored] for raw in ignored: if isinstance(raw, str): if raw.startswith('|'): yield raw[1:] elif raw.startswith('<') and raw.endswith('>'): filename = raw[1:-1] try: infile = open(filename) except Exception as exc: logger.error(f'ignore file failed: {exc}') continue logger.log(1, f'reading ignored names from {filename!r}') with infile: for line in infile: if not line: continue if line[0].isspace(): continue line = line.partition('#')[0].rstrip() if line: # XXX Recurse? yield line else: raw = raw.strip() if raw: yield raw else: raise NotImplementedError def _collate(items, groupby, includeempty): groupby = _parse_groupby(groupby)[0] maxfilename = maxname = maxkind = maxlevel = 0 collated = {} groups = GROUPINGS[groupby] for group in groups: collated[group] = [] for item in items: key = getattr(item, groupby) collated[key].append(item) maxfilename = max(len(item.relfile), maxfilename) maxname = max(len(item.name), maxname) maxkind = max(len(item.kind), maxkind) maxlevel = max(len(item.level), maxlevel) if not includeempty: for group in groups: if not collated[group]: del collated[group] maxextra = { 'kind': maxkind, 'level': maxlevel, } return collated, groupby, maxfilename, maxname, maxextra def _get_sortkey(sort, _groupby, _columns): if sort is True or sort is None: # For now: def sortkey(item): return ( item.level == 'private', LEVELS.index(item.level), KINDS.index(item.kind), os.path.dirname(item.file), os.path.basename(item.file), item.name, ) return sortkey sortfields = 'not-private level kind dirname basename name'.split() elif isinstance(sort, str): sortfields = sort.replace(',', ' ').strip().split() elif callable(sort): return sort else: raise NotImplementedError # XXX Build a sortkey func from sortfields. raise NotImplementedError ################################## # CLI rendering _MARKERS = { 'level': { 'S': 'stable', 'C': 'cpython', 'P': 'private', 'I': 'internal', }, 'kind': { 'F': 'func', 'D': 'data', 'I': 'inline', 'M': 'macro', 'C': 'constant', }, } def resolve_format(format): if not format: return 'table' elif isinstance(format, str) and format in _FORMATS: return format else: return resolve_columns(format) def get_renderer(format): format = resolve_format(format) if isinstance(format, str): try: return _FORMATS[format] except KeyError: raise ValueError(f'unsupported format {format!r}') else: def render(items, **kwargs): return render_table(items, columns=format, **kwargs) return render def render_table(items, *, columns=None, groupby='kind', sort=True, showempty=False, verbose=False, ): if groupby is None: groupby = 'kind' if showempty is None: showempty = False if groupby: (collated, groupby, maxfilename, maxname, maxextra, ) = _collate(items, groupby, showempty) for grouping in GROUPINGS: maxextra[grouping] = max(len(g) for g in GROUPINGS[grouping]) _, extra = _resolve_full_groupby(groupby) extras = [extra] markers = {extra: _MARKERS[extra]} groups = GROUPINGS[groupby] else: # XXX Support no grouping? raise NotImplementedError if columns: def get_extra(item): return {extra: getattr(item, extra) for extra in ('kind', 'level')} else: if verbose: extracols = [f'{extra}:{maxextra[extra]}' for extra in extras] def get_extra(item): return {extra: getattr(item, extra) for extra in extras} elif len(extras) == 1: extra, = extras extracols = [f'{m}:1' for m in markers[extra]] def get_extra(item): return {m: m if getattr(item, extra) == markers[extra][m] else '' for m in markers[extra]} else: raise NotImplementedError #extracols = [[f'{m}:1' for m in markers[extra]] # for extra in extras] #def get_extra(item): # values = {} # for extra in extras: # cur = markers[extra] # for m in cur: # values[m] = m if getattr(item, m) == cur[m] else '' # return values columns = [ f'filename:{maxfilename}', f'name:{maxname}', *extracols, ] header, div, fmt = build_table(columns) if sort: sortkey = _get_sortkey(sort, groupby, columns) total = 0 for group, grouped in collated.items(): if not showempty and group not in collated: continue yield '' yield f' === {group} ===' yield '' yield header yield div if grouped: if sort: grouped = sorted(grouped, key=sortkey) for item in grouped: yield fmt.format( filename=item.relfile, name=item.name, **get_extra(item), ) yield div subtotal = len(grouped) yield f' sub-total: {subtotal}' total += subtotal yield '' yield f'total: {total}' def render_full(items, *, groupby='kind', sort=None, showempty=None, verbose=False, ): if groupby is None: groupby = 'kind' if showempty is None: showempty = False if sort: sortkey = _get_sortkey(sort, groupby, None) if groupby: collated, groupby, _, _, _ = _collate(items, groupby, showempty) for group, grouped in collated.items(): yield '#' * 25 yield f'# {group} ({len(grouped)})' yield '#' * 25 yield '' if not grouped: continue if sort: grouped = sorted(grouped, key=sortkey) for item in grouped: yield from _render_item_full(item, groupby, verbose) yield '' else: if sort: items = sorted(items, key=sortkey) for item in items: yield from _render_item_full(item, None, verbose) yield '' def _render_item_full(item, groupby, verbose): yield item.name yield f' {"filename:":10} {item.relfile}' for extra in ('kind', 'level'): #if groupby != extra: yield f' {extra+":":10} {getattr(item, extra)}' if verbose: print(' ---------------------------------------') for lno, line in enumerate(item.text, item.lno): print(f' | {lno:3} {line}') print(' ---------------------------------------') def render_summary(items, *, groupby='kind', sort=None, showempty=None, verbose=False, ): if groupby is None: groupby = 'kind' summary = summarize( items, groupby=groupby, includeempty=showempty, minimize=None if showempty else not verbose, ) subtotals = summary['totals']['subs'] bygroup = summary['totals']['bygroup'] lastempty = False for outer, subtotal in subtotals.items(): if bygroup: subtotal = f'({subtotal})' yield f'{outer + ":":20} {subtotal:>8}' else: yield f'{outer + ":":10} {subtotal:>8}' if outer in bygroup: for inner, count in bygroup[outer].items(): yield f' {inner + ":":9} {count}' lastempty = False else: lastempty = True total = f'*{summary["totals"]["all"]}*' label = '*total*:' if bygroup: yield f'{label:20} {total:>8}' else: yield f'{label:10} {total:>9}' _FORMATS = { 'table': render_table, 'full': render_full, 'summary': render_summary, }