diff options
author | Tim Harder <radhermit@gmail.com> | 2021-03-31 12:17:59 -0600 |
---|---|---|
committer | Tim Harder <radhermit@gmail.com> | 2021-03-31 12:39:18 -0600 |
commit | 1f7316f6e5b851f8b1f7fed49e444046614b94e4 (patch) | |
tree | d487836ee3db2cf881d72a2135e36de723280f87 | |
parent | tests: update git repo default branch name (diff) | |
download | pkgdev-1f7316f6e5b851f8b1f7fed49e444046614b94e4.tar.gz pkgdev-1f7316f6e5b851f8b1f7fed49e444046614b94e4.tar.bz2 pkgdev-1f7316f6e5b851f8b1f7fed49e444046614b94e4.zip |
pkgdev showkw: initial import, moved from pkgcore's pshowkw
-rw-r--r-- | .coveragerc | 2 | ||||
-rw-r--r-- | README.rst | 2 | ||||
-rw-r--r-- | src/pkgdev/_vendor/__init__.py | 1 | ||||
-rw-r--r-- | src/pkgdev/_vendor/tabulate.py | 1503 | ||||
-rw-r--r-- | src/pkgdev/scripts/pkgdev_showkw.py | 255 | ||||
-rw-r--r-- | tests/scripts/test_pkgdev_showkw.py | 19 |
6 files changed, 1781 insertions, 1 deletions
diff --git a/.coveragerc b/.coveragerc index c3dcf15..92b97a6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,7 +1,7 @@ [run] source = pkgdev branch = True -omit = src/*, tests/* +omit = src/*, tests/*, */_vendor/* [paths] source = **/site-packages/pkgdev @@ -14,6 +14,8 @@ pkgdev provides a collection of tools for Gentoo development including: **pkgdev push**: scan commits for QA issues before pushing upstream +**pkgdev showkw**: show package keywords + Dependencies ============ diff --git a/src/pkgdev/_vendor/__init__.py b/src/pkgdev/_vendor/__init__.py new file mode 100644 index 0000000..c6f4642 --- /dev/null +++ b/src/pkgdev/_vendor/__init__.py @@ -0,0 +1 @@ +"""Vendored external modules with modifications.""" diff --git a/src/pkgdev/_vendor/tabulate.py b/src/pkgdev/_vendor/tabulate.py new file mode 100644 index 0000000..16d245f --- /dev/null +++ b/src/pkgdev/_vendor/tabulate.py @@ -0,0 +1,1503 @@ +# Copyright (c) 2011-2017 Sergey Astanin +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Pretty-print tabular data.""" + +import math +import re +from collections import namedtuple +from collections.abc import Iterable +from functools import partial, reduce +from itertools import zip_longest + +_none_type = type(None) +_bool_type = bool +_int_type = int +_long_type = int +_float_type = float +_text_type = str +_binary_type = bytes + +try: + import wcwidth # optional wide-character (CJK) support +except ImportError: + wcwidth = None + +# minimum extra space in headers +MIN_PADDING = 2 + +# Whether or not to preserve leading/trailing whitespace in data. +PRESERVE_WHITESPACE = False + +_DEFAULT_FLOATFMT="g" +_DEFAULT_MISSINGVAL="" + +# if True, enable wide-character (CJK) support +WIDE_CHARS_MODE = wcwidth is not None + +Line = namedtuple("Line", ["begin", "hline", "sep", "end"]) + +DataRow = namedtuple("DataRow", ["begin", "sep", "end"]) + + +# A table structure is suppposed to be: +# +# --- lineabove --------- +# headerrow +# --- linebelowheader --- +# datarow +# --- linebewteenrows --- +# ... (more datarows) ... +# --- linebewteenrows --- +# last datarow +# --- linebelow --------- +# +# TableFormat's line* elements can be +# +# - either None, if the element is not used, +# - or a Line tuple, +# - or a function: [col_widths], [col_alignments] -> string. +# +# TableFormat's *row elements can be +# +# - either None, if the element is not used, +# - or a DataRow tuple, +# - or a function: [cell_values], [col_widths], [col_alignments] -> string. +# +# padding (an integer) is the amount of white space around data values. +# +# with_header_hide: +# +# - either None, to display all table elements unconditionally, +# - or a list of elements not to be displayed if the table has column headers. + +TableFormat = namedtuple("TableFormat", [ + "lineabove", "linebelowheader", "linebetweenrows", "linebelow", + "headerrow", "datarow", "padding", "with_header_hide", + "vertical_headers", +]) +# TODO: use defaults param when >= python3.7 only +# set default for vertical_headers params +TableFormat.__new__.__defaults__ = (False,) + + +def _pipe_segment_with_colons(align, colwidth): + """Return a segment of a horizontal line with optional colons which + indicate column's alignment (as in `pipe` output format).""" + w = colwidth + if align in ["right", "decimal"]: + return ('-' * (w - 1)) + ":" + elif align == "center": + return ":" + ('-' * (w - 2)) + ":" + elif align == "left": + return ":" + ('-' * (w - 1)) + else: + return '-' * w + + +def _pipe_line_with_colons(colwidths, colaligns): + """Return a horizontal line with optional colons to indicate column's + alignment (as in `pipe` output format).""" + segments = [_pipe_segment_with_colons(a, w) for a, w in zip(colaligns, colwidths)] + return "|" + "|".join(segments) + "|" + + +def _mediawiki_row_with_attrs(separator, cell_values, colwidths, colaligns): + alignment = { "left": '', + "right": 'align="right"| ', + "center": 'align="center"| ', + "decimal": 'align="right"| ' } + # hard-coded padding _around_ align attribute and value together + # rather than padding parameter which affects only the value + values_with_attrs = [' ' + alignment.get(a, '') + c + ' ' + for c, a in zip(cell_values, colaligns)] + colsep = separator*2 + return (separator + colsep.join(values_with_attrs)).rstrip() + + +def _textile_row_with_attrs(cell_values, colwidths, colaligns): + cell_values[0] += ' ' + alignment = { "left": "<.", "right": ">.", "center": "=.", "decimal": ">." } + values = (alignment.get(a, '') + v for a, v in zip(colaligns, cell_values)) + return '|' + '|'.join(values) + '|' + + +def _html_begin_table_without_header(colwidths_ignore, colaligns_ignore): + # this table header will be suppressed if there is a header row + return "\n".join(["<table>", "<tbody>"]) + + +def _html_row_with_attrs(celltag, cell_values, colwidths, colaligns): + alignment = { "left": '', + "right": ' style="text-align: right;"', + "center": ' style="text-align: center;"', + "decimal": ' style="text-align: right;"' } + values_with_attrs = ["<{0}{1}>{2}</{0}>".format(celltag, alignment.get(a, ''), c) + for c, a in zip(cell_values, colaligns)] + rowhtml = "<tr>" + "".join(values_with_attrs).rstrip() + "</tr>" + if celltag == "th": # it's a header row, create a new table header + rowhtml = "\n".join(["<table>", + "<thead>", + rowhtml, + "</thead>", + "<tbody>"]) + return rowhtml + +def _moin_row_with_attrs(celltag, cell_values, colwidths, colaligns, header=''): + alignment = { "left": '', + "right": '<style="text-align: right;">', + "center": '<style="text-align: center;">', + "decimal": '<style="text-align: right;">' } + values_with_attrs = ["{0}{1} {2} ".format(celltag, + alignment.get(a, ''), + header+c+header) + for c, a in zip(cell_values, colaligns)] + return "".join(values_with_attrs)+"||" + +def _latex_line_begin_tabular(colwidths, colaligns, booktabs=False): + alignment = { "left": "l", "right": "r", "center": "c", "decimal": "r" } + tabular_columns_fmt = "".join([alignment.get(a, "l") for a in colaligns]) + return "\n".join(["\\begin{tabular}{" + tabular_columns_fmt + "}", + "\\toprule" if booktabs else "\\hline"]) + +LATEX_ESCAPE_RULES = {r"&": r"\&", r"%": r"\%", r"$": r"\$", r"#": r"\#", + r"_": r"\_", r"^": r"\^{}", r"{": r"\{", r"}": r"\}", + r"~": r"\textasciitilde{}", "\\": r"\textbackslash{}", + r"<": r"\ensuremath{<}", r">": r"\ensuremath{>}"} + +def _latex_row(cell_values, colwidths, colaligns, escrules=LATEX_ESCAPE_RULES): + def escape_char(c): + return escrules.get(c, c) + escaped_values = ["".join(map(escape_char, cell)) for cell in cell_values] + rowfmt = DataRow("", "&", "\\\\") + return _build_simple_row(escaped_values, rowfmt) + +def _rst_escape_first_column(rows, headers): + def escape_empty(val): + if isinstance(val, (_text_type, _binary_type)) and not val.strip(): + return ".." + else: + return val + new_headers = list(headers) + new_rows = [] + if headers: + new_headers[0] = escape_empty(headers[0]) + for row in rows: + new_row = list(row) + if new_row: + new_row[0] = escape_empty(row[0]) + new_rows.append(new_row) + return new_rows, new_headers + + +_table_formats = { + "simple": + TableFormat(lineabove=Line("", "-", " ", ""), + linebelowheader=Line("", "-", " ", ""), + linebetweenrows=None, + linebelow=Line("", "-", " ", ""), + headerrow=DataRow("", " ", ""), + datarow=DataRow("", " ", ""), + padding=0, + with_header_hide=["lineabove", "linebelow"], + ), + "plain": + TableFormat(lineabove=None, linebelowheader=None, + linebetweenrows=None, linebelow=None, + headerrow=DataRow("", " ", ""), + datarow=DataRow("", " ", ""), + padding=0, with_header_hide=None, + ), + "grid": + TableFormat(lineabove=Line("+", "-", "+", "+"), + linebelowheader=Line("+", "=", "+", "+"), + linebetweenrows=Line("+", "-", "+", "+"), + linebelow=Line("+", "-", "+", "+"), + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, with_header_hide=None, + ), + "fancy_grid": + TableFormat(lineabove=Line("╒", "═", "╤", "╕"), + linebelowheader=Line("╞", "═", "╪", "╡"), + linebetweenrows=Line("├", "─", "┼", "┤"), + linebelow=Line("╘", "═", "╧", "╛"), + headerrow=DataRow("│", "│", "│"), + datarow=DataRow("│", "│", "│"), + padding=1, with_header_hide=None, + ), + "github": + TableFormat(lineabove=Line("|", "-", "|", "|"), + linebelowheader=Line("|", "-", "|", "|"), + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=["lineabove"], + ), + "pipe": + TableFormat(lineabove=_pipe_line_with_colons, + linebelowheader=_pipe_line_with_colons, + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=["lineabove"], + ), + "orgtbl": + TableFormat(lineabove=None, + linebelowheader=Line("|", "-", "+", "|"), + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, with_header_hide=None, + ), + "jira": + TableFormat(lineabove=None, + linebelowheader=None, + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("||", "||", "||"), + datarow=DataRow("|", "|", "|"), + padding=1, with_header_hide=None, + ), + "presto": + TableFormat(lineabove=None, + linebelowheader=Line("", "-", "+", ""), + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("", "|", ""), + datarow=DataRow("", "|", ""), + padding=1, with_header_hide=None, + ), + "showkw": + TableFormat(lineabove=None, + linebelowheader=Line("", "-", "-", ""), + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("", "", ""), + datarow=DataRow("", "", ""), + padding=1, with_header_hide=None, + vertical_headers=True, + ), + "psql": + TableFormat(lineabove=Line("+", "-", "+", "+"), + linebelowheader=Line("|", "-", "+", "|"), + linebetweenrows=None, + linebelow=Line("+", "-", "+", "+"), + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, with_header_hide=None, + ), + "rst": + TableFormat(lineabove=Line("", "=", " ", ""), + linebelowheader=Line("", "=", " ", ""), + linebetweenrows=None, + linebelow=Line("", "=", " ", ""), + headerrow=DataRow("", " ", ""), + datarow=DataRow("", " ", ""), + padding=0, with_header_hide=None, + ), + "mediawiki": + TableFormat(lineabove=Line("{| class=\"wikitable\" style=\"text-align: left;\"", + "", "", "\n|+ <!-- caption -->\n|-"), + linebelowheader=Line("|-", "", "", ""), + linebetweenrows=Line("|-", "", "", ""), + linebelow=Line("|}", "", "", ""), + headerrow=partial(_mediawiki_row_with_attrs, "!"), + datarow=partial(_mediawiki_row_with_attrs, "|"), + padding=0, with_header_hide=None, + ), + "moinmoin": + TableFormat(lineabove=None, + linebelowheader=None, + linebetweenrows=None, + linebelow=None, + headerrow=partial(_moin_row_with_attrs,"||",header="'''"), + datarow=partial(_moin_row_with_attrs,"||"), + padding=1, with_header_hide=None, + ), + "youtrack": + TableFormat(lineabove=None, + linebelowheader=None, + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("|| ", " || ", " || "), + datarow=DataRow("| ", " | ", " |"), + padding=1, with_header_hide=None, + ), + "html": + TableFormat(lineabove=_html_begin_table_without_header, + linebelowheader="", + linebetweenrows=None, + linebelow=Line("</tbody>\n</table>", "", "", ""), + headerrow=partial(_html_row_with_attrs, "th"), + datarow=partial(_html_row_with_attrs, "td"), + padding=0, with_header_hide=["lineabove"], + ), + "latex": + TableFormat(lineabove=_latex_line_begin_tabular, + linebelowheader=Line("\\hline", "", "", ""), + linebetweenrows=None, + linebelow=Line("\\hline\n\\end{tabular}", "", "", ""), + headerrow=_latex_row, + datarow=_latex_row, + padding=1, with_header_hide=None, + ), + "latex_raw": + TableFormat(lineabove=_latex_line_begin_tabular, + linebelowheader=Line("\\hline", "", "", ""), + linebetweenrows=None, + linebelow=Line("\\hline\n\\end{tabular}", "", "", ""), + headerrow=partial(_latex_row, escrules={}), + datarow=partial(_latex_row, escrules={}), + padding=1, with_header_hide=None, + ), + "latex_booktabs": + TableFormat(lineabove=partial(_latex_line_begin_tabular, booktabs=True), + linebelowheader=Line("\\midrule", "", "", ""), + linebetweenrows=None, + linebelow=Line("\\bottomrule\n\\end{tabular}", "", "", ""), + headerrow=_latex_row, + datarow=_latex_row, + padding=1, with_header_hide=None, + ), + "tsv": + TableFormat(lineabove=None, linebelowheader=None, + linebetweenrows=None, linebelow=None, + headerrow=DataRow("", "\t", ""), + datarow=DataRow("", "\t", ""), + padding=0, with_header_hide=None, + ), + "textile": + TableFormat(lineabove=None, linebelowheader=None, + linebetweenrows=None, linebelow=None, + headerrow=DataRow("|_. ", "|_.", "|"), + datarow=_textile_row_with_attrs, + padding=1, with_header_hide=None, + ), +} + + +tabulate_formats = sorted(_table_formats.keys()) + +# The table formats for which multiline cells will be folded into subsequent +# table rows. The key is the original format specified at the API. The value is +# the format that will be used to represent the original format. +multiline_formats = { + "plain": "plain", + "simple": "simple", + "grid": "grid", + "fancy_grid": "fancy_grid", + "pipe": "pipe", + "orgtbl": "orgtbl", + "jira": "jira", + "presto": "presto", + "psql": "psql", + "rst": "rst", +} + +# TODO: Add multiline support for the remaining table formats: +# - mediawiki: Replace \n with <br> +# - moinmoin: TBD +# - youtrack: TBD +# - html: Replace \n with <br> +# - latex*: Use "makecell" package: In header, replace X\nY with +# \thead{X\\Y} and in data row, replace X\nY with \makecell{X\\Y} +# - tsv: TBD +# - textile: Replace \n with <br/> (must be well-formed XML) + +_multiline_codes = re.compile(r"\r|\n|\r\n") +_multiline_codes_bytes = re.compile(rb"\r|\n|\r\n") +_invisible_codes = re.compile(r"\x1b\[\d+[;\d]*m|\x1b\[\d*\;\d*\;\d*m") # ANSI color codes +_invisible_codes_bytes = re.compile(rb"\x1b\[\d+[;\d]*m|\x1b\[\d*\;\d*\;\d*m") # ANSI color codes + + +def simple_separated_format(separator): + """Construct a simple TableFormat with columns separated by a separator. + + >>> tsv = simple_separated_format("\\t") ; \ + tabulate([["foo", 1], ["spam", 23]], tablefmt=tsv) == 'foo \\t 1\\nspam\\t23' + True + + """ + return TableFormat(None, None, None, None, + headerrow=DataRow('', separator, ''), + datarow=DataRow('', separator, ''), + padding=0, with_header_hide=None) + + +def _isconvertible(conv, string): + try: + n = conv(string) + return True + except (ValueError, TypeError): + return False + + +def _isnumber(string): + """ + >>> _isnumber("123.45") + True + >>> _isnumber("123") + True + >>> _isnumber("spam") + False + >>> _isnumber("123e45678") + False + >>> _isnumber("inf") + True + """ + if not _isconvertible(float, string): + return False + elif isinstance(string, (_text_type, _binary_type)) and ( + math.isinf(float(string)) or math.isnan(float(string))): + return string.lower() in ['inf', '-inf', 'nan'] + return True + + +def _isint(string, inttype=int): + """ + >>> _isint("123") + True + >>> _isint("123.45") + False + """ + return type(string) is inttype or\ + (isinstance(string, _binary_type) or isinstance(string, _text_type))\ + and\ + _isconvertible(inttype, string) + + +def _isbool(string): + """ + >>> _isbool(True) + True + >>> _isbool("False") + True + >>> _isbool(1) + False + """ + return type(string) is _bool_type or\ + (isinstance(string, (_binary_type, _text_type))\ + and\ + string in ("True", "False")) + + +def _type(string, has_invisible=True, numparse=True): + """The least generic type (type(None), int, float, str, unicode). + + >>> _type(None) is type(None) + True + >>> _type("foo") is type("") + True + >>> _type("1") is type(1) + True + >>> _type('\x1b[31m42\x1b[0m') is type(42) + True + >>> _type('\x1b[31m42\x1b[0m') is type(42) + True + + """ + + if has_invisible and \ + (isinstance(string, _text_type) or isinstance(string, _binary_type)): + string = _strip_invisible(string) + + if string is None: + return _none_type + elif hasattr(string, "isoformat"): # datetime.datetime, date, and time + return _text_type + elif _isbool(string): + return _bool_type + elif _isint(string) and numparse: + return int + elif _isint(string, _long_type) and numparse: + return int + elif _isnumber(string) and numparse: + return float + elif isinstance(string, _binary_type): + return _binary_type + else: + return _text_type + + +def _afterpoint(string): + """Symbols after a decimal point, -1 if the string lacks the decimal point. + + >>> _afterpoint("123.45") + 2 + >>> _afterpoint("1001") + -1 + >>> _afterpoint("eggs") + -1 + >>> _afterpoint("123e45") + 2 + + """ + if _isnumber(string): + if _isint(string): + return -1 + else: + pos = string.rfind(".") + pos = string.lower().rfind("e") if pos < 0 else pos + if pos >= 0: + return len(string) - pos - 1 + else: + return -1 # no point + else: + return -1 # not a number + + +def _padleft(width, s): + """Flush right. + + >>> _padleft(6, '\u044f\u0439\u0446\u0430') == ' \u044f\u0439\u0446\u0430' + True + + """ + fmt = "{0:>%ds}" % width + return fmt.format(s) + + +def _padright(width, s): + """Flush left. + + >>> _padright(6, '\u044f\u0439\u0446\u0430') == '\u044f\u0439\u0446\u0430 ' + True + + """ + fmt = "{0:<%ds}" % width + return fmt.format(s) + + +def _padboth(width, s): + """Center string. + + >>> _padboth(6, '\u044f\u0439\u0446\u0430') == ' \u044f\u0439\u0446\u0430 ' + True + + """ + fmt = "{0:^%ds}" % width + return fmt.format(s) + + +def _padnone(ignore_width, s): + return s + + +def _strip_invisible(s): + "Remove invisible ANSI color codes." + if isinstance(s, _text_type): + return re.sub(_invisible_codes, "", s) + else: # a bytestring + return re.sub(_invisible_codes_bytes, "", s) + + +def _visible_width(s): + """Visible width of a printed string. ANSI color codes are removed. + + >>> _visible_width('\x1b[31mhello\x1b[0m'), _visible_width("world") + (5, 5) + + """ + # optional wide-character support + if wcwidth is not None and WIDE_CHARS_MODE: + len_fn = wcwidth.wcswidth + else: + len_fn = len + if isinstance(s, _text_type) or isinstance(s, _binary_type): + return len_fn(_strip_invisible(s)) + else: + return len_fn(_text_type(s)) + + +def _is_multiline(s): + if isinstance(s, _text_type): + return bool(re.search(_multiline_codes, s)) + else: # a bytestring + return bool(re.search(_multiline_codes_bytes, s)) + + +def _multiline_width(multiline_s, line_width_fn=len): + """Visible width of a potentially multiline content.""" + return max(map(line_width_fn, re.split("[\r\n]", multiline_s))) + + +def _choose_width_fn(has_invisible, enable_widechars, is_multiline): + """Return a function to calculate visible cell width.""" + if has_invisible: + line_width_fn = _visible_width + elif enable_widechars: # optional wide-character support if available + line_width_fn = wcwidth.wcswidth + else: + line_width_fn = len + if is_multiline: + width_fn = lambda s: _multiline_width(s, line_width_fn) + else: + width_fn = line_width_fn + return width_fn + + +def _align_column_choose_padfn(strings, alignment, has_invisible): + if alignment == "right": + if not PRESERVE_WHITESPACE: + strings = [s.strip() for s in strings] + padfn = _padleft + elif alignment == "center": + if not PRESERVE_WHITESPACE: + strings = [s.strip() for s in strings] + padfn = _padboth + elif alignment == "decimal": + if has_invisible: + decimals = [_afterpoint(_strip_invisible(s)) for s in strings] + else: + decimals = [_afterpoint(s) for s in strings] + maxdecimals = max(decimals) + strings = [s + (maxdecimals - decs) * " " + for s, decs in zip(strings, decimals)] + padfn = _padleft + elif not alignment: + padfn = _padnone + else: + if not PRESERVE_WHITESPACE: + strings = [s.strip() for s in strings] + padfn = _padright + return strings, padfn + + +def _align_column(strings, alignment, minwidth=0, + has_invisible=True, enable_widechars=False, is_multiline=False): + """[string] -> [padded_string]""" + strings, padfn = _align_column_choose_padfn(strings, alignment, has_invisible) + width_fn = _choose_width_fn(has_invisible, enable_widechars, is_multiline) + + s_widths = list(map(width_fn, strings)) + maxwidth = max(max(s_widths), minwidth) + # TODO: refactor column alignment in single-line and multiline modes + if is_multiline: + if not enable_widechars and not has_invisible: + padded_strings = [ + "\n".join([padfn(maxwidth, s) for s in ms.splitlines()]) + for ms in strings] + else: + # enable wide-character width corrections + s_lens = [max((len(s) for s in re.split("[\r\n]", ms))) for ms in strings] + visible_widths = [maxwidth - (w - l) for w, l in zip(s_widths, s_lens)] + # wcswidth and _visible_width don't count invisible characters; + # padfn doesn't need to apply another correction + padded_strings = ["\n".join([padfn(w, s) for s in (ms.splitlines() or ms)]) + for ms, w in zip(strings, visible_widths)] + else: # single-line cell values + if not enable_widechars and not has_invisible: + padded_strings = [padfn(maxwidth, s) for s in strings] + else: + # enable wide-character width corrections + s_lens = list(map(len, strings)) + visible_widths = [maxwidth - (w - l) for w, l in zip(s_widths, s_lens)] + # wcswidth and _visible_width don't count invisible characters; + # padfn doesn't need to apply another correction + padded_strings = [padfn(w, s) for s, w in zip(strings, visible_widths)] + return padded_strings + + +def _more_generic(type1, type2): + types = { _none_type: 0, _bool_type: 1, int: 2, float: 3, _binary_type: 4, _text_type: 5 } + invtypes = { 5: _text_type, 4: _binary_type, 3: float, 2: int, 1: _bool_type, 0: _none_type } + moregeneric = max(types.get(type1, 5), types.get(type2, 5)) + return invtypes[moregeneric] + + +def _column_type(strings, has_invisible=True, numparse=True): + """The least generic type all column values are convertible to. + + >>> _column_type([True, False]) is _bool_type + True + >>> _column_type(["1", "2"]) is _int_type + True + >>> _column_type(["1", "2.3"]) is _float_type + True + >>> _column_type(["1", "2.3", "four"]) is _text_type + True + >>> _column_type(["four", '\u043f\u044f\u0442\u044c']) is _text_type + True + >>> _column_type([None, "brux"]) is _text_type + True + >>> _column_type([1, 2, None]) is _int_type + True + >>> import datetime as dt + >>> _column_type([dt.datetime(1991,2,19), dt.time(17,35)]) is _text_type + True + + """ + types = [_type(s, has_invisible, numparse) for s in strings ] + return reduce(_more_generic, types, _bool_type) + + +def _format(val, valtype, floatfmt, missingval="", has_invisible=True): + """Format a value accoding to its type. + + Unicode is supported: + + >>> hrow = ['\u0431\u0443\u043a\u0432\u0430', '\u0446\u0438\u0444\u0440\u0430'] ; \ + tbl = [['\u0430\u0437', 2], ['\u0431\u0443\u043a\u0438', 4]] ; \ + good_result = '\\u0431\\u0443\\u043a\\u0432\\u0430 \\u0446\\u0438\\u0444\\u0440\\u0430\\n------- -------\\n\\u0430\\u0437 2\\n\\u0431\\u0443\\u043a\\u0438 4' ; \ + tabulate(tbl, headers=hrow) == good_result + True + + """ + if val is None: + return missingval + + if valtype in [int, _text_type]: + return "{0}".format(val) + elif valtype is _binary_type: + try: + return _text_type(val, "ascii") + except TypeError: + return _text_type(val) + elif valtype is float: + is_a_colored_number = has_invisible and isinstance(val, (_text_type, _binary_type)) + if is_a_colored_number: + raw_val = _strip_invisible(val) + formatted_val = format(float(raw_val), floatfmt) + return val.replace(raw_val, formatted_val) + else: + return format(float(val), floatfmt) + else: + return "{0}".format(val) + + +def _align_header(header, alignment, width, visible_width, is_multiline=False, width_fn=None): + "Pad string header to width chars given known visible_width of the header." + if is_multiline: + header_lines = re.split(_multiline_codes, header) + padded_lines = [_align_header(h, alignment, width, width_fn(h)) for h in header_lines] + return "\n".join(padded_lines) + # else: not multiline + ninvisible = len(header) - visible_width + width += ninvisible + if alignment == "left": + return _padright(width, header) + elif alignment == "center": + return _padboth(width, header) + elif not alignment: + return "{0}".format(header) + else: + return _padleft(width, header) + + +def _prepend_row_index(rows, index): + """Add a left-most index column.""" + if index is None or index is False: + return rows + if len(index) != len(rows): + print('index=', index) + print('rows=', rows) + raise ValueError('index must be as long as the number of data rows') + rows = [[v]+list(row) for v,row in zip(index, rows)] + return rows + + +def _bool(val): + "A wrapper around standard bool() which doesn't throw on NumPy arrays" + try: + return bool(val) + except ValueError: # val is likely to be a numpy array with many elements + return False + + +def _normalize_tabular_data(tabular_data, headers, showindex="default"): + """Transform a supported data type to a list of lists, and a list of headers. + + Supported tabular data types: + + * list-of-lists or another iterable of iterables + + * list of named tuples (usually used with headers="keys") + + * list of dicts (usually used with headers="keys") + + * list of OrderedDicts (usually used with headers="keys") + + * 2D NumPy arrays + + * NumPy record arrays (usually used with headers="keys") + + * dict of iterables (usually used with headers="keys") + + * pandas.DataFrame (usually used with headers="keys") + + The first row can be used as headers if headers="firstrow", + column indices can be used as headers if headers="keys". + + If showindex="default", show row indices of the pandas.DataFrame. + If showindex="always", show row indices for all types of data. + If showindex="never", don't show row indices for all types of data. + If showindex is an iterable, show its values as row indices. + + """ + + try: + bool(headers) + is_headers2bool_broken = False + except ValueError: # numpy.ndarray, pandas.core.index.Index, ... + is_headers2bool_broken = True + headers = list(headers) + + index = None + if hasattr(tabular_data, "keys") and hasattr(tabular_data, "values"): + # dict-like and pandas.DataFrame? + if hasattr(tabular_data.values, "__call__"): + # likely a conventional dict + keys = tabular_data.keys() + rows = list(zip_longest(*tabular_data.values())) # columns have to be transposed + elif hasattr(tabular_data, "index"): + # values is a property, has .index => it's likely a pandas.DataFrame (pandas 0.11.0) + keys = list(tabular_data) + if tabular_data.index.name is not None: + if isinstance(tabular_data.index.name, list): + keys[:0] = tabular_data.index.name + else: + keys[:0] = [tabular_data.index.name] + vals = tabular_data.values # values matrix doesn't need to be transposed + # for DataFrames add an index per default + index = list(tabular_data.index) + rows = [list(row) for row in vals] + else: + raise ValueError("tabular data doesn't appear to be a dict or a DataFrame") + + if headers == "keys": + headers = list(map(_text_type,keys)) # headers should be strings + + else: # it's a usual an iterable of iterables, or a NumPy array + rows = list(tabular_data) + + if (headers == "keys" and not rows): + # an empty table (issue #81) + headers = [] + elif (headers == "keys" and + hasattr(tabular_data, "dtype") and + getattr(tabular_data.dtype, "names")): + # numpy record array + headers = tabular_data.dtype.names + elif (headers == "keys" + and len(rows) > 0 + and isinstance(rows[0], tuple) + and hasattr(rows[0], "_fields")): + # namedtuple + headers = list(map(_text_type, rows[0]._fields)) + elif (len(rows) > 0 + and isinstance(rows[0], dict)): + # dict or OrderedDict + uniq_keys = set() # implements hashed lookup + keys = [] # storage for set + if headers == "firstrow": + firstdict = rows[0] if len(rows) > 0 else {} + keys.extend(firstdict.keys()) + uniq_keys.update(keys) + rows = rows[1:] + for row in rows: + for k in row.keys(): + #Save unique items in input order + if k not in uniq_keys: + keys.append(k) + uniq_keys.add(k) + if headers == 'keys': + headers = keys + elif isinstance(headers, dict): + # a dict of headers for a list of dicts + headers = [headers.get(k, k) for k in keys] + headers = list(map(_text_type, headers)) + elif headers == "firstrow": + if len(rows) > 0: + headers = [firstdict.get(k, k) for k in keys] + headers = list(map(_text_type, headers)) + else: + headers = [] + elif headers: + raise ValueError('headers for a list of dicts is not a dict or a keyword') + rows = [[row.get(k) for k in keys] for row in rows] + + elif (headers == "keys" + and hasattr(tabular_data, "description") + and hasattr(tabular_data, "fetchone") + and hasattr(tabular_data, "rowcount")): + # Python Database API cursor object (PEP 0249) + # print tabulate(cursor, headers='keys') + headers = [column[0] for column in tabular_data.description] + + elif headers == "keys" and len(rows) > 0: + # keys are column indices + headers = list(map(_text_type, range(len(rows[0])))) + + # take headers from the first row if necessary + if headers == "firstrow" and len(rows) > 0: + if index is not None: + headers = [index[0]] + list(rows[0]) + index = index[1:] + else: + headers = rows[0] + headers = list(map(_text_type, headers)) # headers should be strings + rows = rows[1:] + + headers = list(map(_text_type,headers)) + rows = list(map(list,rows)) + + # add or remove an index column + showindex_is_a_str = type(showindex) in [_text_type, _binary_type] + if showindex == "default" and index is not None: + rows = _prepend_row_index(rows, index) + elif isinstance(showindex, Iterable) and not showindex_is_a_str: + rows = _prepend_row_index(rows, list(showindex)) + elif showindex == "always" or (_bool(showindex) and not showindex_is_a_str): + if index is None: + index = list(range(len(rows))) + rows = _prepend_row_index(rows, index) + elif showindex == "never" or (not _bool(showindex) and not showindex_is_a_str): + pass + + # pad with empty headers for initial columns if necessary + if headers and len(rows) > 0: + nhs = len(headers) + ncols = len(rows[0]) + if nhs < ncols: + headers = [""]*(ncols - nhs) + headers + + return rows, headers + + + +def tabulate(tabular_data, headers=(), tablefmt="simple", + floatfmt=_DEFAULT_FLOATFMT, numalign="decimal", stralign="left", + missingval=_DEFAULT_MISSINGVAL, showindex="default", disable_numparse=False, + colalign=None): + """Format a fixed width table for pretty printing. + + >>> print(tabulate([[1, 2.34], [-56, "8.999"], ["2", "10001"]])) + --- --------- + 1 2.34 + -56 8.999 + 2 10001 + --- --------- + + The first required argument (`tabular_data`) can be a + list-of-lists (or another iterable of iterables), a list of named + tuples, a dictionary of iterables, an iterable of dictionaries, + a two-dimensional NumPy array, NumPy record array, or a Pandas' + dataframe. + + + Table headers + ------------- + + To print nice column headers, supply the second argument (`headers`): + + - `headers` can be an explicit list of column headers + - if `headers="firstrow"`, then the first row of data is used + - if `headers="keys"`, then dictionary keys or column indices are used + + Otherwise a headerless table is produced. + + If the number of headers is less than the number of columns, they + are supposed to be names of the last columns. This is consistent + with the plain-text format of R and Pandas' dataframes. + + >>> print(tabulate([["sex","age"],["Alice","F",24],["Bob","M",19]], + ... headers="firstrow")) + sex age + ----- ----- ----- + Alice F 24 + Bob M 19 + + By default, pandas.DataFrame data have an additional column called + row index. To add a similar column to all other types of data, + use `showindex="always"` or `showindex=True`. To suppress row indices + for all types of data, pass `showindex="never" or `showindex=False`. + To add a custom row index column, pass `showindex=some_iterable`. + + >>> print(tabulate([["F",24],["M",19]], showindex="always")) + - - -- + 0 F 24 + 1 M 19 + - - -- + + + Column alignment + ---------------- + + `tabulate` tries to detect column types automatically, and aligns + the values properly. By default it aligns decimal points of the + numbers (or flushes integer numbers to the right), and flushes + everything else to the left. Possible column alignments + (`numalign`, `stralign`) are: "right", "center", "left", "decimal" + (only for `numalign`), and None (to disable alignment). + + + Table formats + ------------- + + `floatfmt` is a format specification used for columns which + contain numeric data with a decimal point. This can also be + a list or tuple of format strings, one per column. + + `None` values are replaced with a `missingval` string (like + `floatfmt`, this can also be a list of values for different + columns): + + >>> print(tabulate([["spam", 1, None], + ... ["eggs", 42, 3.14], + ... ["other", None, 2.7]], missingval="?")) + ----- -- ---- + spam 1 ? + eggs 42 3.14 + other ? 2.7 + ----- -- ---- + + Various plain-text table formats (`tablefmt`) are supported: + 'plain', 'simple', 'grid', 'pipe', 'orgtbl', 'rst', 'mediawiki', + 'latex', 'latex_raw' and 'latex_booktabs'. Variable `tabulate_formats` + contains the list of currently supported formats. + + "plain" format doesn't use any pseudographics to draw tables, + it separates columns with a double space: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "plain")) + strings numbers + spam 41.9999 + eggs 451 + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="plain")) + spam 41.9999 + eggs 451 + + "simple" format is like Pandoc simple_tables: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "simple")) + strings numbers + --------- --------- + spam 41.9999 + eggs 451 + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="simple")) + ---- -------- + spam 41.9999 + eggs 451 + ---- -------- + + "grid" is similar to tables produced by Emacs table.el package or + Pandoc grid_tables: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "grid")) + +-----------+-----------+ + | strings | numbers | + +===========+===========+ + | spam | 41.9999 | + +-----------+-----------+ + | eggs | 451 | + +-----------+-----------+ + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="grid")) + +------+----------+ + | spam | 41.9999 | + +------+----------+ + | eggs | 451 | + +------+----------+ + + "fancy_grid" draws a grid using box-drawing characters: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "fancy_grid")) + ╒═══════════╤═══════════╕ + │ strings │ numbers │ + ╞═══════════╪═══════════╡ + │ spam │ 41.9999 │ + ├───────────┼───────────┤ + │ eggs │ 451 │ + ╘═══════════╧═══════════╛ + + "pipe" is like tables in PHP Markdown Extra extension or Pandoc + pipe_tables: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "pipe")) + | strings | numbers | + |:----------|----------:| + | spam | 41.9999 | + | eggs | 451 | + + "presto" is like tables produce by the Presto CLI: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "presto")) + strings | numbers + -----------+----------- + spam | 41.9999 + eggs | 451 + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="pipe")) + |:-----|---------:| + | spam | 41.9999 | + | eggs | 451 | + + "orgtbl" is like tables in Emacs org-mode and orgtbl-mode. They + are slightly different from "pipe" format by not using colons to + define column alignment, and using a "+" sign to indicate line + intersections: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "orgtbl")) + | strings | numbers | + |-----------+-----------| + | spam | 41.9999 | + | eggs | 451 | + + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="orgtbl")) + | spam | 41.9999 | + | eggs | 451 | + + "rst" is like a simple table format from reStructuredText; please + note that reStructuredText accepts also "grid" tables: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "rst")) + ========= ========= + strings numbers + ========= ========= + spam 41.9999 + eggs 451 + ========= ========= + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="rst")) + ==== ======== + spam 41.9999 + eggs 451 + ==== ======== + + "mediawiki" produces a table markup used in Wikipedia and on other + MediaWiki-based sites: + + >>> print(tabulate([["strings", "numbers"], ["spam", 41.9999], ["eggs", "451.0"]], + ... headers="firstrow", tablefmt="mediawiki")) + {| class="wikitable" style="text-align: left;" + |+ <!-- caption --> + |- + ! strings !! align="right"| numbers + |- + | spam || align="right"| 41.9999 + |- + | eggs || align="right"| 451 + |} + + "html" produces HTML markup: + + >>> print(tabulate([["strings", "numbers"], ["spam", 41.9999], ["eggs", "451.0"]], + ... headers="firstrow", tablefmt="html")) + <table> + <thead> + <tr><th>strings </th><th style="text-align: right;"> numbers</th></tr> + </thead> + <tbody> + <tr><td>spam </td><td style="text-align: right;"> 41.9999</td></tr> + <tr><td>eggs </td><td style="text-align: right;"> 451 </td></tr> + </tbody> + </table> + + "latex" produces a tabular environment of LaTeX document markup: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="latex")) + \\begin{tabular}{lr} + \\hline + spam & 41.9999 \\\\ + eggs & 451 \\\\ + \\hline + \\end{tabular} + + "latex_raw" is similar to "latex", but doesn't escape special characters, + such as backslash and underscore, so LaTeX commands may embedded into + cells' values: + + >>> print(tabulate([["spam$_9$", 41.9999], ["\\\\emph{eggs}", "451.0"]], tablefmt="latex_raw")) + \\begin{tabular}{lr} + \\hline + spam$_9$ & 41.9999 \\\\ + \\emph{eggs} & 451 \\\\ + \\hline + \\end{tabular} + + "latex_booktabs" produces a tabular environment of LaTeX document markup + using the booktabs.sty package: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="latex_booktabs")) + \\begin{tabular}{lr} + \\toprule + spam & 41.9999 \\\\ + eggs & 451 \\\\ + \\bottomrule + \\end{tabular} + + Number parsing + -------------- + By default, anything which can be parsed as a number is a number. + This ensures numbers represented as strings are aligned properly. + This can lead to weird results for particular strings such as + specific git SHAs e.g. "42992e1" will be parsed into the number + 429920 and aligned as such. + + To completely disable number parsing (and alignment), use + `disable_numparse=True`. For more fine grained control, a list column + indices is used to disable number parsing only on those columns + e.g. `disable_numparse=[0, 2]` would disable number parsing only on the + first and third columns. + """ + if tabular_data is None: + tabular_data = [] + list_of_lists, headers = _normalize_tabular_data( + tabular_data, headers, showindex=showindex) + + fmt = tablefmt + if not isinstance(fmt, TableFormat): + fmt = _table_formats.get(fmt, _table_formats["simple"]) + + # empty values in the first column of RST tables should be escaped (issue #82) + # "" should be escaped as "\\ " or ".." + if tablefmt == 'rst': + list_of_lists, headers = _rst_escape_first_column(list_of_lists, headers) + + # optimization: look for ANSI control codes once, + # enable smart width functions only if a control code is found + plain_text = '\t'.join(['\t'.join(map(_text_type, headers))] + \ + ['\t'.join(map(_text_type, row)) for row in list_of_lists]) + + has_invisible = re.search(_invisible_codes, plain_text) + enable_widechars = wcwidth is not None and WIDE_CHARS_MODE + if tablefmt in multiline_formats and _is_multiline(plain_text): + tablefmt = multiline_formats.get(tablefmt, tablefmt) + is_multiline = True + else: + is_multiline = False + width_fn = _choose_width_fn(has_invisible, enable_widechars, is_multiline) + + # format rows and columns, convert numeric values to strings + cols = list(zip_longest(*list_of_lists)) + numparses = _expand_numparse(disable_numparse, len(cols)) + coltypes = [_column_type(col, numparse=np) for col, np in + zip(cols, numparses)] + if isinstance(floatfmt, str): #old version + float_formats = len(cols) * [floatfmt] # just duplicate the string to use in each column + else: # if floatfmt is list, tuple etc we have one per column + float_formats = list(floatfmt) + if len(float_formats) < len(cols): + float_formats.extend( (len(cols)-len(float_formats)) * [_DEFAULT_FLOATFMT] ) + if isinstance(missingval, str): + missing_vals = len(cols) * [missingval] + else: + missing_vals = list(missingval) + if len(missing_vals) < len(cols): + missing_vals.extend( (len(cols)-len(missing_vals)) * [_DEFAULT_MISSINGVAL] ) + cols = [[_format(v, ct, fl_fmt, miss_v, has_invisible) for v in c] + for c, ct, fl_fmt, miss_v in zip(cols, coltypes, float_formats, missing_vals)] + + # align columns + aligns = [numalign if ct in {int, float} else stralign for ct in coltypes] + if colalign is not None: + assert isinstance(colalign, Iterable) + for idx, align in enumerate(colalign): + aligns[idx] = align + if not headers: + minwidths = [0] * len(cols) + elif fmt.vertical_headers: + minwidths = [1] * len(cols) + else: + minwidths = [width_fn(h) + MIN_PADDING for h in headers] + cols = [_align_column(c, a, minw, has_invisible, enable_widechars, is_multiline) + for c, a, minw in zip(cols, aligns, minwidths)] + + if headers: + # align headers and add headers + t_cols = cols or [['']] * len(headers) + t_aligns = aligns or [stralign] * len(headers) + minwidths = [ + max(minw, max(width_fn(cl) for cl in c)) + for minw, c in zip(minwidths, t_cols)] + if fmt.vertical_headers: + max_len = max(len(x) for x in headers) + headers = [x.rjust(max_len) for x in headers] + headers = [ + [_align_header(h[i], a, minw, width_fn(h[i])) for h, a, minw + in zip(headers, t_aligns, minwidths)] + for i in range(max_len) + ] + else: + headers = [ + _align_header(h, a, minw, width_fn(h), is_multiline, width_fn) + for h, a, minw in zip(headers, t_aligns, minwidths)] + rows = list(zip(*cols)) + else: + minwidths = [max(width_fn(cl) for cl in c) for c in cols] + rows = list(zip(*cols)) + + return _format_table(fmt, headers, rows, minwidths, aligns, is_multiline) + + +def _expand_numparse(disable_numparse, column_count): + """ + Return a list of bools of length `column_count` which indicates whether + number parsing should be used on each column. + If `disable_numparse` is a list of indices, each of those indices are False, + and everything else is True. + If `disable_numparse` is a bool, then the returned list is all the same. + """ + if isinstance(disable_numparse, Iterable): + numparses = [True] * column_count + for index in disable_numparse: + numparses[index] = False + return numparses + else: + return [not disable_numparse] * column_count + + +def _pad_row(cells, padding, squash=False): + if cells: + pad = " " * padding + if squash: + padded_cells = [pad + cell for cell in cells] + else: + padded_cells = [pad + cell + pad for cell in cells] + return padded_cells + else: + return cells + + +def _build_simple_row(padded_cells, rowfmt): + "Format row according to DataRow format without padding." + begin, sep, end = rowfmt + return (begin + sep.join(padded_cells) + end).rstrip() + + +def _build_row(padded_cells, colwidths, colaligns, rowfmt): + "Return a string which represents a row of data cells." + if not rowfmt: + return None + if hasattr(rowfmt, "__call__"): + return rowfmt(padded_cells, colwidths, colaligns) + else: + return _build_simple_row(padded_cells, rowfmt) + + +def _append_basic_row(lines, padded_cells, colwidths, colaligns, rowfmt): + lines.append(_build_row(padded_cells, colwidths, colaligns, rowfmt)) + return lines + + +def _append_multiline_row(lines, padded_multiline_cells, padded_widths, colaligns, rowfmt, pad): + colwidths = [w - 2*pad for w in padded_widths] + cells_lines = [c.splitlines() for c in padded_multiline_cells] + nlines = max(map(len, cells_lines)) # number of lines in the row + # vertically pad cells where some lines are missing + cells_lines = [(cl + [' '*w]*(nlines - len(cl))) for cl, w in zip(cells_lines, colwidths)] + lines_cells = [[cl[i] for cl in cells_lines] for i in range(nlines)] + for ln in lines_cells: + padded_ln = _pad_row(ln, pad) + _append_basic_row(lines, padded_ln, colwidths, colaligns, rowfmt) + return lines + + +def _build_line(colwidths, colaligns, linefmt): + "Return a string which represents a horizontal line." + if not linefmt: + return None + if hasattr(linefmt, "__call__"): + return linefmt(colwidths, colaligns) + else: + begin, fill, sep, end = linefmt + cells = [fill*w for w in colwidths] + return _build_simple_row(cells, (begin, sep, end)) + + +def _append_line(lines, colwidths, colaligns, linefmt): + lines.append(_build_line(colwidths, colaligns, linefmt)) + return lines + + +def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline): + """Produce a plain-text representation of the table.""" + lines = [] + hidden = fmt.with_header_hide if (headers and fmt.with_header_hide) else [] + pad = fmt.padding + headerrow = fmt.headerrow + + if fmt.vertical_headers: + colwidths[0] += 1 + padded_widths = colwidths + else: + padded_widths = [(w + 2*pad) for w in colwidths] + + if is_multiline: + pad_row = lambda row, _: row # do it later, in _append_multiline_row + append_row = partial(_append_multiline_row, pad=pad) + else: + pad_row = _pad_row + append_row = _append_basic_row + + if fmt.vertical_headers: + padded_headers = [pad_row(line, pad, squash=True) for line in headers] + else: + padded_headers = pad_row(headers, pad) + padded_rows = [pad_row(row, pad, squash=fmt.vertical_headers) for row in rows] + + if fmt.lineabove and "lineabove" not in hidden: + _append_line(lines, padded_widths, colaligns, fmt.lineabove) + + if padded_headers: + if fmt.vertical_headers: + for line in padded_headers: + append_row(lines, line, padded_widths, colaligns, headerrow) + else: + append_row(lines, padded_headers, padded_widths, colaligns, headerrow) + if fmt.linebelowheader and "linebelowheader" not in hidden: + _append_line(lines, padded_widths, colaligns, fmt.linebelowheader) + + if padded_rows and fmt.linebetweenrows and "linebetweenrows" not in hidden: + # initial rows with a line below + for row in padded_rows[:-1]: + append_row(lines, row, padded_widths, colaligns, fmt.datarow) + _append_line(lines, padded_widths, colaligns, fmt.linebetweenrows) + # the last row without a line below + append_row(lines, padded_rows[-1], padded_widths, colaligns, fmt.datarow) + else: + for row in padded_rows: + append_row(lines, row, padded_widths, colaligns, fmt.datarow) + + if fmt.linebelow and "linebelow" not in hidden: + _append_line(lines, padded_widths, colaligns, fmt.linebelow) + + if headers or rows: + return "\n".join(lines) + else: # a completely empty table + return "" diff --git a/src/pkgdev/scripts/pkgdev_showkw.py b/src/pkgdev/scripts/pkgdev_showkw.py new file mode 100644 index 0000000..1b5ab8e --- /dev/null +++ b/src/pkgdev/scripts/pkgdev_showkw.py @@ -0,0 +1,255 @@ +"""display package keywords""" + +import os +from functools import partial + +from pkgcore.ebuild import restricts +from pkgcore.util import commandline +from pkgcore.util import packages as pkgutils +from snakeoil.cli import arghparse +from snakeoil.strings import pluralism + +from .._vendor.tabulate import tabulate, tabulate_formats + + +showkw = arghparse.ArgumentParser( + prog='pkgdev showkw', description='show package keywords') +showkw.add_argument( + 'targets', metavar='target', nargs='*', + action=commandline.StoreTarget, + help='extended atom matching of packages') + +output_opts = showkw.add_argument_group('output options') +output_opts.add_argument( + '-f', '--format', default='showkw', metavar='FORMAT', + choices=tabulate_formats, + help='keywords table format', + docs=f""" + Output table using specified tabular format (defaults to compressed, + custom format). + + Available formats: {', '.join(tabulate_formats)} + """) +output_opts.add_argument( + '-c', '--collapse', action='store_true', + help='show collapsed list of arches') + +arch_options = showkw.add_argument_group('arch options') +arch_options.add_argument( + '-s', '--stable', action='store_true', + help='show stable arches') +arch_options.add_argument( + '-u', '--unstable', action='store_true', + help='show unstable arches') +arch_options.add_argument( + '-o', '--only-unstable', action='store_true', + help='show arches that only have unstable keywords') +arch_options.add_argument( + '-p', '--prefix', action='store_true', + help='show prefix and non-native arches') +arch_options.add_argument( + '-a', '--arch', action='csv_negations', + help='select arches to display') + +# TODO: allow multi-repo comma-separated input +target_opts = showkw.add_argument_group('target options') +target_opts.add_argument( + '-r', '--repo', dest='selected_repo', metavar='REPO', priority=29, + action=commandline.StoreRepoObject, + repo_type='all-raw', allow_external_repos=True, + help='repo to query (defaults to all ebuild repos)') +@showkw.bind_delayed_default(30, 'repos') +def _setup_repos(namespace, attr): + target_repo = namespace.selected_repo + all_ebuild_repos = namespace.domain.all_ebuild_repos_raw + namespace.cwd = os.getcwd() + + # TODO: move this to StoreRepoObject + if target_repo is None: + # determine target repo from the target directory + for repo in all_ebuild_repos.trees: + if namespace.cwd in repo: + target_repo = repo + break + else: + # determine if CWD is inside an unconfigured repo + target_repo = namespace.domain.find_repo( + namespace.cwd, config=namespace.config) + + # fallback to using all, unfiltered ebuild repos if no target repo can be found + namespace.repo = target_repo if target_repo is not None else all_ebuild_repos + + +@showkw.bind_delayed_default(40, 'arches') +def _setup_arches(namespace, attr): + default_repo = namespace.config.get_default('repo') + + try: + known_arches = {arch for r in namespace.repo.trees + for arch in r.config.known_arches} + except AttributeError: + try: + # binary/vdb repos use known arches from the default repo + known_arches = default_repo.config.known_arches + except AttributeError: + # TODO: remove fallback for tests after fixing default repo pull + # from faked config + known_arches = set() + + arches = known_arches + if namespace.arch is not None: + disabled_arches, enabled_arches = namespace.arch + disabled_arches = set(disabled_arches) + enabled_arches = set(enabled_arches) + unknown_arches = disabled_arches.difference(known_arches) | enabled_arches.difference(known_arches) + if unknown_arches: + unknown = ', '.join(map(repr, sorted(unknown_arches))) + known = ', '.join(sorted(known_arches)) + es = pluralism(unknown_arches, plural='es') + showkw.error(f'unknown arch{es}: {unknown} (choices: {known})') + if enabled_arches: + arches = arches.intersection(enabled_arches) + if disabled_arches: + arches = arches - disabled_arches + + prefix_arches = set(x for x in arches if '-' in x) + native_arches = arches.difference(prefix_arches) + arches = native_arches + if namespace.prefix: + arches = arches.union(prefix_arches) + if namespace.stable: + try: + stable_arches = {arch for r in namespace.repo.trees + for arch in r.config.profiles.arches('stable')} + except AttributeError: + # binary/vdb repos use stable arches from the default repo + stable_arches = default_repo.config.profiles.arches('stable') + arches = arches.intersection(stable_arches) + + namespace.known_arches = known_arches + namespace.prefix_arches = prefix_arches + namespace.native_arches = native_arches + namespace.arches = arches + + +def _colormap(colors, line): + if colors is None: + return line + return colors[line] + line + colors['reset'] + + +@showkw.bind_final_check +def _validate_args(parser, namespace): + namespace.pkg_dir = False + + # disable colors when not using the native output format + if namespace.format != 'showkw': + namespace.color = False + + if namespace.color: + # default colors to use for keyword types + _COLORS = { + '+': '\u001b[32m', + '~': '\u001b[33m', + '-': '\u001b[31m', + '*': '\u001b[31m', + 'o': '\u001b[30;1m', + 'reset': '\u001b[0m', + } + else: + _COLORS = None + namespace.colormap = partial(_colormap, _COLORS) + + if not namespace.targets: + if namespace.selected_repo: + # use repo restriction since no targets specified + restriction = restricts.RepositoryDep(namespace.selected_repo.repo_id) + token = namespace.selected_repo.repo_id + else: + # Use a path restriction if we're in a repo, obviously it'll work + # faster if we're in an invididual ebuild dir but we're not that + # restrictive. + try: + restriction = namespace.repo.path_restrict(namespace.cwd) + token = namespace.cwd + except (AttributeError, ValueError): + parser.error('missing target argument and not in a supported repo') + + # determine if we're grabbing the keywords for a single pkg in cwd + namespace.pkg_dir = any( + isinstance(x, restricts.PackageDep) + for x in reversed(restriction.restrictions)) + + namespace.targets = [(token, restriction)] + + +def _collapse_arches(options, pkgs): + """Collapse arches into a single set.""" + keywords = set() + stable_keywords = set() + unstable_keywords = set() + for pkg in pkgs: + for x in pkg.keywords: + if x[0] == '~': + unstable_keywords.add(x[1:]) + elif x in options.arches: + stable_keywords.add(x) + if options.unstable: + keywords.update(unstable_keywords) + if options.only_unstable: + keywords.update(unstable_keywords.difference(stable_keywords)) + if not keywords or options.stable: + keywords.update(stable_keywords) + return ( + sorted(keywords.intersection(options.native_arches)) + + sorted(keywords.intersection(options.prefix_arches))) + + +def _render_rows(options, pkgs, arches): + """Build rows for tabular data output.""" + for pkg in sorted(pkgs): + keywords = set(pkg.keywords) + row = [pkg.fullver] + for arch in arches: + if arch in keywords: + line = '+' + elif f'~{arch}' in keywords: + line = '~' + elif f'-{arch}' in keywords: + line = '-' + elif '-*' in keywords: + line = '*' + else: + line = 'o' + row.append(options.colormap(line)) + row.extend([pkg.eapi, pkg.fullslot, pkg.repo.repo_id]) + yield row + + +@showkw.bind_main_func +def main(options, out, err): + continued = False + for token, restriction in options.targets: + for pkgs in pkgutils.groupby_pkg(options.repo.itermatch(restriction, sorter=sorted)): + if options.collapse: + out.write(' '.join(_collapse_arches(options, pkgs))) + else: + arches = sorted(options.arches.intersection(options.native_arches)) + if options.prefix: + arches += sorted(options.arches.intersection(options.prefix_arches)) + headers = [''] + arches + ['eapi', 'slot', 'repo'] + if continued: + out.write() + if not options.pkg_dir: + pkgs = list(pkgs) + out.write(f'keywords for {pkgs[0].unversioned_atom}:') + data = _render_rows(options, pkgs, arches) + table = tabulate( + data, headers=headers, tablefmt=options.format, + disable_numparse=True) + out.write(table) + continued = True + + if not continued: + err.write(f"{options.prog}: no matches for {token!r}") + return 1 diff --git a/tests/scripts/test_pkgdev_showkw.py b/tests/scripts/test_pkgdev_showkw.py new file mode 100644 index 0000000..635cb69 --- /dev/null +++ b/tests/scripts/test_pkgdev_showkw.py @@ -0,0 +1,19 @@ +import pytest + + +class TestPkgdevShowkwParseArgs: + + def test_missing_target(self, capsys, tool): + with pytest.raises(SystemExit): + tool.parse_args(['showkw']) + captured = capsys.readouterr() + assert captured.err.strip() == ( + 'pkgdev showkw: error: missing target argument and not in a supported repo') + + def test_unknown_arches(self, capsys, tool, make_repo): + repo = make_repo(arches=['amd64']) + with pytest.raises(SystemExit): + tool.parse_args(['showkw', '-a', 'unknown', '-r', repo.location]) + captured = capsys.readouterr() + assert captured.err.strip() == ( + "pkgdev showkw: error: unknown arch: 'unknown' (choices: amd64)") |