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
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+) # <func>
\s* [(]
)
''')
CAPI_DATA = textwrap.dedent(rf'''
(?:
^
\s*
PyAPI_DATA \s*
[(]
{_ind(_MAYBE_NESTED_PARENS, 2)}
[)] \s*
(\w+) # <data>
\b [^(]
)
''')
CAPI_INLINE = textwrap.dedent(r'''
(?:
^
\s*
static \s+ inline \s+
.*?
\s+
( \w+ ) # <inline>
\s* [(]
)
''')
CAPI_MACRO = textwrap.dedent(r'''
(?:
(\w+) # <macro>
[(]
)
''')
CAPI_CONSTANT = textwrap.dedent(r'''
(?:
(\w+) # <constant>
\s+ [^(]
)
''')
CAPI_DEFINE = textwrap.dedent(rf'''
(?:
^
\s* [#] \s* define \s+
(?:
{_ind(CAPI_MACRO, 3)}
|
{_ind(CAPI_CONSTANT, 3)}
|
(?:
# ignored
\w+ # <defined_name>
\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'):
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']
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}'
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,
}