guidellm 0.3.1__py3-none-any.whl → 0.6.0a5__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.
Files changed (141) hide show
  1. guidellm/__init__.py +5 -2
  2. guidellm/__main__.py +524 -255
  3. guidellm/backends/__init__.py +33 -0
  4. guidellm/backends/backend.py +109 -0
  5. guidellm/backends/openai.py +340 -0
  6. guidellm/backends/response_handlers.py +428 -0
  7. guidellm/benchmark/__init__.py +69 -39
  8. guidellm/benchmark/benchmarker.py +160 -316
  9. guidellm/benchmark/entrypoints.py +560 -127
  10. guidellm/benchmark/outputs/__init__.py +24 -0
  11. guidellm/benchmark/outputs/console.py +633 -0
  12. guidellm/benchmark/outputs/csv.py +721 -0
  13. guidellm/benchmark/outputs/html.py +473 -0
  14. guidellm/benchmark/outputs/output.py +169 -0
  15. guidellm/benchmark/outputs/serialized.py +69 -0
  16. guidellm/benchmark/profiles.py +718 -0
  17. guidellm/benchmark/progress.py +553 -556
  18. guidellm/benchmark/scenarios/__init__.py +40 -0
  19. guidellm/benchmark/scenarios/chat.json +6 -0
  20. guidellm/benchmark/scenarios/rag.json +6 -0
  21. guidellm/benchmark/schemas/__init__.py +66 -0
  22. guidellm/benchmark/schemas/base.py +402 -0
  23. guidellm/benchmark/schemas/generative/__init__.py +55 -0
  24. guidellm/benchmark/schemas/generative/accumulator.py +841 -0
  25. guidellm/benchmark/schemas/generative/benchmark.py +163 -0
  26. guidellm/benchmark/schemas/generative/entrypoints.py +381 -0
  27. guidellm/benchmark/schemas/generative/metrics.py +927 -0
  28. guidellm/benchmark/schemas/generative/report.py +158 -0
  29. guidellm/data/__init__.py +34 -4
  30. guidellm/data/builders.py +541 -0
  31. guidellm/data/collators.py +16 -0
  32. guidellm/data/config.py +120 -0
  33. guidellm/data/deserializers/__init__.py +49 -0
  34. guidellm/data/deserializers/deserializer.py +141 -0
  35. guidellm/data/deserializers/file.py +223 -0
  36. guidellm/data/deserializers/huggingface.py +94 -0
  37. guidellm/data/deserializers/memory.py +194 -0
  38. guidellm/data/deserializers/synthetic.py +246 -0
  39. guidellm/data/entrypoints.py +52 -0
  40. guidellm/data/loaders.py +190 -0
  41. guidellm/data/preprocessors/__init__.py +27 -0
  42. guidellm/data/preprocessors/formatters.py +410 -0
  43. guidellm/data/preprocessors/mappers.py +196 -0
  44. guidellm/data/preprocessors/preprocessor.py +30 -0
  45. guidellm/data/processor.py +29 -0
  46. guidellm/data/schemas.py +175 -0
  47. guidellm/data/utils/__init__.py +6 -0
  48. guidellm/data/utils/dataset.py +94 -0
  49. guidellm/extras/__init__.py +4 -0
  50. guidellm/extras/audio.py +220 -0
  51. guidellm/extras/vision.py +242 -0
  52. guidellm/logger.py +2 -2
  53. guidellm/mock_server/__init__.py +8 -0
  54. guidellm/mock_server/config.py +84 -0
  55. guidellm/mock_server/handlers/__init__.py +17 -0
  56. guidellm/mock_server/handlers/chat_completions.py +280 -0
  57. guidellm/mock_server/handlers/completions.py +280 -0
  58. guidellm/mock_server/handlers/tokenizer.py +142 -0
  59. guidellm/mock_server/models.py +510 -0
  60. guidellm/mock_server/server.py +238 -0
  61. guidellm/mock_server/utils.py +302 -0
  62. guidellm/scheduler/__init__.py +69 -26
  63. guidellm/scheduler/constraints/__init__.py +49 -0
  64. guidellm/scheduler/constraints/constraint.py +325 -0
  65. guidellm/scheduler/constraints/error.py +411 -0
  66. guidellm/scheduler/constraints/factory.py +182 -0
  67. guidellm/scheduler/constraints/request.py +312 -0
  68. guidellm/scheduler/constraints/saturation.py +722 -0
  69. guidellm/scheduler/environments.py +252 -0
  70. guidellm/scheduler/scheduler.py +137 -368
  71. guidellm/scheduler/schemas.py +358 -0
  72. guidellm/scheduler/strategies.py +617 -0
  73. guidellm/scheduler/worker.py +413 -419
  74. guidellm/scheduler/worker_group.py +712 -0
  75. guidellm/schemas/__init__.py +65 -0
  76. guidellm/schemas/base.py +417 -0
  77. guidellm/schemas/info.py +188 -0
  78. guidellm/schemas/request.py +235 -0
  79. guidellm/schemas/request_stats.py +349 -0
  80. guidellm/schemas/response.py +124 -0
  81. guidellm/schemas/statistics.py +1018 -0
  82. guidellm/{config.py → settings.py} +31 -24
  83. guidellm/utils/__init__.py +71 -8
  84. guidellm/utils/auto_importer.py +98 -0
  85. guidellm/utils/cli.py +132 -5
  86. guidellm/utils/console.py +566 -0
  87. guidellm/utils/encoding.py +778 -0
  88. guidellm/utils/functions.py +159 -0
  89. guidellm/utils/hf_datasets.py +1 -2
  90. guidellm/utils/hf_transformers.py +4 -4
  91. guidellm/utils/imports.py +9 -0
  92. guidellm/utils/messaging.py +1118 -0
  93. guidellm/utils/mixins.py +115 -0
  94. guidellm/utils/random.py +3 -4
  95. guidellm/utils/registry.py +220 -0
  96. guidellm/utils/singleton.py +133 -0
  97. guidellm/utils/synchronous.py +159 -0
  98. guidellm/utils/text.py +163 -50
  99. guidellm/utils/typing.py +41 -0
  100. guidellm/version.py +2 -2
  101. guidellm-0.6.0a5.dist-info/METADATA +364 -0
  102. guidellm-0.6.0a5.dist-info/RECORD +109 -0
  103. guidellm/backend/__init__.py +0 -23
  104. guidellm/backend/backend.py +0 -259
  105. guidellm/backend/openai.py +0 -708
  106. guidellm/backend/response.py +0 -136
  107. guidellm/benchmark/aggregator.py +0 -760
  108. guidellm/benchmark/benchmark.py +0 -837
  109. guidellm/benchmark/output.py +0 -997
  110. guidellm/benchmark/profile.py +0 -409
  111. guidellm/benchmark/scenario.py +0 -104
  112. guidellm/data/prideandprejudice.txt.gz +0 -0
  113. guidellm/dataset/__init__.py +0 -22
  114. guidellm/dataset/creator.py +0 -213
  115. guidellm/dataset/entrypoints.py +0 -42
  116. guidellm/dataset/file.py +0 -92
  117. guidellm/dataset/hf_datasets.py +0 -62
  118. guidellm/dataset/in_memory.py +0 -132
  119. guidellm/dataset/synthetic.py +0 -287
  120. guidellm/objects/__init__.py +0 -18
  121. guidellm/objects/pydantic.py +0 -89
  122. guidellm/objects/statistics.py +0 -953
  123. guidellm/preprocess/__init__.py +0 -3
  124. guidellm/preprocess/dataset.py +0 -374
  125. guidellm/presentation/__init__.py +0 -28
  126. guidellm/presentation/builder.py +0 -27
  127. guidellm/presentation/data_models.py +0 -232
  128. guidellm/presentation/injector.py +0 -66
  129. guidellm/request/__init__.py +0 -18
  130. guidellm/request/loader.py +0 -284
  131. guidellm/request/request.py +0 -79
  132. guidellm/request/types.py +0 -10
  133. guidellm/scheduler/queues.py +0 -25
  134. guidellm/scheduler/result.py +0 -155
  135. guidellm/scheduler/strategy.py +0 -495
  136. guidellm-0.3.1.dist-info/METADATA +0 -329
  137. guidellm-0.3.1.dist-info/RECORD +0 -62
  138. {guidellm-0.3.1.dist-info → guidellm-0.6.0a5.dist-info}/WHEEL +0 -0
  139. {guidellm-0.3.1.dist-info → guidellm-0.6.0a5.dist-info}/entry_points.txt +0 -0
  140. {guidellm-0.3.1.dist-info → guidellm-0.6.0a5.dist-info}/licenses/LICENSE +0 -0
  141. {guidellm-0.3.1.dist-info → guidellm-0.6.0a5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,566 @@
1
+ """
2
+ Console utilities for rich terminal output and status updates.
3
+
4
+ Provides an extended Rich console with custom formatting for status messages,
5
+ progress tracking, and tabular data display. Includes predefined color schemes,
6
+ status levels, icons, and styles for consistent terminal output across the
7
+ application. Supports multi-step operations with spinners and context managers
8
+ for clean progress reporting.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Mapping, Sequence
14
+ from dataclasses import dataclass
15
+ from typing import Annotated, Any, Literal
16
+
17
+ from rich.console import Console as RichConsole
18
+ from rich.padding import Padding
19
+ from rich.status import Status
20
+ from rich.text import Text
21
+
22
+ __all__ = [
23
+ "Colors",
24
+ "Console",
25
+ "ConsoleUpdateStep",
26
+ "StatusIcons",
27
+ "StatusLevel",
28
+ "StatusStyles",
29
+ ]
30
+
31
+ StatusLevel = Annotated[
32
+ Literal[
33
+ "debug",
34
+ "info",
35
+ "warning",
36
+ "error",
37
+ "critical",
38
+ "notset",
39
+ "success",
40
+ ],
41
+ "Status level for console messages indicating severity or state",
42
+ ]
43
+
44
+
45
+ class Colors:
46
+ """
47
+ Color constants for console styling.
48
+
49
+ Provides standardized color schemes for different message types and branding.
50
+ Colors are defined using Rich console color names or hex values.
51
+
52
+ :cvar info: Color for informational messages
53
+ :cvar progress: Color for progress indicators
54
+ :cvar success: Color for successful operations
55
+ :cvar warning: Color for warning messages
56
+ :cvar error: Color for error messages
57
+ :cvar primary: Primary brand color
58
+ :cvar secondary: Secondary brand color
59
+ :cvar tertiary: Tertiary brand color
60
+ """
61
+
62
+ # Core states
63
+ info: str = "light_steel_blue"
64
+ progress: str = "dark_slate_gray1"
65
+ success: str = "chartreuse1"
66
+ warning: str = "#FDB516"
67
+ error: str = "orange_red1"
68
+
69
+ # Branding
70
+ primary: str = "#30A2FF"
71
+ secondary: str = "#FDB516"
72
+ tertiary: str = "#008080"
73
+
74
+
75
+ StatusIcons: Annotated[
76
+ Mapping[str, str],
77
+ "Mapping of status levels to unicode icon characters for visual indicators",
78
+ ] = {
79
+ "debug": "…",
80
+ "info": "ℹ",
81
+ "warning": "⚠",
82
+ "error": "✖",
83
+ "critical": "‼",
84
+ "notset": "⟳",
85
+ "success": "✔",
86
+ }
87
+
88
+ StatusStyles: Annotated[
89
+ Mapping[str, str],
90
+ "Mapping of status levels to Rich console style strings for colored output",
91
+ ] = {
92
+ "debug": "dim",
93
+ "info": f"bold {Colors.info}",
94
+ "warning": f"bold {Colors.warning}",
95
+ "error": f"bold {Colors.error}",
96
+ "critical": "bold red reverse",
97
+ "notset": f"bold {Colors.progress}",
98
+ "success": f"bold {Colors.success}",
99
+ }
100
+
101
+
102
+ @dataclass
103
+ class ConsoleUpdateStep:
104
+ """
105
+ Context manager for multi-step progress operations with spinner.
106
+
107
+ Displays animated spinner during operation execution and allows dynamic
108
+ status updates. Automatically stops spinner on exit and prints final
109
+ status message. Designed for use with Python's `with` statement.
110
+
111
+ Example:
112
+ ::
113
+ console = Console()
114
+ with console.print_update_step("Processing data") as step:
115
+ step.update("Loading files", "info")
116
+ # ... do work ...
117
+ step.finish("Completed successfully", status_level="success")
118
+
119
+ :param console: The Console instance to use for output
120
+ :param title: Initial progress message to display
121
+ :param details: Optional additional details to show after completion
122
+ :param status_level: Initial status level determining style and icon
123
+ :param spinner: Spinner animation style name from Rich's spinner set
124
+ """
125
+
126
+ console: Console
127
+ title: str
128
+ details: Any | None = None
129
+ status_level: StatusLevel = "info"
130
+ spinner: str = "dots"
131
+ _status: Status | None = None
132
+
133
+ def __enter__(self) -> ConsoleUpdateStep:
134
+ if self.console.quiet:
135
+ return self
136
+
137
+ style = StatusStyles.get(self.status_level, "bold")
138
+ self._status = self.console.status(
139
+ f"[{style}]{self.title}[/]",
140
+ spinner=self.spinner,
141
+ )
142
+ self._status.__enter__()
143
+ return self
144
+
145
+ def update(self, title: str, status_level: StatusLevel | None = None):
146
+ """
147
+ Update the progress message and optionally the status level.
148
+
149
+ :param title: New progress message to display
150
+ :param status_level: Optional new status level to apply
151
+ """
152
+ self.title = title
153
+ if status_level is not None:
154
+ self.status_level = status_level
155
+
156
+ if self._status:
157
+ style = StatusStyles.get(self.status_level, "bold")
158
+ self._status.update(status=f"[{style}]{title}[/]")
159
+
160
+ def finish(
161
+ self,
162
+ title: str,
163
+ details: Any | None = None,
164
+ status_level: StatusLevel = "info",
165
+ ):
166
+ """
167
+ Stop the spinner and print the final status message.
168
+
169
+ :param title: Final completion message to display
170
+ :param details: Optional additional information to show below message
171
+ :param status_level: Status level for final message styling
172
+ """
173
+ self.title = title
174
+ self.status_level = status_level
175
+
176
+ if self._status:
177
+ self._status.stop()
178
+
179
+ self.console.print_update(title, details, status_level)
180
+
181
+ def __exit__(self, exc_type, exc_val, exc_tb):
182
+ if self._status:
183
+ self._status.__exit__(exc_type, exc_val, exc_tb)
184
+
185
+
186
+ class Console(RichConsole):
187
+ """
188
+ Extended Rich console with custom formatting and status reporting.
189
+
190
+ Enhances Rich's Console with specialized methods for status messages,
191
+ progress tracking with spinners, and formatted table output. Provides
192
+ consistent styling through predefined status levels, icons, and colors.
193
+ Supports quiet mode to suppress non-critical output.
194
+
195
+ Example:
196
+ ::
197
+ console = Console()
198
+ console.print_update("Starting process", status="info")
199
+ with console.print_update_step("Loading data") as step:
200
+ step.update("Processing items")
201
+ step.finish("Complete", status_level="success")
202
+ """
203
+
204
+ def print_update(
205
+ self,
206
+ title: str,
207
+ details: Any | None = None,
208
+ status: StatusLevel = "info",
209
+ ):
210
+ """
211
+ Print a status message with icon and optional details.
212
+
213
+ :param title: Main status message to display
214
+ :param details: Optional additional details shown indented below message
215
+ :param status: Status level determining icon and styling
216
+ """
217
+ icon = StatusIcons.get(status, "•")
218
+ style = StatusStyles.get(status, "bold")
219
+ line = Text.assemble(f"{icon} ", (title, style))
220
+ self.print(line)
221
+ self.print_update_details(details)
222
+
223
+ def print_update_details(self, details: Any | None):
224
+ """
225
+ Print additional details indented below a status message.
226
+
227
+ :param details: Content to display, converted to string and styled dimly
228
+ """
229
+ if details:
230
+ block = Padding(
231
+ Text.from_markup(str(details)),
232
+ (0, 0, 0, 2),
233
+ style=StatusStyles.get("debug", "dim"),
234
+ )
235
+ self.print(block)
236
+
237
+ def print_update_step(
238
+ self,
239
+ title: str,
240
+ status: StatusLevel = "info",
241
+ details: Any | None = None,
242
+ spinner: str = "dots",
243
+ ) -> ConsoleUpdateStep:
244
+ """
245
+ Create a context manager for multi-step progress with spinner.
246
+
247
+ :param title: Initial progress message to display
248
+ :param status: Initial status level for styling
249
+ :param details: Optional details to show after completion
250
+ :param spinner: Spinner animation style name
251
+ :return: ConsoleUpdateStep context manager for progress tracking
252
+ """
253
+ return ConsoleUpdateStep(
254
+ console=self,
255
+ title=title,
256
+ details=details,
257
+ status_level=status,
258
+ spinner=spinner,
259
+ )
260
+
261
+ def print_tables(
262
+ self,
263
+ header_cols_groups: Sequence[Sequence[str | list[str]]],
264
+ value_cols_groups: Sequence[Sequence[str | list[str]]],
265
+ title: str | None = None,
266
+ widths: Sequence[int] | None = None,
267
+ ):
268
+ """
269
+ Print multiple tables with uniform column widths.
270
+
271
+ :param header_cols_groups: List of header column groups for each table
272
+ :param value_cols_groups: List of value column groups for each table
273
+ :param title: Optional title to display before tables
274
+ :param widths: Optional minimum column widths to enforce
275
+ """
276
+ if title is not None:
277
+ self.print_update(title, None, "info")
278
+
279
+ # Format all groups to determine uniform widths
280
+ widths = widths or None
281
+ headers = []
282
+ values = []
283
+
284
+ # Process all tables to get consistent widths
285
+ for value_cols in value_cols_groups:
286
+ formatted, widths = self._format_table_columns(value_cols, widths)
287
+ values.append(formatted)
288
+ for header_cols in header_cols_groups:
289
+ formatted, widths = self._format_table_headers(header_cols, widths)
290
+ headers.append(formatted)
291
+
292
+ # Print each table
293
+ for ind, (header, value) in enumerate(zip(headers, values, strict=False)):
294
+ is_last = ind == len(headers) - 1
295
+ self.print_table(
296
+ header,
297
+ value,
298
+ widths=widths,
299
+ apply_formatting=False,
300
+ print_bottom_divider=is_last,
301
+ )
302
+
303
+ def print_table(
304
+ self,
305
+ header_cols: Sequence[str | list[str]],
306
+ value_cols: Sequence[str | list[str]],
307
+ title: str | None = None,
308
+ widths: Sequence[int] | None = None,
309
+ apply_formatting: bool = True,
310
+ print_bottom_divider: bool = True,
311
+ ):
312
+ """
313
+ Print a formatted table with headers and values.
314
+
315
+ :param header_cols: List of header columns, each string or list of strings
316
+ :param value_cols: List of value columns, each string or list of strings
317
+ :param title: Optional title to display before table
318
+ :param widths: Optional minimum column widths to enforce
319
+ :param apply_formatting: Whether to calculate widths and format columns
320
+ :param print_bottom_divider: Whether to print bottom border line
321
+ """
322
+ if title is not None:
323
+ self.print_update(title, None, "info")
324
+
325
+ # Format data
326
+ values: list[list[str]]
327
+ headers: list[list[str]]
328
+ final_widths: list[int]
329
+
330
+ if apply_formatting:
331
+ values, final_widths = self._format_table_columns(value_cols, widths)
332
+ headers, final_widths = self._format_table_headers(
333
+ header_cols, final_widths
334
+ )
335
+ else:
336
+ values = [col if isinstance(col, list) else [col] for col in value_cols]
337
+ headers = [col if isinstance(col, list) else [col] for col in header_cols]
338
+ final_widths = list(widths) if widths else []
339
+
340
+ # Print table structure
341
+ self.print_table_divider(final_widths, "=")
342
+ self.print_table_headers(headers, final_widths)
343
+ self.print_table_divider(final_widths, "-")
344
+ self.print_table_values(values, final_widths)
345
+
346
+ if print_bottom_divider:
347
+ self.print_table_divider(final_widths, "=")
348
+
349
+ def print_table_divider(self, widths: Sequence[int], char: str):
350
+ """
351
+ Print a horizontal divider line across table columns.
352
+
353
+ :param widths: Column widths for divider line
354
+ :param char: Character to use for divider line (e.g., '=', '-')
355
+ """
356
+ self.print_table_row(
357
+ [""] * len(widths),
358
+ widths=widths,
359
+ spacer=char,
360
+ cell_style="bold",
361
+ divider_style="bold",
362
+ edge_style="bold",
363
+ )
364
+
365
+ def print_table_headers(self, headers: Sequence[list[str]], widths: Sequence[int]):
366
+ """
367
+ Print header rows with support for column spanning.
368
+
369
+ :param headers: List of header columns, each containing header row values
370
+ :param widths: Column widths for proper alignment
371
+ """
372
+ if not headers or not headers[0]:
373
+ return
374
+
375
+ for row_idx in range(len(headers[0])):
376
+ # Calculate widths for this header row, accounting for merged cells.
377
+ row_widths = list(widths)
378
+ for col_idx in range(len(headers)):
379
+ if not headers[col_idx][row_idx]:
380
+ continue
381
+
382
+ # Find span end
383
+ span_end = col_idx + 1
384
+ while span_end < len(headers) and not headers[span_end][row_idx]:
385
+ row_widths[span_end] = 0
386
+ span_end += 1
387
+
388
+ # Set combined width for the first cell in span
389
+ row_widths[col_idx] = sum(
390
+ widths[col] for col in range(col_idx, span_end)
391
+ )
392
+
393
+ # Print the header row
394
+ self.print_table_row(
395
+ values=[headers[col][row_idx] for col in range(len(headers))],
396
+ widths=row_widths,
397
+ cell_style="bold",
398
+ divider_style="bold",
399
+ edge_style="bold",
400
+ )
401
+
402
+ def print_table_values(self, values: Sequence[list[str]], widths: Sequence[int]):
403
+ """
404
+ Print all data rows in the table.
405
+
406
+ :param values: List of value columns, each containing row values
407
+ :param widths: Column widths for proper alignment
408
+ """
409
+ if not values:
410
+ return
411
+
412
+ for row_idx in range(len(values[0])):
413
+ # Print the value row
414
+ self.print_table_row(
415
+ values=[values[col][row_idx] for col in range(len(values))],
416
+ widths=widths,
417
+ divider="|",
418
+ edge_style="bold",
419
+ )
420
+
421
+ def print_table_row(
422
+ self,
423
+ values: Sequence[str],
424
+ widths: Sequence[int] | None = None,
425
+ spacer: str = " ",
426
+ divider: str = "|",
427
+ cell_style: str = "",
428
+ value_style: str = "",
429
+ divider_style: str = "",
430
+ edge_style: str = "",
431
+ ):
432
+ """
433
+ Print a single table row with custom styling.
434
+
435
+ :param values: Cell values for the row
436
+ :param widths: Column widths, defaults to value lengths
437
+ :param spacer: Character for padding cells
438
+ :param divider: Character separating columns
439
+ :param cell_style: Rich style string for entire cells
440
+ :param value_style: Rich style string for cell values only
441
+ :param divider_style: Rich style string for column dividers
442
+ :param edge_style: Rich style string for table edges
443
+ """
444
+ widths = widths or [len(val) for val in values]
445
+
446
+ # Build styled cells
447
+ cells = []
448
+ for val, width in zip(values, widths, strict=True):
449
+ cell = val.ljust(width, spacer)
450
+ if value_style and val:
451
+ cell = cell.replace(val, f"[{value_style}]{val}[/{value_style}]")
452
+ if cell_style:
453
+ cell = f"[{cell_style}]{cell}[/{cell_style}]"
454
+ cells.append(cell)
455
+
456
+ # Build and print row
457
+ edge = f"[{edge_style}]{divider}[/{edge_style}]" if edge_style else divider
458
+ inner = (
459
+ f"[{divider_style}]{divider}[/{divider_style}]"
460
+ if divider_style
461
+ else divider
462
+ )
463
+ line = edge + inner.join(cells) + edge
464
+ self.print(line, overflow="ignore", crop=False)
465
+
466
+ def _format_table_headers(
467
+ self,
468
+ headers: Sequence[str | list[str]],
469
+ col_widths: Sequence[int] | None = None,
470
+ spacer: str = " ",
471
+ min_padding: int = 1,
472
+ ) -> tuple[list[list[str]], list[int]]:
473
+ formatted, header_widths = self._format_table_columns(
474
+ headers, col_widths, spacer, min_padding
475
+ )
476
+
477
+ if not formatted or not formatted[0]:
478
+ return formatted, []
479
+
480
+ # Merge identical adjacent headers row by row
481
+ widths = list(col_widths) if col_widths else header_widths
482
+ for row_idx in range(len(formatted[0])):
483
+ last_value = None
484
+ start_col = -1
485
+
486
+ for col_idx in range(len(formatted) + 1):
487
+ cur_value = (
488
+ formatted[col_idx][row_idx] if col_idx < len(formatted) else None
489
+ )
490
+
491
+ # Check if we should continue merging
492
+ if (
493
+ col_idx < len(formatted)
494
+ and cur_value != ""
495
+ and cur_value == last_value
496
+ and (
497
+ row_idx == 0
498
+ or headers[start_col][row_idx - 1]
499
+ == headers[col_idx][row_idx - 1]
500
+ )
501
+ ):
502
+ continue
503
+
504
+ # Finalize previous
505
+ if start_col >= 0:
506
+ # Clear merged cells to keep only the first
507
+ for col in range(start_col + 1, col_idx):
508
+ formatted[col][row_idx] = ""
509
+
510
+ # Adjust widths of columns in the merged span, if needed
511
+ if (required := len(formatted[start_col][row_idx])) > (
512
+ current := sum(widths[col] for col in range(start_col, col_idx))
513
+ ):
514
+ diff = required - current
515
+ cols_count = col_idx - start_col
516
+ per_col = diff // cols_count
517
+ extra = diff % cols_count
518
+
519
+ for col in range(start_col, col_idx):
520
+ widths[col] += per_col
521
+ if extra > 0:
522
+ widths[col] += 1
523
+ extra -= 1
524
+
525
+ # Start new merge
526
+ last_value = cur_value
527
+ start_col = col_idx
528
+
529
+ return formatted, widths
530
+
531
+ def _format_table_columns(
532
+ self,
533
+ columns: Sequence[str | list[str]],
534
+ col_widths: Sequence[int] | None = None,
535
+ spacer: str = " ",
536
+ min_padding: int = 1,
537
+ ) -> tuple[list[list[str]], list[int]]:
538
+ if not columns:
539
+ return [], []
540
+
541
+ # Normalize to list of lists
542
+ max_rows = max(len(col) if isinstance(col, list) else 1 for col in columns)
543
+
544
+ formatted = []
545
+ for col in columns:
546
+ col_list = col if isinstance(col, list) else [col]
547
+ # Pad to max height
548
+ col_list = col_list + [""] * (max_rows - len(col_list))
549
+ # Add cell padding
550
+ padding = spacer * min_padding
551
+ col_list = [
552
+ f"{padding}{item}{padding}" if item else "" for item in col_list
553
+ ]
554
+ formatted.append(col_list)
555
+
556
+ # Calculate widths
557
+ widths = [max(len(row) for row in col) for col in formatted]
558
+
559
+ # Apply minimum widths if provided
560
+ if col_widths is not None:
561
+ widths = [
562
+ max(width, min_w)
563
+ for width, min_w in zip(widths, col_widths, strict=True)
564
+ ]
565
+
566
+ return formatted, widths