"""Pure-Python GitHub-flavored Markdown table renderer.
This module reproduces, byte-for-byte, the output of
``pandas.DataFrame.to_markdown(tablefmt="github", index=<bool>)`` for the slice
of behavior CMX relies on -- WITHOUT importing ``tabulate`` at runtime.
It is a faithful, narrow re-implementation of tabulate's GitHub format. The
algorithm mirrors tabulate's internals:
* Per-column type detection (least-generic type over the column's values).
``bool``/``str``/``bytes``/datetime columns are STRING (left-aligned); ``int``
and ``float`` columns are NUMERIC (decimal-point aligned, which collapses to
right-alignment).
* Number formatting matches tabulate defaults: ints via ``str``/``format("")``;
floats via the ``g`` format. Missing values (None / NaN) render as ``""``.
* Width = ``max(len(header) + MIN_PADDING, max cell width)``; cells get one
space of padding on each side; the separator row is plain dashes (no colons).
* ``index=True`` prepends the DataFrame index as a leading column (with the
index name -- or an empty string -- as its header).
Only ``pandas``/``numpy`` are used, and only to introspect the frame's values;
they are already dependencies of the tables feature.
The reference for this implementation is tabulate 0.x's ``__init__.py``
(functions ``_type``, ``_column_type``, ``_format``, ``_align_column``,
``_align_header``, ``_format_table`` and the ``"github"`` ``TableFormat``).
"""
import math
from functools import reduce
# tabulate's MIN_PADDING constant: every column's minimum width is its header
# width plus this, before being expanded to fit the body cells.
MIN_PADDING = 2
# Numeric (right/decimal aligned) column types.
_NUMERIC_TYPES = (int, float)
def _is_missing(val):
"""True only for the values tabulate treats as missing (-> empty cell).
NOTE: tabulate considers ONLY ``None`` and the empty string/bytes missing.
A bare ``float('nan')`` (how pandas stores NaN in a numeric column) is NOT
missing -- it is a numeric value that formats as the literal ``"nan"``.
Only the Python ``None`` object (e.g. an object column with ``None``) yields
an empty cell.
"""
if val is None:
return True
if isinstance(val, (bytes, str)) and not val:
return True
return False
def _is_bool(val):
return type(val) is bool or (isinstance(val, (bytes, str)) and val in ("True", "False"))
def _is_int(val):
if type(val) is int:
return True
# numpy integer scalars (numpy.int64 etc.)
if (hasattr(val, "is_integer") or hasattr(val, "__array__")) and str(type(val)).startswith("<class 'numpy.int"):
return True
if isinstance(val, (bytes, str)):
try:
int(val)
return True
except (ValueError, TypeError):
return False
return False
def _is_number(val):
"""Mirror tabulate._isnumber: could this be considered numeric?"""
if type(val) in (float, int):
return True
try:
f = float(val)
except (ValueError, TypeError):
return False
if not isinstance(val, (str, bytes)):
return True
# numeric string: exclude over/underflow to +/-inf unless literally inf/nan.
return (not (math.isinf(f) or math.isnan(f))) or val.lower() in ("inf", "-inf", "nan")
def _type(val):
"""Least-generic scalar type of a value (mirrors tabulate._type).
Returns one of: ``type(None)``, ``bool``, ``int``, ``float``, ``bytes``,
``str``. Empty / missing values are treated as ``type(None)`` so they do not
influence a column's deduced type.
"""
if _is_missing(val):
return type(None)
if isinstance(val, (bytes, str)) and not val:
return type(None)
if hasattr(val, "isoformat"): # datetime / date / time -> string column
return str
if _is_bool(val):
return bool
if _is_int(val):
return int
if _is_number(val):
return float
if isinstance(val, bytes):
return bytes
return str
# Generality ordering used to reduce a column's per-value types to one type.
_TYPE_RANK = {type(None): 0, bool: 1, int: 2, float: 3, bytes: 4, str: 5}
_RANK_TYPE = {v: k for k, v in _TYPE_RANK.items()}
def _more_generic(type1, type2):
return _RANK_TYPE[max(_TYPE_RANK.get(type1, 5), _TYPE_RANK.get(type2, 5))]
def _column_type(values):
"""Least-generic type all of a column's values are convertible to."""
return reduce(_more_generic, (_type(v) for v in values), bool)
def _format_value(val, coltype, missingval=""):
"""Format a single value as tabulate would (defaults: floatfmt='g')."""
if val is None:
return missingval
if isinstance(val, (bytes, str)) and not val:
return ""
if coltype is str:
return f"{val}"
if coltype is int:
return format(val, "")
if coltype is bytes:
try:
return str(val, "ascii")
except (TypeError, UnicodeDecodeError):
return str(val)
if coltype is float:
if isinstance(val, str) and "," in val:
val = val.replace(",", "")
try:
return format(float(val), "g")
except (ValueError, TypeError):
return f"{val}"
return f"{val}"
def _afterpoint(string):
"""Count of symbols after the decimal point, -1 if there is none.
Mirrors tabulate._afterpoint, used for decimal alignment of numeric columns.
"""
if _is_number(string):
if _is_int(string):
return -1
pos = string.rfind(".")
if pos < 0:
pos = string.lower().rfind("e")
if pos >= 0:
return len(string) - pos - 1
return -1
return -1
def _align_column(strings, alignment, minwidth):
"""Pad a column of pre-formatted strings to a common width.
``alignment`` is ``"left"`` for string columns or ``"decimal"`` for numeric
columns. Returns the list of padded cell strings.
"""
if alignment == "decimal":
decimals = [_afterpoint(s) for s in strings]
maxdecimals = max(decimals)
strings = [s + (maxdecimals - d) * " " for s, d in zip(strings, decimals)]
# decimal alignment then flushes right.
maxwidth = max(max((len(s) for s in strings), default=0), minwidth)
return [s.rjust(maxwidth) for s in strings]
# "left": strip then flush left.
strings = [s.strip() for s in strings]
maxwidth = max(max((len(s) for s in strings), default=0), minwidth)
return [s.ljust(maxwidth) for s in strings]
def _align_header(header, alignment, width):
if alignment == "left":
return header.ljust(width)
# numeric ("decimal") headers flush right.
return header.rjust(width)
def _extract_columns(df, index):
"""Return ``(headers, columns)`` from a DataFrame.
``columns`` is a list of per-column value lists (row-major transposed).
When ``index`` is true the frame's index is prepended as a leading column,
with the index name (or ``""``) as its header. Mirrors the DataFrame branch
of tabulate._normalize_tabular_data.
"""
headers = [str(k) for k in df.columns]
columns = [list(df[col].values) for col in df.columns]
if index:
idx = df.index
idx_name = idx.name
if idx_name is not None:
headers = [str(idx_name)] + headers
else:
headers = [""] + headers
columns = [list(idx)] + columns
return headers, columns
[docs]
def render(df, index=False):
"""Render a pandas DataFrame as a GitHub-flavored Markdown table string.
Reproduces ``df.to_markdown(tablefmt="github", index=index)`` exactly
(without any trailing newline). Only pandas/numpy are used to read the
frame; ``tabulate`` is never imported.
"""
headers, columns = _extract_columns(df, index)
coltypes = [_column_type(col) for col in columns]
# Numeric columns align on the decimal point; others flush left.
aligns = ["decimal" if ct in _NUMERIC_TYPES else "left" for ct in coltypes]
# Format every cell to its string form.
formatted = [[_format_value(v, ct) for v in col] for col, ct in zip(columns, coltypes)]
# Minimum width per column: header width + MIN_PADDING (tabulate behavior).
minwidths = [len(h) + MIN_PADDING for h in headers]
# Pad body cells within each column to its aligned width.
aligned_cols = [_align_column(col, a, mw) for col, a, mw in zip(formatted, aligns, minwidths)]
# Final column content width = max(header+MIN_PADDING, widest body cell).
colwidths = [
max(mw, max((len(cell) for cell in col), default=0))
for mw, col in zip(minwidths, aligned_cols)
]
# Align headers to the final width.
aligned_headers = [_align_header(h, a, w) for h, a, w in zip(headers, aligns, colwidths)]
pad = 1 # github padding
padded_widths = [w + 2 * pad for w in colwidths]
def build_row(cells):
padded = [(" " * pad) + c + (" " * pad) for c in cells]
return ("|" + "|".join(padded) + "|").rstrip()
def build_sep():
cells = ["-" * w for w in padded_widths]
return ("|" + "|".join(cells) + "|").rstrip()
lines = [build_row(aligned_headers), build_sep()]
# Re-align padded body cells back to colwidth (already are) and emit rows.
rows = list(zip(*aligned_cols)) if aligned_cols else []
for row in rows:
lines.append(build_row(list(row)))
return "\n".join(lines)