1"""
2Module for formatting output data in console (to string).
3"""
4
5from __future__ import annotations
6
7from shutil import get_terminal_size
8from typing import TYPE_CHECKING
9
10import numpy as np
11
12from pandas.io.formats.printing import pprint_thing
13
14if TYPE_CHECKING:
15 from collections.abc import Iterable
16
17 from pandas.io.formats.format import DataFrameFormatter
18
19
20class StringFormatter:
21 """Formatter for string representation of a dataframe."""
22
23 def __init__(self, fmt: DataFrameFormatter, line_width: int | None = None) -> None:
24 self.fmt = fmt
25 self.adj = fmt.adj
26 self.frame = fmt.frame
27 self.line_width = line_width
28
29 def to_string(self) -> str:
30 text = self._get_string_representation()
31 if self.fmt.should_show_dimensions:
32 text = f"{text}{self.fmt.dimensions_info}"
33 return text
34
35 def _get_strcols(self) -> list[list[str]]:
36 strcols = self.fmt.get_strcols()
37 if self.fmt.is_truncated:
38 strcols = self._insert_dot_separators(strcols)
39 return strcols
40
41 def _get_string_representation(self) -> str:
42 if self.fmt.frame.empty:
43 return self._empty_info_line
44
45 strcols = self._get_strcols()
46
47 if self.line_width is None:
48 # no need to wrap around just print the whole frame
49 return self.adj.adjoin(1, *strcols)
50
51 if self._need_to_wrap_around:
52 return self._join_multiline(strcols)
53
54 return self._fit_strcols_to_terminal_width(strcols)
55
56 @property
57 def _empty_info_line(self) -> str:
58 return (
59 f"Empty {type(self.frame).__name__}\n"
60 f"Columns: {pprint_thing(self.frame.columns)}\n"
61 f"Index: {pprint_thing(self.frame.index)}"
62 )
63
64 @property
65 def _need_to_wrap_around(self) -> bool:
66 return bool(self.fmt.max_cols is None or self.fmt.max_cols > 0)
67
68 def _insert_dot_separators(self, strcols: list[list[str]]) -> list[list[str]]:
69 index_length = len(strcols[0])
70
71 if self.fmt.is_truncated_horizontally:
72 strcols = self._insert_dot_separator_horizontal(strcols, index_length)
73
74 if self.fmt.is_truncated_vertically:
75 strcols = self._insert_dot_separator_vertical(strcols, index_length)
76
77 return strcols
78
79 @property
80 def _adjusted_tr_col_num(self) -> int:
81 return self.fmt.tr_col_num + 1 if self.fmt.index else self.fmt.tr_col_num
82
83 def _insert_dot_separator_horizontal(
84 self, strcols: list[list[str]], index_length: int
85 ) -> list[list[str]]:
86 strcols.insert(self._adjusted_tr_col_num, [" ..."] * index_length)
87 return strcols
88
89 def _insert_dot_separator_vertical(
90 self, strcols: list[list[str]], index_length: int
91 ) -> list[list[str]]:
92 n_header_rows = index_length - len(self.fmt.tr_frame)
93 row_num = self.fmt.tr_row_num
94 for ix, col in enumerate(strcols):
95 cwidth = self.adj.len(col[row_num])
96
97 if self.fmt.is_truncated_horizontally:
98 is_dot_col = ix == self._adjusted_tr_col_num
99 else:
100 is_dot_col = False
101
102 if cwidth > 3 or is_dot_col:
103 dots = "..."
104 else:
105 dots = ".."
106
107 if ix == 0 and self.fmt.index:
108 dot_mode = "left"
109 elif is_dot_col:
110 cwidth = 4
111 dot_mode = "right"
112 else:
113 dot_mode = "right"
114
115 dot_str = self.adj.justify([dots], cwidth, mode=dot_mode)[0]
116 col.insert(row_num + n_header_rows, dot_str)
117 return strcols
118
119 def _join_multiline(self, strcols_input: Iterable[list[str]]) -> str:
120 lwidth = self.line_width
121 adjoin_width = 1
122 strcols = list(strcols_input)
123
124 if self.fmt.index:
125 idx = strcols.pop(0)
126 lwidth -= np.array([self.adj.len(x) for x in idx]).max() + adjoin_width
127
128 col_widths = [
129 np.array([self.adj.len(x) for x in col]).max() if len(col) > 0 else 0
130 for col in strcols
131 ]
132
133 assert lwidth is not None
134 col_bins = _binify(col_widths, lwidth)
135 nbins = len(col_bins)
136
137 str_lst = []
138 start = 0
139 for i, end in enumerate(col_bins):
140 row = strcols[start:end]
141 if self.fmt.index:
142 row.insert(0, idx)
143 if nbins > 1:
144 nrows = len(row[-1])
145 if end <= len(strcols) and i < nbins - 1:
146 row.append([" \\"] + [" "] * (nrows - 1))
147 else:
148 row.append([" "] * nrows)
149 str_lst.append(self.adj.adjoin(adjoin_width, *row))
150 start = end
151 return "\n\n".join(str_lst)
152
153 def _fit_strcols_to_terminal_width(self, strcols: list[list[str]]) -> str:
154 col_lens = [max(self.adj.len(x) for x in col) if col else 0 for col in strcols]
155 n_cols = len(col_lens)
156 # total width = sum of column widths + adjoin spacing (1 per gap)
157 total_width = sum(col_lens) + n_cols - 1
158
159 width, _ = get_terminal_size()
160 dif = total_width - width
161 # '+ 1' to avoid too wide repr (GH PR #17023)
162 adj_dif = dif + 1
163
164 if adj_dif <= 0:
165 # All columns fit; no truncation needed and max_cols_fitted
166 # stays at the value set by _calc_max_cols_fitted (0), so
167 # is_truncated_horizontally remains False.
168 return self.adj.adjoin(1, *strcols)
169
170 # Account for the " ..." separator column that will be inserted
171 # after horizontal truncation (GH#32461)
172 adj_dif += 4 + 1 # 4 chars for " ..." + 1 adjoin spacing
173
174 while adj_dif > 0 and n_cols > 1:
175 mid = round(n_cols / 2)
176 # adjoin adds one
177 adj_dif -= col_lens.pop(mid) + 1
178 n_cols -= 1
179
180 # subtract index column
181 max_cols_fitted = n_cols - self.fmt.index
182 # GH-21180. Ensure that we print at least two.
183 max_cols_fitted = max(max_cols_fitted, 2)
184 self.fmt.max_cols_fitted = max_cols_fitted
185
186 # Reuse already-formatted columns instead of re-truncating and
187 # re-formatting the entire DataFrame.
188 num_data_cols = len(strcols) - self.fmt.index
189 if max_cols_fitted >= num_data_cols:
190 # All data columns fit (can happen due to the max(..., 2) above)
191 return self.adj.adjoin(1, *strcols)
192
193 col_num = max_cols_fitted // 2
194 has_index = self.fmt.index
195 if has_index:
196 # strcols[0] is the index
197 kept = strcols[: col_num + 1] + strcols[-col_num:]
198 insert_pos = col_num + 1
199 else:
200 kept = strcols[:col_num] + strcols[-col_num:]
201 insert_pos = col_num
202
203 # Insert horizontal "..." separator column
204 row_count = len(kept[0]) if kept else 0
205 kept.insert(insert_pos, [" ..."] * row_count)
206
207 return self.adj.adjoin(1, *kept)
208
209
210def _binify(cols: list[int], line_width: int) -> list[int]:
211 adjoin_width = 1
212 bins = []
213 curr_width = 0
214 i_last_column = len(cols) - 1
215 for i, w in enumerate(cols):
216 w_adjoined = w + adjoin_width
217 curr_width += w_adjoined
218 if i_last_column == i:
219 wrap = curr_width + 1 > line_width and i > 0
220 else:
221 wrap = curr_width + 2 > line_width and i > 0
222 if wrap:
223 bins.append(i)
224 curr_width = w_adjoined
225
226 bins.append(len(cols))
227 return bins