Source code for annie.dataset.manipulate

"""Browse 'Manipulate' transforms — derive a display/filter value per label column.

A label column carries raw CSV text plus a **data type** (``str`` / ``int`` /
``float``, chosen when the CSV is added). The Browse *Manipulate* block then applies
one **transform** per column, replacing its raw value for tags and filtering:

* text → ``trim`` (strip surrounding whitespace);
* numeric → ``round`` (to the nearest integer, e.g. sentiment ``1.0`` → ``1``),
  ``threshold`` (``≥X`` / ``<X`` buckets), or ``sign`` (``negative`` / ``zero`` /
  ``positive``).

The functions are pure and unit-tested; the UI only wires them up.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal

if TYPE_CHECKING:
    from collections.abc import Iterable

ColumnType = Literal["str", "int", "float"]
"""A column's data type."""
TransformKind = Literal["none", "trim", "round", "threshold", "sign"]
"""A transform a column can carry."""

#: Transforms offered for text columns (no numeric operations apply).
_TEXT_TRANSFORMS: tuple[TransformKind, ...] = ("none", "trim")
#: Transforms offered for numeric columns.
_NUMERIC_TRANSFORMS: tuple[TransformKind, ...] = ("none", "round", "threshold", "sign")

TRANSFORM_LABELS: dict[TransformKind, str] = {
    "none": "none",
    "trim": "trim spaces",
    "round": "round to X digits",
    "threshold": "threshold ≥ X",
    "sign": "sign (−/0/+)",
}
"""Short human labels for the transform dropdown."""


[docs] @dataclass(slots=True) class Transform: """One column's transform. Attributes: kind: The transform to apply. threshold: The cut for the ``threshold`` transform. digits: Decimal places for the ``round`` transform (default 2). show_original: Display the *untransformed* value on the sample rows while the filter still matches on the transformed one. This separates the two jobs a transform does: ``threshold ≥ 0`` on a ``[-3, 3]`` sentiment makes a clean two-way filter facet, but the reviewer usually still wants to read the real ``1.83`` on the row. Purely a display flag — filtering never consults it. """ kind: TransformKind = "none" threshold: float = 1.0 digits: int = 2 show_original: bool = False
[docs] def transforms_for(col_type: ColumnType) -> tuple[TransformKind, ...]: """Return the transforms valid for a column type.""" return _TEXT_TRANSFORMS if col_type == "str" else _NUMERIC_TRANSFORMS
def _is_int(text: str) -> bool: try: int(text) except ValueError: return False return True def _is_float(text: str) -> bool: try: float(text) except ValueError: return False return True
[docs] def detect_type(values: Iterable[str]) -> ColumnType: """Guess a column's type from a sample of its (string) values. Args: values: Sample cell values. Returns: ``"int"`` if every non-empty value is an integer, ``"float"`` if every value is numeric, else ``"str"``. An all-empty/absent column defaults to ``"str"``. """ seen = [v.strip() for v in values if v and v.strip()] if not seen: return "str" if all(_is_int(v) for v in seen): return "int" if all(_is_float(v) for v in seen): return "float" return "str"
def _fmt_threshold(value: float) -> str: """Format a threshold without trailing zeros (``1.0`` → ``1``).""" return f"{value:g}"
[docs] def apply_transform(value: str, col_type: ColumnType, transform: Transform) -> str: """Apply a column's transform to one raw value, returning its display value. A value that cannot be parsed as a number (in a numeric column) is returned unchanged, so a stray non-numeric cell never breaks the row. Args: value: The raw cell text. col_type: The column's data type. transform: The transform to apply. Returns: The transformed string. """ if col_type == "str": return value.strip() if transform.kind == "trim" else value if transform.kind == "none": return value try: number = float(value) except ValueError: return value if transform.kind == "round": d = max(0, int(transform.digits)) return str(round(number, d)) if d > 0 else str(int(round(number))) if transform.kind == "threshold": cut = _fmt_threshold(transform.threshold) return f"≥{cut}" if number >= transform.threshold else f"<{cut}" if transform.kind == "sign": if number > 0: return "positive" if number < 0: return "negative" return "zero" return value