tytable 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tytable/_format.py ADDED
@@ -0,0 +1,282 @@
1
+ """
2
+ Value formatting: digits, significant figures, replace, escape, and fn transforms.
3
+
4
+ Applied during the render pipeline by :func:`tytable._resolve.build`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from ._escape import escape_typst
13
+ from ._indices import resolve_i, resolve_j
14
+
15
+ if TYPE_CHECKING:
16
+ from ._directives import FormatDirective
17
+ from ._tytable import TinyTable
18
+
19
+ Cell = tuple[int, int]
20
+
21
+
22
+ def _is_numeric_typed(val: object) -> bool:
23
+ """True for ints/floats but not bools (which are technically int subclasses)."""
24
+ if isinstance(val, bool):
25
+ return False
26
+ return isinstance(val, (int, float))
27
+
28
+
29
+ def _fmt_numeric_decimal(val: Any, digits: int) -> str:
30
+ """Format a number with a fixed number of decimal places."""
31
+ return f"{float(val):.{digits}f}"
32
+
33
+
34
+ def _fmt_numeric_significant(val: Any, digits: int) -> str:
35
+ """Format a number to a given number of significant figures."""
36
+ return f"{float(val):.{digits}g}"
37
+
38
+
39
+ def _matches(o: object, typed: object, s: str) -> bool:
40
+ """Check whether a ``replace`` key matches a typed value or its string form (handles null/nan/inf)."""
41
+ if o is None:
42
+ return typed is None
43
+ if isinstance(o, float) and math.isnan(o):
44
+ return isinstance(typed, float) and math.isnan(typed)
45
+ if isinstance(o, float) and math.isinf(o):
46
+ return isinstance(typed, float) and math.isinf(typed) and (typed > 0) == (o > 0)
47
+ if isinstance(o, str):
48
+ lo = o.lower()
49
+ if lo == "null":
50
+ return typed is None
51
+ if lo == "nan":
52
+ return isinstance(typed, float) and math.isnan(typed)
53
+ if lo == "inf":
54
+ return isinstance(typed, float) and math.isinf(typed) and typed > 0
55
+ if lo == "-inf":
56
+ return isinstance(typed, float) and math.isinf(typed) and typed < 0
57
+ return typed == o or s == str(o)
58
+
59
+
60
+ def _apply_replace(typed_val: object, current_str: str, replace: object) -> str:
61
+ """Apply a ``replace`` spec (``True``, a fill string, or an ``{old: new}`` dict) to one cell."""
62
+ if replace is True:
63
+ if typed_val is None or (isinstance(typed_val, float) and math.isnan(typed_val)):
64
+ return " "
65
+ return current_str
66
+ if isinstance(replace, str):
67
+ if typed_val is None or (isinstance(typed_val, float) and math.isnan(typed_val)):
68
+ return replace
69
+ return current_str
70
+ if isinstance(replace, dict):
71
+ mapping: dict = {}
72
+ for k, v in replace.items():
73
+ if isinstance(k, list):
74
+ for item in k:
75
+ mapping[item] = v
76
+ else:
77
+ mapping[k] = v
78
+ for old, new in mapping.items():
79
+ if _matches(old, typed_val, current_str):
80
+ return str(new)
81
+ return current_str
82
+
83
+
84
+ def _apply_escape(current_str: str, escape_spec: object, output: str) -> str:
85
+ """Re-escape a cell string for the target backend when ``fmt(escape=...)`` is set."""
86
+ if escape_spec is True or escape_spec == "typst":
87
+ if output in ("html", "ascii"):
88
+ from ._escape import escape_html
89
+
90
+ return escape_html(current_str)
91
+ return escape_typst(current_str)
92
+ return current_str
93
+
94
+
95
+ def _resolve_target_cells(
96
+ i_vals: list[int],
97
+ j_vals: list[int],
98
+ data_body: list[list[str]],
99
+ colnames_display: list[str],
100
+ ) -> list[Cell]:
101
+ """Translate resolved table coordinates into body/header cell coordinates."""
102
+ target_cells: list[Cell] = []
103
+ for i_idx in i_vals:
104
+ if i_idx == 0:
105
+ target_cells.extend(
106
+ (-1, j_idx - 1) for j_idx in j_vals if 0 < j_idx <= len(colnames_display)
107
+ )
108
+ continue
109
+ row_idx = i_idx - 1
110
+ if row_idx < 0 or row_idx >= len(data_body):
111
+ continue
112
+ target_cells.extend(
113
+ (row_idx, j_idx - 1) for j_idx in j_vals if 0 < j_idx <= len(data_body[row_idx])
114
+ )
115
+ return target_cells
116
+
117
+
118
+ def _typed_value(cell: Cell, typed_body: list[list[Any]], colnames: list[str]) -> object | None:
119
+ """Return the original typed value for a body or header cell."""
120
+ row_idx, col_idx = cell
121
+ if row_idx == -1:
122
+ return colnames[col_idx]
123
+ if row_idx < len(typed_body) and col_idx < len(typed_body[row_idx]):
124
+ return typed_body[row_idx][col_idx]
125
+ return None
126
+
127
+
128
+ def _cell_value(cell: Cell, data_body: list[list[str]], colnames_display: list[str]) -> str:
129
+ """Read the current rendered value of a body or header cell."""
130
+ row_idx, col_idx = cell
131
+ return colnames_display[col_idx] if row_idx == -1 else data_body[row_idx][col_idx]
132
+
133
+
134
+ def _set_cell_value(
135
+ cell: Cell,
136
+ value: object,
137
+ data_body: list[list[str]],
138
+ colnames_display: list[str],
139
+ ) -> None:
140
+ """Set the rendered value of a body or header cell."""
141
+ row_idx, col_idx = cell
142
+ if row_idx == -1:
143
+ colnames_display[col_idx] = str(value)
144
+ else:
145
+ data_body[row_idx][col_idx] = str(value)
146
+
147
+
148
+ def _apply_digits(
149
+ cells: list[Cell],
150
+ directive: FormatDirective,
151
+ data_body: list[list[str]],
152
+ typed_body: list[list[Any]],
153
+ colnames_display: list[str],
154
+ colnames: list[str],
155
+ ) -> None:
156
+ """Apply numeric formatting to the selected cells."""
157
+ if directive.digits is None:
158
+ return
159
+ formatter = (
160
+ _fmt_numeric_significant if directive.num_fmt == "significant" else _fmt_numeric_decimal
161
+ )
162
+ for cell in cells:
163
+ typed_val = _typed_value(cell, typed_body, colnames)
164
+ if _is_numeric_typed(typed_val) and not isinstance(typed_val, int):
165
+ _set_cell_value(
166
+ cell, formatter(typed_val, directive.digits), data_body, colnames_display
167
+ )
168
+
169
+
170
+ def _apply_fn(
171
+ cells: list[Cell],
172
+ directive: FormatDirective,
173
+ data_body: list[list[str]],
174
+ colnames_display: list[str],
175
+ ) -> None:
176
+ """Apply a column-wise transform to the selected cells."""
177
+ if directive.fn is None:
178
+ return
179
+ col_to_rows: dict[int, list[int]] = {}
180
+ for row_idx, col_idx in cells:
181
+ col_to_rows.setdefault(col_idx, []).append(row_idx)
182
+ for col_idx, rows in col_to_rows.items():
183
+ column_cells = [(row_idx, col_idx) for row_idx in sorted(rows)]
184
+ values = [_cell_value(cell, data_body, colnames_display) for cell in column_cells]
185
+ result = directive.fn(values)
186
+ if len(result) != len(values):
187
+ raise ValueError(f"fn() returned {len(result)} items, expected {len(values)}")
188
+ for cell, value in zip(column_cells, result, strict=True):
189
+ _set_cell_value(cell, value, data_body, colnames_display)
190
+
191
+
192
+ def _apply_replacements(
193
+ cells: list[Cell],
194
+ directive: FormatDirective,
195
+ data_body: list[list[str]],
196
+ typed_body: list[list[Any]],
197
+ colnames_display: list[str],
198
+ colnames: list[str],
199
+ ) -> None:
200
+ """Apply replacement rules to the selected cells."""
201
+ if directive.replace is None:
202
+ return
203
+ for cell in cells:
204
+ formatted = _apply_replace(
205
+ _typed_value(cell, typed_body, colnames),
206
+ _cell_value(cell, data_body, colnames_display),
207
+ directive.replace,
208
+ )
209
+ _set_cell_value(cell, formatted, data_body, colnames_display)
210
+
211
+
212
+ def _apply_escapes(
213
+ cells: list[Cell],
214
+ directive: FormatDirective,
215
+ data_body: list[list[str]],
216
+ colnames_display: list[str],
217
+ output: str,
218
+ ) -> set[Cell]:
219
+ """Apply directive-level escaping and return the escaped cell coordinates."""
220
+ if not directive.escape:
221
+ return set()
222
+ for cell in cells:
223
+ formatted = _apply_escape(
224
+ _cell_value(cell, data_body, colnames_display), directive.escape, output
225
+ )
226
+ _set_cell_value(cell, formatted, data_body, colnames_display)
227
+ return set(cells)
228
+
229
+
230
+ def apply_formats(
231
+ data_body: list[list[str]],
232
+ typed_body: list[list[Any]],
233
+ colnames_display: list[str],
234
+ table: TinyTable,
235
+ *,
236
+ nhead: int,
237
+ has_header: bool,
238
+ n_merged_body: int,
239
+ group_positions: set[int],
240
+ output: str,
241
+ colnames: list[str],
242
+ ) -> set[tuple[int, int]]:
243
+ """Apply every ``FormatDirective``, returning cells that were explicitly escaped.
244
+
245
+ Body cells use their zero-based row index. Header cells use row ``-1`` so
246
+ callers can distinguish them when applying the table-wide escape pass.
247
+ """
248
+ escaped_cells: set[tuple[int, int]] = set()
249
+
250
+ for d in table._format_directives:
251
+ if d.output is not None and output not in d.output:
252
+ continue
253
+
254
+ i_vals = resolve_i(
255
+ d.i,
256
+ nhead=nhead,
257
+ group_positions=group_positions,
258
+ n_merged_body=n_merged_body,
259
+ has_header=has_header,
260
+ data=table._data,
261
+ )
262
+ if i_vals is None:
263
+ i_vals = resolve_i(
264
+ "body",
265
+ nhead=nhead,
266
+ group_positions=group_positions,
267
+ n_merged_body=n_merged_body,
268
+ has_header=has_header,
269
+ )
270
+ if i_vals is None:
271
+ raise RuntimeError("i_vals unexpectedly None in apply_formats")
272
+ j_vals = resolve_j(d.j, colnames, regex=d.regex)
273
+
274
+ target_cells = _resolve_target_cells(i_vals, j_vals, data_body, colnames_display)
275
+ _apply_digits(target_cells, d, data_body, typed_body, colnames_display, table._colnames)
276
+ _apply_fn(target_cells, d, data_body, colnames_display)
277
+ _apply_replacements(
278
+ target_cells, d, data_body, typed_body, colnames_display, table._colnames
279
+ )
280
+ escaped_cells.update(_apply_escapes(target_cells, d, data_body, colnames_display, output))
281
+
282
+ return escaped_cells
tytable/_groups.py ADDED
@@ -0,0 +1,158 @@
1
+ """
2
+ Row and column group registration and merging into the table body.
3
+
4
+ Called by :meth:`TinyTable.group` to record groups, and by
5
+ :func:`tytable._resolve.build` to merge row-group separator rows into the body.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from ._directives import RowGroup
13
+
14
+ if TYPE_CHECKING:
15
+ from ._tytable import TinyTable
16
+
17
+
18
+ def _resolve_cols(col_spec: list[str | int], colnames: list[str]) -> list[int]:
19
+ """Translate a list of column names/positions into 0-based integer indices."""
20
+ indices = []
21
+ for c in col_spec:
22
+ if isinstance(c, str):
23
+ try:
24
+ indices.append(colnames.index(c))
25
+ except ValueError:
26
+ raise ValueError(f"column {c!r} not found") from None
27
+ elif isinstance(c, int):
28
+ indices.append(c)
29
+ else:
30
+ raise TypeError(f"column spec must be str or int, got {type(c).__name__}")
31
+ return indices
32
+
33
+
34
+ def _build_col_group_row(
35
+ j_dict: dict[str, list[str | int]], colnames: list[str]
36
+ ) -> list[str | None]:
37
+ """Build one column-group header row (label at span start, ``""`` under the span, ``None`` elsewhere)."""
38
+ ncol = len(colnames)
39
+ row: list[str | None] = [None] * ncol
40
+ for label, cols in j_dict.items():
41
+ indices = _resolve_cols(cols, colnames)
42
+ if not indices:
43
+ continue
44
+ start = indices[0]
45
+ row[start] = label
46
+ for ci in indices[1:]:
47
+ if 0 <= ci < ncol:
48
+ row[ci] = ""
49
+ return row
50
+
51
+
52
+ def _build_col_group_rows_delim(delim: str, colnames: list[str]) -> list[list[str | None]]:
53
+ """Split column names on ``delim`` and build one header row per hierarchical level."""
54
+ parts = [c.split(delim) for c in colnames]
55
+ nlevels = len(parts[0])
56
+ if any(len(p) != nlevels for p in parts):
57
+ raise ValueError(
58
+ f"delimiter {delim!r} does not split all column names into the same number of parts"
59
+ )
60
+ rows: list[list[str | None]] = []
61
+ for level in range(nlevels):
62
+ ncol = len(colnames)
63
+ row: list[str | None] = [None] * ncol
64
+ i = 0
65
+ while i < ncol:
66
+ label = parts[i][level].strip()
67
+ start = i
68
+ i += 1
69
+ while i < ncol and parts[i][level].strip() == label:
70
+ i += 1
71
+ display = label if label else " "
72
+ row[start] = display
73
+ for ci in range(start + 1, i):
74
+ row[ci] = ""
75
+ rows.append(row)
76
+ return rows
77
+
78
+
79
+ def _resolve_col_group_spans(row: list[str | None]) -> list[tuple[str, int, int]]:
80
+ """Return ``(label, start, span)`` entries for a column-group header row.
81
+
82
+ An empty string immediately after a label extends that label's span, while
83
+ ``None`` and standalone empty strings each represent one blank cell.
84
+ """
85
+ spans: list[tuple[str, int, int]] = []
86
+ i = 0
87
+ while i < len(row):
88
+ value = row[i]
89
+ label = "" if value is None else value.strip()
90
+ start = i
91
+ i += 1
92
+ if label:
93
+ while i < len(row) and row[i] is not None and (row[i] or "").strip() == "":
94
+ i += 1
95
+ spans.append((label, start, i - start))
96
+ return spans
97
+
98
+
99
+ def register_row_groups(table: TinyTable, i: dict[str, int] | list[Any]) -> TinyTable:
100
+ """Record row-group separators from a ``{label: row}`` dict or a run-length list."""
101
+ if isinstance(i, dict):
102
+ pairs = sorted(i.items(), key=lambda x: x[1])
103
+ for label, pos in pairs:
104
+ table._row_groups.append(RowGroup(label=str(label), position=int(pos)))
105
+ elif isinstance(i, list):
106
+ prev = None
107
+ pos = 0
108
+ for idx, val in enumerate(i):
109
+ if idx > 0 and val != prev:
110
+ table._row_groups.append(RowGroup(label=str(prev), position=pos))
111
+ pos = idx
112
+ prev = val
113
+ if pos < len(i) and prev is not None:
114
+ table._row_groups.append(RowGroup(label=str(prev), position=pos))
115
+ else:
116
+ raise TypeError("group(i=...) must be a dict or list")
117
+ return table
118
+
119
+
120
+ def register_col_groups(
121
+ table: TinyTable, j: dict[str, list[str | int]] | str, colnames: list[str]
122
+ ) -> TinyTable:
123
+ """Record column-group header rows from a ``{label: [cols]}`` dict or a delimiter string."""
124
+ if isinstance(j, dict):
125
+ row = _build_col_group_row(j, colnames)
126
+ table._col_group_rows.insert(0, row)
127
+ elif isinstance(j, str):
128
+ rows = _build_col_group_rows_delim(j, colnames)
129
+ for row in reversed(rows):
130
+ table._col_group_rows.insert(0, row)
131
+ else:
132
+ raise TypeError("group(j=...) must be a dict or str")
133
+ return table
134
+
135
+
136
+ def merge_row_groups(
137
+ data_body: list[list[str]], row_groups: list[RowGroup], ncols: int
138
+ ) -> tuple[list[list[str]], dict[int, str]]:
139
+ """Interleave row-group separator rows into the body; returns the merged body and ``{row: label}``."""
140
+ if not row_groups:
141
+ return data_body, {}
142
+ nrows = len(data_body)
143
+ ngroups = len(row_groups)
144
+ n_merged = nrows + ngroups
145
+ p = sorted(rg.position for rg in row_groups)
146
+ group_positions_1 = [p[k] + k + 1 for k in range(ngroups)]
147
+ group_positions_set = set(group_positions_1)
148
+ sorted_rg = sorted(row_groups, key=lambda rg: rg.position)
149
+ pos_to_label = dict(zip(group_positions_1, (rg.label for rg in sorted_rg), strict=True))
150
+ merged = []
151
+ data_row_idx = 0
152
+ for r in range(1, n_merged + 1):
153
+ if r in group_positions_set:
154
+ merged.append([pos_to_label[r]] + [""] * (ncols - 1))
155
+ else:
156
+ merged.append(data_body[data_row_idx])
157
+ data_row_idx += 1
158
+ return merged, pos_to_label