"""Format sugar parsing, column-statistics inference, and application.
The resolution ladder (user > semantic tag > dtype) produces
:class:`~greater_tables.ir.FormatSpec` *data objects* — never closures, so
the greater_tables late-binding formatter bug cannot recur. Application is a
pure function of (value, spec).
Sugar accepted, both prior vocabularies:
- csv-grid spec strings: ``','``? ``'.N'``? then one of ``f d % e g s``,
plus ``'year'``, ``'eng'``, ``'si'``, and strftime patterns for dates.
- GT f-strings: ``'{x:,.1f}'``, ``'{x:.1%}'`` — the python format mini-spec.
- An int means that many decimal digits.
``g`` is the one kind whose ``digits`` counts **significant** figures rather
than decimal places, because that is what python's ``g`` presentation type
means; ``'.3g'`` renders 1234.5 as ``1.23e+03`` and 0.0012345 as
``0.00123``. Uppercase ``G``/``E`` are still unparsed — see ``dev/pending.md``.
"""
from __future__ import annotations
import math
import re
from collections.abc import Mapping
import pandas as pd
from ..ir import FormatSpec
__all__ = ['apply_format', 'infer_numeric_format', 'parse_sugar']
_CG_RE = re.compile(r'^(?P<group>,)?(?:\.(?P<digits>\d+))?(?P<kind>[fd%esg])$')
_FSTRING_RE = re.compile(r'^\{x:(?P<spec>[^}]*)\}$')
_PYSPEC_RE = re.compile(r'^(?P<group>,)?(?:\.(?P<digits>\d+))?(?P<kind>[fde%g])?$')
_KIND_MAP = {'f': 'dec', 'd': 'int', '%': 'pct', 'e': 'sci', 'g': 'gen',
's': 'str'}
[docs]
def parse_sugar(value: FormatSpec | Mapping | str | int) -> FormatSpec:
"""Normalize a user format override to a FormatSpec.
Accepts a FormatSpec, a mapping of its fields (the YAML form — the only
way a config file can reach ``negative='paren'``, ``scale``, ``prefix``,
``suffix``, ``null`` or ``zero``), a csv-grid spec string (``',.1f'``,
``'.3g'``), a GT f-string (``'{x:.1%}'``, ``'{x:.3g}'``), a strftime
pattern (``'%d %b %Y'``), the words ``'year'``/``'eng'``/``'si'``, or an
int (decimal digits, grouped). ``g`` maps to ``kind='gen'``, where
``digits`` means significant figures.
Raises
------
ValueError
If the sugar string is not recognized.
pydantic.ValidationError
If a mapping names a field FormatSpec does not have.
"""
if isinstance(value, FormatSpec):
return value
if isinstance(value, Mapping):
return FormatSpec(**value)
if isinstance(value, int):
return FormatSpec(kind='dec', digits=value, group=True)
if value in ('year', 'eng', 'si'):
return FormatSpec(kind=value)
if '%' in value and not _CG_RE.match(value) and '{' not in value:
# strftime pattern, e.g. '%Y-%m-%d' or '%d %b %Y'
return FormatSpec(kind='date', pattern=value)
m = _CG_RE.match(value)
if m is None:
f = _FSTRING_RE.match(value)
if f is not None:
m = _PYSPEC_RE.match(f.group('spec'))
if m is None:
raise ValueError(f'unrecognized format sugar: {value!r}')
kind = _KIND_MAP.get(m.group('kind') or 'f', 'dec')
digits = int(m.group('digits')) if m.group('digits') else None
if kind == 'str':
return FormatSpec(kind='str')
return FormatSpec(kind=kind, digits=digits, group=bool(m.group('group')))
# defaults when FormatSpec.digits is None, by kind. 'gen' takes python's own
# default precision for g (6), and counts significant figures, not decimals.
_DEFAULT_DIGITS = {'dec': 2, 'pct': 1, 'sci': 2, 'gen': 6, 'eng': 3, 'si': 3}
def _exp3(v: float) -> int:
"""Exponent snapped down to a multiple of 3."""
e = math.floor(math.log10(abs(v)))
return 3 * math.floor(e / 3)
def _eng(v: float, digits: int) -> str:
"""Engineering notation: mantissa in [1, 1000), exponent multiple of 3.
Exponents are fixed-width ``e+00``/``e-00`` (sign always, two digits) so
right-aligned columns line up exactly.
"""
if v == 0:
return f'{0:.{digits}f}e+00'
e3 = _exp3(v)
return f'{v / 10.0 ** e3:.{digits}f}e{"+" if e3 >= 0 else "-"}{abs(e3):02d}'
# full SI prefix ladder (2022 additions included) so real columns never mix
# suffix and exponent styles mid-column
_SI_SUFFIX = {-30: 'q', -27: 'r', -24: 'y', -21: 'z', -18: 'a', -15: 'f',
-12: 'p', -9: 'n', -6: 'µ', -3: 'm', 0: '', 3: 'k', 6: 'M',
9: 'G', 12: 'T', 15: 'P', 18: 'E', 21: 'Z', 24: 'Y', 27: 'R',
30: 'Q'}
def _si(v: float, digits: int) -> str:
"""SI-suffix notation (1.200M style) — the default engineering display.
The empty suffix slot (exponent 0) is a no-break space so decimal points
stay aligned against suffixed neighbours in tabular-nums/monospace
rendering. Falls back to :func:`_eng` beyond ±10^30.
"""
if v == 0:
return f'{0:.{digits}f}{chr(0xa0)}'
e3 = _exp3(v)
if e3 not in _SI_SUFFIX:
return _eng(v, digits)
return f'{v / 10.0 ** e3:.{digits}f}{_SI_SUFFIX[e3] or chr(0xa0)}'