"""Column dtype discovery, semantic tags, alignment, and wrap hints.
Arrow-aware via ``pandas.api.types`` predicates — dtypes are trusted, never
coerced (the greater_tables float-coercion loop does not come along).
"""
from __future__ import annotations
import re
from typing import Any, Hashable
import pandas as pd
from pandas.api.types import (
is_bool_dtype, is_datetime64_any_dtype, is_float_dtype, is_integer_dtype,
is_string_dtype,
)
from ..ir import Alignment, Dtype, Tag
from ..spec import ColSelector, TableSpec
__all__ = ['alignment', 'column_dtype', 'resolve_cols', 'semantic_tag', 'wrap_hint']
[docs]
def column_dtype(ser: pd.Series) -> Dtype:
"""Classify a series into an IR dtype.
Datetime columns whose values all sit at midnight classify as ``date``;
any time-of-day component makes them ``datetime``.
"""
dtype = ser.dtype
if is_datetime64_any_dtype(dtype):
valid = ser.dropna()
if len(valid) and (valid.dt.normalize() == valid).all():
return 'date'
return 'datetime'
if is_bool_dtype(dtype):
return 'bool'
if is_integer_dtype(dtype):
return 'int'
if is_float_dtype(dtype):
return 'float'
if is_string_dtype(dtype):
return 'string'
return 'object'
[docs]
def resolve_cols(selector: ColSelector, columns: list[Hashable]) -> set[Hashable]:
"""Resolve a column selector to a set of labels from ``columns``.
A list matches labels exactly (and leaf names for tuple labels);
``'all'`` matches everything; any other string is a regex searched
against the leaf name and the full label.
"""
if selector is None:
return set()
if isinstance(selector, str):
if selector == 'all':
return set(columns)
pattern = re.compile(selector)
return {c for c in columns
if pattern.search(str(c[-1] if isinstance(c, tuple) else c))
or pattern.search(str(c))}
wanted = set(selector)
out = set()
for c in columns:
if c in wanted or (isinstance(c, tuple) and c[-1] in wanted):
out.add(c)
return out
[docs]
def semantic_tag(col: Hashable, tags: dict[str, set[Hashable]]) -> Tag | None:
"""Return the winning semantic tag for a column (GT ladder order)."""
for name in ('ratio', 'year', 'date', 'raw'):
if col in tags[name]:
return name # type: ignore[return-value]
return None
[docs]
def alignment(col: Hashable, *, is_stub: bool, dtype: Dtype, tag: Tag | None,
aligners: dict[Hashable, str]) -> Alignment:
"""Alignment ladder: user > stub-left > year-center > raw-left >
date-center > numeric-right > left."""
if col in aligners and aligners[col] in ('l', 'r', 'c'):
return aligners[col] # type: ignore[return-value]
if is_stub and tag is None:
return 'l'
if tag == 'year':
return 'c'
if tag == 'raw':
return 'l'
if tag == 'date' or dtype in ('date', 'datetime'):
return 'c'
if tag == 'ratio' or dtype in ('int', 'float'):
return 'r'
return 'l'
[docs]
def wrap_hint(dtype: Dtype, tag: Tag | None, col: Hashable,
unbreakable: set[Hashable]) -> bool:
"""Prose (string/object, untagged) columns may wrap; everything else not."""
if col in unbreakable:
return False
return dtype in ('string', 'object') and tag is None
def build_aligner_map(spec: TableSpec, stub_names: list[Hashable],
data_cols: list[Hashable]) -> dict[Hashable, str]:
"""Normalize spec.aligners to a label → 'l'/'r'/'c' map.
A positional 'lrc' string covers stub columns first, then data columns
(matching the visual order).
"""
if spec.aligners is None:
return {}
if isinstance(spec.aligners, str):
ordered: list[Any] = list(stub_names) + list(data_cols)
return dict(zip(ordered, spec.aligners))
return dict(spec.aligners)