| from collections import namedtuple | |
| import csv | |
| import re | |
| import textwrap | |
| from . import NOT_SET, strutil, fsutil | |
| EMPTY = '-' | |
| UNKNOWN = '???' | |
| def parse_markers(markers, default=None): | |
| if markers is NOT_SET: | |
| return default | |
| if not markers: | |
| return None | |
| if type(markers) is not str: | |
| return markers | |
| if markers == markers[0] * len(markers): | |
| return [markers] | |
| return list(markers) | |
| def fix_row(row, **markers): | |
| if isinstance(row, str): | |
| raise NotImplementedError(row) | |
| empty = parse_markers(markers.pop('empty', ('-',))) | |
| unknown = parse_markers(markers.pop('unknown', ('???',))) | |
| row = (val if val else None for val in row) | |
| if not empty: | |
| if unknown: | |
| row = (UNKNOWN if val in unknown else val for val in row) | |
| elif not unknown: | |
| row = (EMPTY if val in empty else val for val in row) | |
| else: | |
| row = (EMPTY if val in empty else (UNKNOWN if val in unknown else val) | |
| for val in row) | |
| return tuple(row) | |
| def _fix_read_default(row): | |
| for value in row: | |
| yield value.strip() | |
| def _fix_write_default(row, empty=''): | |
| for value in row: | |
| yield empty if value is None else str(value) | |
| def _normalize_fix_read(fix): | |
| if fix is None: | |
| fix = '' | |
| if callable(fix): | |
| def fix_row(row): | |
| values = fix(row) | |
| return _fix_read_default(values) | |
| elif isinstance(fix, str): | |
| def fix_row(row): | |
| values = _fix_read_default(row) | |
| return (None if v == fix else v | |
| for v in values) | |
| else: | |
| raise NotImplementedError(fix) | |
| return fix_row | |
| def _normalize_fix_write(fix, empty=''): | |
| if fix is None: | |
| fix = empty | |
| if callable(fix): | |
| def fix_row(row): | |
| values = fix(row) | |
| return _fix_write_default(values, empty) | |
| elif isinstance(fix, str): | |
| def fix_row(row): | |
| return _fix_write_default(row, fix) | |
| else: | |
| raise NotImplementedError(fix) | |
| return fix_row | |
| def read_table(infile, header, *, | |
| sep='\t', | |
| fix=None, | |
| _open=open, | |
| _get_reader=csv.reader, | |
| ): | |
| """Yield each row of the given ???-separated (e.g. tab) file.""" | |
| if isinstance(infile, str): | |
| with _open(infile, newline='') as infile: | |
| yield from read_table( | |
| infile, | |
| header, | |
| sep=sep, | |
| fix=fix, | |
| _open=_open, | |
| _get_reader=_get_reader, | |
| ) | |
| return | |
| lines = strutil._iter_significant_lines(infile) | |
| # Validate the header. | |
| if not isinstance(header, str): | |
| header = sep.join(header) | |
| try: | |
| actualheader = next(lines).strip() | |
| except StopIteration: | |
| actualheader = '' | |
| if actualheader != header: | |
| raise ValueError(f'bad header {actualheader!r}') | |
| fix_row = _normalize_fix_read(fix) | |
| for row in _get_reader(lines, delimiter=sep or '\t'): | |
| yield tuple(fix_row(row)) | |
| def write_table(outfile, header, rows, *, | |
| sep='\t', | |
| fix=None, | |
| backup=True, | |
| _open=open, | |
| _get_writer=csv.writer, | |
| ): | |
| """Write each of the rows to the given ???-separated (e.g. tab) file.""" | |
| if backup: | |
| fsutil.create_backup(outfile, backup) | |
| if isinstance(outfile, str): | |
| with _open(outfile, 'w', newline='') as outfile: | |
| return write_table( | |
| outfile, | |
| header, | |
| rows, | |
| sep=sep, | |
| fix=fix, | |
| backup=backup, | |
| _open=_open, | |
| _get_writer=_get_writer, | |
| ) | |
| if isinstance(header, str): | |
| header = header.split(sep or '\t') | |
| fix_row = _normalize_fix_write(fix) | |
| writer = _get_writer(outfile, delimiter=sep or '\t') | |
| writer.writerow(header) | |
| for row in rows: | |
| writer.writerow( | |
| tuple(fix_row(row)) | |
| ) | |
| def parse_table(entries, sep, header=None, rawsep=None, *, | |
| default=NOT_SET, | |
| strict=True, | |
| ): | |
| header, sep = _normalize_table_file_props(header, sep) | |
| if not sep: | |
| raise ValueError('missing "sep"') | |
| ncols = None | |
| if header: | |
| if strict: | |
| ncols = len(header.split(sep)) | |
| cur_file = None | |
| for line, filename in strutil.parse_entries(entries, ignoresep=sep): | |
| _sep = sep | |
| if filename: | |
| if header and cur_file != filename: | |
| cur_file = filename | |
| # Skip the first line if it's the header. | |
| if line.strip() == header: | |
| continue | |
| else: | |
| # We expected the header. | |
| raise NotImplementedError((header, line)) | |
| elif rawsep and sep not in line: | |
| _sep = rawsep | |
| row = _parse_row(line, _sep, ncols, default) | |
| if strict and not ncols: | |
| ncols = len(row) | |
| yield row, filename | |
| def parse_row(line, sep, *, ncols=None, default=NOT_SET): | |
| if not sep: | |
| raise ValueError('missing "sep"') | |
| return _parse_row(line, sep, ncols, default) | |
| def _parse_row(line, sep, ncols, default): | |
| row = tuple(v.strip() for v in line.split(sep)) | |
| if (ncols or 0) > 0: | |
| diff = ncols - len(row) | |
| if diff: | |
| if default is NOT_SET or diff < 0: | |
| raise Exception(f'bad row (expected {ncols} columns, got {row!r})') | |
| row += (default,) * diff | |
| return row | |
| def _normalize_table_file_props(header, sep): | |
| if not header: | |
| return None, sep | |
| if not isinstance(header, str): | |
| if not sep: | |
| raise NotImplementedError(header) | |
| header = sep.join(header) | |
| elif not sep: | |
| for sep in ('\t', ',', ' '): | |
| if sep in header: | |
| break | |
| else: | |
| sep = None | |
| return header, sep | |
| ################################## | |
| # stdout tables | |
| WIDTH = 20 | |
| def resolve_columns(specs): | |
| if isinstance(specs, str): | |
| specs = specs.replace(',', ' ').strip().split() | |
| resolved = [] | |
| for raw in specs: | |
| column = ColumnSpec.from_raw(raw) | |
| resolved.append(column) | |
| return resolved | |
| def build_table(specs, *, sep=' ', defaultwidth=None): | |
| columns = resolve_columns(specs) | |
| return _build_table(columns, sep=sep, defaultwidth=defaultwidth) | |
| class ColumnSpec(namedtuple('ColumnSpec', 'field label fmt')): | |
| REGEX = re.compile(textwrap.dedent(r''' | |
| ^ | |
| (?: | |
| \[ | |
| ( | |
| (?: [^\s\]] [^\]]* )? | |
| [^\s\]] | |
| ) # <label> | |
| ] | |
| )? | |
| ( [-\w]+ ) # <field> | |
| (?: | |
| (?: | |
| : | |
| ( [<^>] ) # <align> | |
| ( \d+ )? # <width1> | |
| ) | |
| | | |
| (?: | |
| (?: | |
| : | |
| ( \d+ ) # <width2> | |
| )? | |
| (?: | |
| : | |
| ( .*? ) # <fmt> | |
| )? | |
| ) | |
| )? | |
| $ | |
| '''), re.VERBOSE) | |
| @classmethod | |
| def from_raw(cls, raw): | |
| if not raw: | |
| raise ValueError('missing column spec') | |
| elif isinstance(raw, cls): | |
| return raw | |
| if isinstance(raw, str): | |
| *values, _ = cls._parse(raw) | |
| else: | |
| *values, _ = cls._normalize(raw) | |
| if values is None: | |
| raise ValueError(f'unsupported column spec {raw!r}') | |
| return cls(*values) | |
| @classmethod | |
| def parse(cls, specstr): | |
| parsed = cls._parse(specstr) | |
| if not parsed: | |
| return None | |
| *values, _ = parsed | |
| return cls(*values) | |
| @classmethod | |
| def _parse(cls, specstr): | |
| m = cls.REGEX.match(specstr) | |
| if not m: | |
| return None | |
| (label, field, | |
| align, width1, | |
| width2, fmt, | |
| ) = m.groups() | |
| if not label: | |
| label = field | |
| if fmt: | |
| assert not align and not width1, (specstr,) | |
| _parsed = _parse_fmt(fmt) | |
| if not _parsed: | |
| raise NotImplementedError | |
| elif width2: | |
| width, _ = _parsed | |
| if width != int(width2): | |
| raise NotImplementedError(specstr) | |
| elif width2: | |
| fmt = width2 | |
| width = int(width2) | |
| else: | |
| assert not fmt, (fmt, specstr) | |
| if align: | |
| width = int(width1) if width1 else len(label) | |
| fmt = f'{align}{width}' | |
| else: | |
| width = None | |
| return field, label, fmt, width | |
| @classmethod | |
| def _normalize(cls, spec): | |
| if len(spec) == 1: | |
| raw, = spec | |
| raise NotImplementedError | |
| return _resolve_column(raw) | |
| if len(spec) == 4: | |
| label, field, width, fmt = spec | |
| if width: | |
| if not fmt: | |
| fmt = str(width) | |
| elif _parse_fmt(fmt)[0] != width: | |
| raise ValueError(f'width mismatch in {spec}') | |
| elif len(raw) == 3: | |
| label, field, fmt = spec | |
| if not field: | |
| label, field = None, label | |
| elif not isinstance(field, str) or not field.isidentifier(): | |
| # XXX This doesn't seem right... | |
| fmt = f'{field}:{fmt}' if fmt else field | |
| label, field = None, label | |
| elif len(raw) == 2: | |
| label = None | |
| field, fmt = raw | |
| if not field: | |
| field, fmt = fmt, None | |
| elif not field.isidentifier() or fmt.isidentifier(): | |
| label, field = field, fmt | |
| else: | |
| raise NotImplementedError | |
| fmt = f':{fmt}' if fmt else '' | |
| if label: | |
| return cls._parse(f'[{label}]{field}{fmt}') | |
| else: | |
| return cls._parse(f'{field}{fmt}') | |
| @property | |
| def width(self): | |
| if not self.fmt: | |
| return None | |
| parsed = _parse_fmt(self.fmt) | |
| if not parsed: | |
| return None | |
| width, _ = parsed | |
| return width | |
| def resolve_width(self, default=None): | |
| return _resolve_width(self.width, self.fmt, self.label, default) | |
| def _parse_fmt(fmt): | |
| if fmt.startswith(tuple('<^>')): | |
| align = fmt[0] | |
| width = fmt[1:] | |
| if width.isdigit(): | |
| return int(width), align | |
| elif fmt.isdigit(): | |
| return int(fmt), '<' | |
| return None | |
| def _resolve_width(width, fmt, label, default): | |
| if width: | |
| if not isinstance(width, int): | |
| raise NotImplementedError | |
| return width | |
| elif fmt: | |
| parsed = _parse_fmt(fmt) | |
| if parsed: | |
| width, _ = parsed | |
| if width: | |
| return width | |
| if not default: | |
| return WIDTH | |
| elif hasattr(default, 'get'): | |
| defaults = default | |
| default = defaults.get(None) or WIDTH | |
| return defaults.get(label) or default | |
| else: | |
| return default or WIDTH | |
| def _build_table(columns, *, sep=' ', defaultwidth=None): | |
| header = [] | |
| div = [] | |
| rowfmt = [] | |
| for spec in columns: | |
| width = spec.resolve_width(defaultwidth) | |
| colfmt = spec.fmt | |
| colfmt = f':{spec.fmt}' if spec.fmt else f':{width}' | |
| header.append(f' {{:^{width}}} '.format(spec.label)) | |
| div.append('-' * (width + 2)) | |
| rowfmt.append(f' {{{spec.field}{colfmt}}} ') | |
| return ( | |
| sep.join(header), | |
| sep.join(div), | |
| sep.join(rowfmt), | |
| ) |