flexablelib 2.0.1__tar.gz → 2.0.3__tar.gz

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 (42) hide show
  1. {flexablelib-2.0.1 → flexablelib-2.0.3}/CHANGELOG.md +12 -0
  2. {flexablelib-2.0.1/flexablelib.egg-info → flexablelib-2.0.3}/PKG-INFO +1 -1
  3. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib/merges.py +26 -9
  4. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib/readers.py +37 -6
  5. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib/table.py +3 -1
  6. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib/writers.py +22 -0
  7. {flexablelib-2.0.1 → flexablelib-2.0.3/flexablelib.egg-info}/PKG-INFO +1 -1
  8. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib.egg-info/SOURCES.txt +1 -0
  9. {flexablelib-2.0.1 → flexablelib-2.0.3}/package.json +1 -1
  10. flexablelib-2.0.3/tests/test_excel.py +110 -0
  11. {flexablelib-2.0.1 → flexablelib-2.0.3}/tests/test_merges.py +59 -0
  12. {flexablelib-2.0.1 → flexablelib-2.0.3}/LICENSE +0 -0
  13. {flexablelib-2.0.1 → flexablelib-2.0.3}/MANIFEST.in +0 -0
  14. {flexablelib-2.0.1 → flexablelib-2.0.3}/README.md +0 -0
  15. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib/__init__.py +0 -0
  16. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib/_speedups.c +0 -0
  17. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib/_width_tables.h +0 -0
  18. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib/cells.py +0 -0
  19. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib/colors.py +0 -0
  20. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib/columns.py +0 -0
  21. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib/fast.py +0 -0
  22. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib/options.py +0 -0
  23. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib/style_compositions.py +0 -0
  24. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib/table_strings.py +0 -0
  25. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib/text_width.py +0 -0
  26. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib/utils.py +0 -0
  27. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib.egg-info/dependency_links.txt +0 -0
  28. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib.egg-info/requires.txt +0 -0
  29. {flexablelib-2.0.1 → flexablelib-2.0.3}/flexablelib.egg-info/top_level.txt +0 -0
  30. {flexablelib-2.0.1 → flexablelib-2.0.3}/setup.cfg +0 -0
  31. {flexablelib-2.0.1 → flexablelib-2.0.3}/setup.py +0 -0
  32. {flexablelib-2.0.1 → flexablelib-2.0.3}/tests/test_cells.py +0 -0
  33. {flexablelib-2.0.1 → flexablelib-2.0.3}/tests/test_columns.py +0 -0
  34. {flexablelib-2.0.1 → flexablelib-2.0.3}/tests/test_features.py +0 -0
  35. {flexablelib-2.0.1 → flexablelib-2.0.3}/tests/test_issues.py +0 -0
  36. {flexablelib-2.0.1 → flexablelib-2.0.3}/tests/test_readme_examples.py +0 -0
  37. {flexablelib-2.0.1 → flexablelib-2.0.3}/tests/test_styles.py +0 -0
  38. {flexablelib-2.0.1 → flexablelib-2.0.3}/tests/test_text_width.py +0 -0
  39. {flexablelib-2.0.1 → flexablelib-2.0.3}/tests/test_two_pass_measurement.py +0 -0
  40. {flexablelib-2.0.1 → flexablelib-2.0.3}/tests/test_upstream_issues.py +0 -0
  41. {flexablelib-2.0.1 → flexablelib-2.0.3}/tools/benchmark.py +0 -0
  42. {flexablelib-2.0.1 → flexablelib-2.0.3}/tools/generate_width_tables.py +0 -0
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ## [2.0.3](https://github.com/Opus-Perpetuus/flexablelib/compare/v2.0.2...v2.0.3) (2026-10-28)
6
+
7
+ ### Features
8
+
9
+ * ``to_excel`` / ``from_excel`` preserve merged cell ranges (openpyxl)
10
+
11
+ ## [2.0.2](https://github.com/Opus-Perpetuus/flexablelib/compare/v2.0.1...v2.0.2) (2026-10-28)
12
+
13
+ ### Bug Fixes
14
+
15
+ * redraw vertical-merge vertices as edges/tees instead of leftover corners
16
+
5
17
  ## [2.0.1](https://github.com/Opus-Perpetuus/flexablelib/compare/v2.0.0...v2.0.1) (2026-10-28)
6
18
 
7
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flexablelib
3
- Version: 2.0.1
3
+ Version: 2.0.3
4
4
  Summary: Flexible console and HTML tables for Python
5
5
  Home-page: https://github.com/Opus-Perpetuus/flexablelib
6
6
  Download-URL:
@@ -204,7 +204,12 @@ def _region_bounds(region, layout, index_offset):
204
204
  return None
205
205
 
206
206
  first, last = drawn[0], drawn[-1]
207
- return (layout.spans[first][0], layout.spans[last][1]), layout.spans[first], last
207
+ return (
208
+ (layout.spans[first][0], layout.spans[last][1]),
209
+ layout.spans[first],
210
+ first,
211
+ last,
212
+ )
208
213
 
209
214
 
210
215
  def apply(lines, row_line_map, regions, column_widths, composition,
@@ -235,7 +240,7 @@ def apply(lines, row_line_map, regions, column_widths, composition,
235
240
  bounds = _region_bounds(region, layout, index_offset)
236
241
  if bounds is None:
237
242
  continue
238
- (start, end), (cell_start, cell_end), last_span = bounds
243
+ (start, end), (cell_start, cell_end), first_span, last_span = bounds
239
244
  span_width = end - start - margin * 2
240
245
  if span_width < 1:
241
246
  continue
@@ -277,13 +282,16 @@ def apply(lines, row_line_map, regions, column_widths, composition,
277
282
  # Paint over the separator lines that fall inside the block, so the
278
283
  # merged region reads as one cell top to bottom.
279
284
  #
280
- # Where such a line meets the rule at the block's right edge, that
281
- # junction no longer has a line coming in from the left, so the four
282
- # way character is wrong there. The body rule's own left character is
283
- # exactly the three-way junction needed ('├' where '┼' was).
285
+ # Blanking the horizontal run removes the arms that fed each vertex.
286
+ # Rebuild those vertices from the style's own characters:
287
+ # - right edge, more columns: body left ('├' where '┼' was)
288
+ # - right edge, table border: vertical right ('│' where '┤' was)
289
+ # - left edge, prior columns: body right ('┤' where '┼' was)
290
+ # - left edge, table border: vertical left ('│' where '├' was)
284
291
  body_rule = composition.table_body_line
285
- junction = body_rule.left if body_rule is not None else None
292
+ v_lines = composition.vertical_table_body_lines
286
293
  joins_more_columns = last_span + 1 < len(layout.spans)
294
+ joins_prev_columns = first_span > 0
287
295
 
288
296
  covered = set(target_lines)
289
297
  for line_index in range(min(target_lines), max(target_lines)):
@@ -293,8 +301,17 @@ def apply(lines, row_line_map, regions, column_widths, composition,
293
301
  if visible_width(line) < end:
294
302
  continue
295
303
  rewritten = _repaint(line, start, end, blank)
296
- if junction and joins_more_columns and visible_width(rewritten) > end:
297
- rewritten = _repaint(rewritten, end, end + 1, junction)
304
+ if body_rule is not None or v_lines is not None:
305
+ if visible_width(rewritten) > end:
306
+ if joins_more_columns and body_rule is not None and body_rule.left:
307
+ rewritten = _repaint(rewritten, end, end + 1, body_rule.left)
308
+ elif not joins_more_columns and v_lines is not None and v_lines.right:
309
+ rewritten = _repaint(rewritten, end, end + 1, v_lines.right)
310
+ if start > 0:
311
+ if joins_prev_columns and body_rule is not None and body_rule.right:
312
+ rewritten = _repaint(rewritten, start - 1, start, body_rule.right)
313
+ elif not joins_prev_columns and v_lines is not None and v_lines.left:
314
+ rewritten = _repaint(rewritten, start - 1, start, v_lines.left)
298
315
  lines[line_index] = rewritten
299
316
 
300
317
  return lines
@@ -344,7 +344,8 @@ def from_excel(table_class, path, sheet=None, has_header: bool = True,
344
344
  'Install it with: pip install flexablelib[excel]'
345
345
  )
346
346
 
347
- workbook = load_workbook(filename=path, read_only=True, data_only=True)
347
+ # read_only hides merged cell ranges; we need them to rebuild merges.
348
+ workbook = load_workbook(filename=path, read_only=False, data_only=True)
348
349
  try:
349
350
  if sheet is None:
350
351
  worksheet = workbook.active
@@ -357,14 +358,44 @@ def from_excel(table_class, path, sheet=None, has_header: bool = True,
357
358
  ['' if cell is None else cell for cell in row]
358
359
  for row in worksheet.iter_rows(values_only=True)
359
360
  ]
361
+ merge_ranges = [
362
+ (merged.min_row, merged.min_col, merged.max_row, merged.max_col)
363
+ for merged in worksheet.merged_cells.ranges
364
+ ]
360
365
  finally:
361
366
  workbook.close()
362
367
 
363
368
  if not rows:
364
369
  return table_class(**options)
365
370
  if has_header:
366
- return _new_table(table_class, rows[0], rows[1:], parse_numbers,
367
- **options)
368
- width = max(len(row) for row in rows)
369
- headers = [f'column {i + 1}' for i in range(width)]
370
- return _new_table(table_class, headers, rows, parse_numbers, **options)
371
+ table = _new_table(table_class, rows[0], rows[1:], parse_numbers,
372
+ **options)
373
+ header_rows = 1
374
+ else:
375
+ width = max(len(row) for row in rows)
376
+ headers = [f'column {i + 1}' for i in range(width)]
377
+ table = _new_table(table_class, headers, rows, parse_numbers, **options)
378
+ header_rows = 0
379
+
380
+ # Excel rows/cols are 1-based. Body rows start after the header row when
381
+ # has_header is set. Skip merges that only cover the header.
382
+ for min_row, min_col, max_row, max_col in merge_ranges:
383
+ # Header-only or header-spanning merges are not table body merges.
384
+ if min_row <= header_rows:
385
+ continue
386
+ first_row = min_row - header_rows - 1
387
+ last_row = max_row - header_rows - 1
388
+ first_column = min_col - 1
389
+ last_column = max_col - 1
390
+ if first_row == last_row and first_column == last_column:
391
+ continue
392
+ if last_row < 0 or last_column < 0:
393
+ continue
394
+ if last_row >= table.row_count or last_column >= table.column_count:
395
+ continue
396
+ try:
397
+ table.merge_cells(first_row, first_column, last_row, last_column)
398
+ except ValueError:
399
+ # Overlaps or single cells from odd sheets: skip rather than fail.
400
+ continue
401
+ return table
@@ -4127,6 +4127,7 @@ class Table(object):
4127
4127
  """
4128
4128
  Build a table from a worksheet in an .xlsx file.
4129
4129
 
4130
+ Excel merged ranges are restored as ``merge_cells`` regions.
4130
4131
  Requires openpyxl: pip install flexablelib[excel]
4131
4132
  """
4132
4133
  from .readers import from_excel
@@ -4195,7 +4196,8 @@ class Table(object):
4195
4196
  Write an .xlsx workbook.
4196
4197
 
4197
4198
  Numbers are written as numbers so the spreadsheet can compute with
4198
- them. Requires openpyxl: pip install flexablelib[excel]
4199
+ them. ``merged_regions`` become real Excel merges. Requires openpyxl:
4200
+ pip install flexablelib[excel]
4199
4201
  """
4200
4202
  from .writers import to_excel
4201
4203
  return to_excel(self, path, sheet_name, include_index, autofit,
@@ -557,4 +557,26 @@ def to_excel(table, path, sheet_name: str = 'Sheet1',
557
557
  if freeze_header:
558
558
  worksheet.freeze_panes = 'A2'
559
559
 
560
+ # Mirror flexablelib merged regions as Excel merged cells. Table coordinates
561
+ # ignore the optional index column and the header row lives on Excel row 1.
562
+ col_offset = 2 if include_index else 1
563
+ for region in getattr(table, 'merged_regions', []):
564
+ start_row = region.first_row + 2
565
+ end_row = region.last_row + 2
566
+ start_col = region.first_column + col_offset
567
+ end_col = region.last_column + col_offset
568
+ top_left = worksheet.cell(row=start_row, column=start_col)
569
+ if region.value is not None:
570
+ top_left.value = _plain(region.value)
571
+ top_left.alignment = Alignment(
572
+ horizontal={'l': 'left', 'r': 'right', 'c': 'center'}.get(
573
+ region.align, 'center'),
574
+ vertical='center',
575
+ wrap_text=True,
576
+ )
577
+ worksheet.merge_cells(
578
+ start_row=start_row, start_column=start_col,
579
+ end_row=end_row, end_column=end_col,
580
+ )
581
+
560
582
  workbook.save(path)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flexablelib
3
- Version: 2.0.1
3
+ Version: 2.0.3
4
4
  Summary: Flexible console and HTML tables for Python
5
5
  Home-page: https://github.com/Opus-Perpetuus/flexablelib
6
6
  Download-URL:
@@ -28,6 +28,7 @@ flexablelib.egg-info/requires.txt
28
28
  flexablelib.egg-info/top_level.txt
29
29
  tests/test_cells.py
30
30
  tests/test_columns.py
31
+ tests/test_excel.py
31
32
  tests/test_features.py
32
33
  tests/test_issues.py
33
34
  tests/test_merges.py
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexablelib",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "Flexible console and HTML tables for Python: styles, merges, colors, widths, decimal alignment.",
5
5
  "main": "__init__.py",
6
6
  "scripts": {
@@ -0,0 +1,110 @@
1
+ """
2
+ Excel import / export.
3
+
4
+ Round-trips plain tables and merged regions through openpyxl workbooks.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from flexablelib import Table
12
+
13
+
14
+ pytest.importorskip('openpyxl')
15
+
16
+
17
+ def build_table():
18
+ table = Table()
19
+ table.add_column('Region', ['Norte', 'Norte', 'Sur', 'Sur'])
20
+ table.add_column('Month', ['Jan', 'Feb', 'Jan', 'Feb'])
21
+ table.add_column('Sales', [120.5, 98.25, 210.0, 175.75])
22
+ return table
23
+
24
+
25
+ def test_to_excel_and_from_excel_round_trip_values(tmp_path):
26
+ path = tmp_path / 'plain.xlsx'
27
+ table = build_table()
28
+ table.to_excel(path)
29
+
30
+ loaded = Table.from_excel(path)
31
+
32
+ assert loaded.headers == ['Region', 'Month', 'Sales']
33
+ assert loaded.rows[0][0] == 'Norte'
34
+ assert loaded.rows[0][2] == 120.5
35
+
36
+
37
+ def test_to_excel_writes_merged_cells(tmp_path):
38
+ from openpyxl import load_workbook
39
+
40
+ path = tmp_path / 'merged.xlsx'
41
+ table = build_table()
42
+ table.merge_cells(0, 0, last_row=1, value='Norte')
43
+ table.merge_cells(0, 1, last_column=2, value='Q1')
44
+ table.to_excel(path)
45
+
46
+ workbook = load_workbook(path)
47
+ ranges = {str(r) for r in workbook.active.merged_cells.ranges}
48
+ workbook.close()
49
+
50
+ assert 'A2:A3' in ranges
51
+ assert 'B2:C2' in ranges
52
+
53
+
54
+ def test_from_excel_restores_merged_regions(tmp_path):
55
+ path = tmp_path / 'round.xlsx'
56
+ table = build_table()
57
+ table.merge_cells(0, 0, last_row=1, value='Norte')
58
+ table.merge_cells(2, 0, last_column=1, value='Sur block')
59
+ table.to_excel(path)
60
+
61
+ loaded = Table.from_excel(path)
62
+
63
+ assert len(loaded.merged_regions) == 2
64
+ regions = {(r.first_row, r.first_column, r.last_row, r.last_column)
65
+ for r in loaded.merged_regions}
66
+ assert (0, 0, 1, 0) in regions
67
+ assert (2, 0, 2, 1) in regions
68
+ assert 'Norte' in str(loaded)
69
+ assert 'Sur block' in str(loaded)
70
+
71
+
72
+ def test_to_excel_shifts_merges_when_index_included(tmp_path):
73
+ from openpyxl import load_workbook
74
+
75
+ path = tmp_path / 'indexed.xlsx'
76
+ table = build_table()
77
+ table.show_index = True
78
+ table.merge_cells(0, 0, last_row=1, value='Norte')
79
+ table.to_excel(path, include_index=True)
80
+
81
+ workbook = load_workbook(path)
82
+ ranges = {str(r) for r in workbook.active.merged_cells.ranges}
83
+ workbook.close()
84
+
85
+ # Index column is A; body Region is B → B2:B3
86
+ assert 'B2:B3' in ranges
87
+
88
+
89
+ def test_from_excel_sheet_selection(tmp_path):
90
+ from openpyxl import Workbook
91
+
92
+ path = tmp_path / 'multi.xlsx'
93
+ workbook = Workbook()
94
+ first = workbook.active
95
+ first.title = 'One'
96
+ first.append(['A'])
97
+ first.append([1])
98
+ second = workbook.create_sheet('Two')
99
+ second.append(['X', 'Y'])
100
+ second.append(['x1', 'y1'])
101
+ second.append(['x2', 'y2'])
102
+ second.merge_cells('A2:A3')
103
+ workbook.save(path)
104
+ workbook.close()
105
+
106
+ loaded = Table.from_excel(path, sheet='Two')
107
+ assert loaded.headers == ['X', 'Y']
108
+ assert len(loaded.merged_regions) == 1
109
+ region = loaded.merged_regions[0]
110
+ assert (region.first_row, region.first_column, region.last_row, region.last_column) == (0, 0, 1, 0)
@@ -51,6 +51,65 @@ def test_a_vertical_merge_removes_the_rules_between_its_rows():
51
51
  assert rendered.count('Norte') == 1
52
52
 
53
53
 
54
+
55
+ def test_vertical_merge_turns_left_border_into_an_edge():
56
+ """Vertices on a body rule inside a vertical merge must become edges.
57
+
58
+ The horizontal arms into the merged cell are gone, so a left-border
59
+ '├' becomes the vertical edge '│', and the right junction keeps the
60
+ three-way '├' that opens the remaining columns.
61
+ """
62
+ table = build_table()
63
+ table.merge_cells(0, 0, last_row=1, value='Norte')
64
+
65
+ sep = next(
66
+ line for line in str(table).splitlines()
67
+ if '├───────┼────────┤' in line or line.startswith('│ ├')
68
+ )
69
+
70
+ assert sep.startswith('│')
71
+ assert '├───────┼────────┤' in sep
72
+ assert not sep.startswith('├')
73
+
74
+
75
+ def test_vertical_merge_in_middle_column_uses_left_and_right_tees():
76
+ table = build_table()
77
+ table.merge_cells(0, 1, last_row=1, value='M')
78
+
79
+ sep = next(line for line in str(table).splitlines() if ' ' in line and '┤' in line and line.count('├') >= 1)
80
+
81
+ assert '├────────┤ ├────────┤' in sep
82
+
83
+
84
+ def test_vertical_merge_on_last_column_turns_right_border_into_an_edge():
85
+ table = build_table()
86
+ table.merge_cells(0, 2, last_row=1, value='S')
87
+
88
+ sep = next(line for line in str(table).splitlines() if line.endswith('│') and '┤ │' in line)
89
+
90
+ assert sep.endswith('│')
91
+ assert '├────────┼───────┤ │' in sep
92
+
93
+
94
+ def test_vertical_merge_vertices_follow_grid_ascii_style():
95
+ table = build_table('grid')
96
+ table.merge_cells(0, 0, last_row=1, value='Norte')
97
+
98
+ sep = next(line for line in str(table).splitlines() if '+-------+' in line and line.startswith('|'))
99
+
100
+ assert sep.startswith('| +')
101
+
102
+
103
+ def test_vertical_merge_vertices_follow_bold_borderline_style():
104
+ table = build_table('bold_borderline')
105
+ table.merge_cells(0, 0, last_row=1, value='Norte')
106
+
107
+ sep = next(line for line in str(table).splitlines() if '╟───────┼────────╢' in line)
108
+
109
+ assert sep.startswith('║')
110
+ assert '║ ╟───────┼────────╢' in sep
111
+
112
+
54
113
  def test_a_block_merge_covers_both_directions():
55
114
  table = build_table()
56
115
  table.merge_cells(0, 0, 1, 1, value='Block')
File without changes
File without changes
File without changes
File without changes
File without changes