tableio 0.4__tar.gz → 0.5__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 (36) hide show
  1. {tableio-0.4/tableio.egg-info → tableio-0.5}/PKG-INFO +5 -3
  2. {tableio-0.4 → tableio-0.5}/README.md +2 -2
  3. {tableio-0.4 → tableio-0.5}/README_pypi.md +4 -2
  4. {tableio-0.4 → tableio-0.5}/pyproject.toml +1 -1
  5. {tableio-0.4 → tableio-0.5}/setup.py +1 -1
  6. {tableio-0.4 → tableio-0.5}/src/tableio/__init__.py +3 -1
  7. tableio-0.5/src/tableio/border_helper.py +196 -0
  8. {tableio-0.4 → tableio-0.5}/src/tableio/capability.py +5 -1
  9. {tableio-0.4 → tableio-0.5}/src/tableio/tableio.py +66 -37
  10. {tableio-0.4 → tableio-0.5}/src/tableio/tableio_csv.py +2 -1
  11. {tableio-0.4 → tableio-0.5}/src/tableio/tableio_excel_openpyxl.py +33 -1
  12. {tableio-0.4 → tableio-0.5}/src/tableio/tableio_excel_pylightxl.py +2 -1
  13. {tableio-0.4 → tableio-0.5}/src/tableio/tableio_excel_xlsxwriter.py +64 -15
  14. {tableio-0.4 → tableio-0.5}/src/tableio/tableio_mformatbased.py +2 -1
  15. {tableio-0.4 → tableio-0.5}/src/tableio/tableio_ods_odfdo.py +88 -13
  16. {tableio-0.4 → tableio-0.5}/src/tableio/tableio_spreadsheetbased.py +42 -18
  17. tableio-0.5/src/tableio/tableio_types.py +132 -0
  18. {tableio-0.4 → tableio-0.5/tableio.egg-info}/PKG-INFO +5 -3
  19. {tableio-0.4 → tableio-0.5}/tableio.egg-info/SOURCES.txt +1 -0
  20. tableio-0.4/src/tableio/tableio_types.py +0 -61
  21. {tableio-0.4 → tableio-0.5}/LICENSE.txt +0 -0
  22. {tableio-0.4 → tableio-0.5}/setup.cfg +0 -0
  23. {tableio-0.4 → tableio-0.5}/src/tableio/_archive_rewrite.py +0 -0
  24. {tableio-0.4 → tableio-0.5}/src/tableio/color.py +0 -0
  25. {tableio-0.4 → tableio-0.5}/src/tableio/factory.py +0 -0
  26. {tableio-0.4 → tableio-0.5}/src/tableio/optional_args.py +0 -0
  27. {tableio-0.4 → tableio-0.5}/src/tableio/py.typed +0 -0
  28. {tableio-0.4 → tableio-0.5}/src/tableio/reg_pkg_formats.py +0 -0
  29. {tableio-0.4 → tableio-0.5}/src/tableio/tableio_excelbased.py +0 -0
  30. {tableio-0.4 → tableio-0.5}/src/tableio/tableio_mformat.py +0 -0
  31. {tableio-0.4 → tableio-0.5}/src/tableio/tableio_textbased.py +0 -0
  32. {tableio-0.4 → tableio-0.5}/src/tableio/value_type.py +0 -0
  33. {tableio-0.4 → tableio-0.5}/src/tableio/valueconversion.py +0 -0
  34. {tableio-0.4 → tableio-0.5}/tableio.egg-info/dependency_links.txt +0 -0
  35. {tableio-0.4 → tableio-0.5}/tableio.egg-info/requires.txt +0 -0
  36. {tableio-0.4 → tableio-0.5}/tableio.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tableio
3
- Version: 0.4
3
+ Version: 0.5
4
4
  Summary: Uniform way to write table data to and read from different file formats
5
5
  Author: Tom Björkholm
6
6
  Author-email: Tom Björkholm <klausuler_linnet0q@icloud.com>
@@ -115,6 +115,7 @@ The main features are:
115
115
  - using multiple sheets in spreadsheets
116
116
  - finding location where some data is present in spreadsheet and doing modifications
117
117
  at that position or at positions relative to that position.
118
+ - several border styles for tables
118
119
 
119
120
  ## Example programs
120
121
 
@@ -138,6 +139,7 @@ a better start by reading the examples.
138
139
 
139
140
  | Version | Date | Python version | Comment |
140
141
  |---------|-------------|----------------|-----------------------------|
142
+ | 0.5 | 2026 Apr 07 | 3.12 or newer | Added table border styles |
141
143
  | 0.4 | 2026 Apr 06 | 3.12 or newer | Fix .xlsx and .ods syntax |
142
144
  | 0.3 | 2026 Apr 01 | 3.12 or newer | Improved API, more backends |
143
145
  | 0.2 | 2026 Mar 28 | 3.12 or newer | More features, API changes |
@@ -145,8 +147,8 @@ a better start by reading the examples.
145
147
 
146
148
  ## Test summary
147
149
 
148
- - Test result: 1126 passed in 17s
150
+ - Test result: 1173 passed in 18s
149
151
  - No flake8 warnings.
150
152
  - No mypy errors found.
151
- - Built version(s): 0.4
153
+ - Built version(s): 0.5
152
154
  - Build and test using Python 3.14.3
@@ -93,8 +93,8 @@ information about the build system. This README can also be viewed at
93
93
 
94
94
  ## Test summary
95
95
 
96
- - Test result: 1126 passed in 17s
96
+ - Test result: 1173 passed in 18s
97
97
  - No flake8 warnings.
98
98
  - No mypy errors found.
99
- - Built version(s): 0.4
99
+ - Built version(s): 0.5
100
100
  - Build and test using Python 3.14.3
@@ -87,6 +87,7 @@ The main features are:
87
87
  - using multiple sheets in spreadsheets
88
88
  - finding location where some data is present in spreadsheet and doing modifications
89
89
  at that position or at positions relative to that position.
90
+ - several border styles for tables
90
91
 
91
92
  ## Example programs
92
93
 
@@ -110,6 +111,7 @@ a better start by reading the examples.
110
111
 
111
112
  | Version | Date | Python version | Comment |
112
113
  |---------|-------------|----------------|-----------------------------|
114
+ | 0.5 | 2026 Apr 07 | 3.12 or newer | Added table border styles |
113
115
  | 0.4 | 2026 Apr 06 | 3.12 or newer | Fix .xlsx and .ods syntax |
114
116
  | 0.3 | 2026 Apr 01 | 3.12 or newer | Improved API, more backends |
115
117
  | 0.2 | 2026 Mar 28 | 3.12 or newer | More features, API changes |
@@ -117,8 +119,8 @@ a better start by reading the examples.
117
119
 
118
120
  ## Test summary
119
121
 
120
- - Test result: 1126 passed in 17s
122
+ - Test result: 1173 passed in 18s
121
123
  - No flake8 warnings.
122
124
  - No mypy errors found.
123
- - Built version(s): 0.4
125
+ - Built version(s): 0.5
124
126
  - Build and test using Python 3.14.3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tableio"
7
- version = "0.4"
7
+ version = "0.5"
8
8
  authors = [
9
9
  { name="Tom Björkholm", email="klausuler_linnet0q@icloud.com" },
10
10
  ]
@@ -5,7 +5,7 @@ from setuptools import setup
5
5
 
6
6
  setup(
7
7
  name='tableio',
8
- version='0.4',
8
+ version='0.5',
9
9
  description='Uniform way to write table data to and read from different ' \
10
10
  'file formats',
11
11
  author='Tom Björkholm',
@@ -11,7 +11,8 @@ from tableio.color import Color
11
11
  from tableio.factory import create_tableio, filter_args_tableio, \
12
12
  list_implementations_tableio, list_registered_tableio
13
13
  from tableio.optional_args import CsvDialect, OptionalArgs, OptionalArgsDict
14
- from tableio.tableio import Box, FileAccess, Position, TableIO
14
+ from tableio.tableio import TableIO
15
+ from tableio.tableio_types import TableBorderStyle, Box, FileAccess, Position
15
16
  from tableio.value_type import CellT, DictData, DictDataMap, Fmt, \
16
17
  FmtDictData, FmtDictRow, FmtListData, FmtListRow, ListData, \
17
18
  ListDataSeq, ReadResult, Value, ValueFmt
@@ -20,6 +21,7 @@ __all__ = ['create_tableio',
20
21
  'TableIO',
21
22
  'FileAccess',
22
23
  'Box',
24
+ 'TableBorderStyle',
23
25
  'Position',
24
26
  'OptionalArgs',
25
27
  'OptionalArgsDict',
@@ -0,0 +1,196 @@
1
+ #! /usr/bin/env python3
2
+ """Helpers for working with normalized table borders."""
3
+
4
+ # Copyright (c) 2026 Tom Björkholm
5
+ # MIT License
6
+
7
+ from enum import IntEnum
8
+ from typing import NamedTuple, Optional
9
+
10
+ from tableio.capability import Capabilities, CapabilityNotSupported, \
11
+ Strictness
12
+ from tableio.tableio_types import TableBorderStyle
13
+ from tableio.value_type import Fmt
14
+
15
+
16
+ class BorderWeight(IntEnum):
17
+ """Semantic weight for one border edge."""
18
+
19
+ NONE = 0
20
+ THIN = 1
21
+ THICK = 2
22
+
23
+
24
+ class CellBorder(NamedTuple):
25
+ """Border weights for the four edges of one cell."""
26
+
27
+ top: BorderWeight
28
+ right: BorderWeight
29
+ bottom: BorderWeight
30
+ left: BorderWeight
31
+
32
+
33
+ NO_BORDERS = CellBorder(
34
+ BorderWeight.NONE,
35
+ BorderWeight.NONE,
36
+ BorderWeight.NONE,
37
+ BorderWeight.NONE)
38
+ """The absence of borders on all four cell edges."""
39
+
40
+
41
+ class CellStyleState(NamedTuple):
42
+ """Combined cell formatting state used by style-caching backends."""
43
+
44
+ fmt: Fmt = Fmt()
45
+ font_size: Optional[int] = None
46
+ borders: CellBorder = NO_BORDERS
47
+
48
+
49
+ DEFAULT_CELL_STYLE = CellStyleState()
50
+ """The default cell formatting state with no extra styling."""
51
+
52
+
53
+ class _BorderComponents(NamedTuple):
54
+ """Normalized border weights for one table style."""
55
+
56
+ outer: BorderWeight
57
+ first_row_separator: BorderWeight
58
+ inner_horizontal: BorderWeight
59
+ inner_vertical: BorderWeight
60
+
61
+
62
+ def _thicker(first: BorderWeight, second: BorderWeight) -> BorderWeight:
63
+ """Return the thicker of two border weights."""
64
+ if first >= second:
65
+ return first
66
+ return second
67
+
68
+
69
+ _STYLE_COMPONENTS: dict[TableBorderStyle, _BorderComponents] = {
70
+ TableBorderStyle.NONE: _BorderComponents(
71
+ outer=BorderWeight.NONE,
72
+ first_row_separator=BorderWeight.NONE,
73
+ inner_horizontal=BorderWeight.NONE,
74
+ inner_vertical=BorderWeight.NONE),
75
+ TableBorderStyle.OUTER_THIN: _BorderComponents(
76
+ outer=BorderWeight.THIN,
77
+ first_row_separator=BorderWeight.NONE,
78
+ inner_horizontal=BorderWeight.NONE,
79
+ inner_vertical=BorderWeight.NONE),
80
+ TableBorderStyle.OUTER_THICK: _BorderComponents(
81
+ outer=BorderWeight.THICK,
82
+ first_row_separator=BorderWeight.NONE,
83
+ inner_horizontal=BorderWeight.NONE,
84
+ inner_vertical=BorderWeight.NONE),
85
+ TableBorderStyle.OUTER_FIRST_ROW_THIN: _BorderComponents(
86
+ outer=BorderWeight.THIN,
87
+ first_row_separator=BorderWeight.THIN,
88
+ inner_horizontal=BorderWeight.NONE,
89
+ inner_vertical=BorderWeight.NONE),
90
+ TableBorderStyle.OUTER_FIRST_ROW_THICK: _BorderComponents(
91
+ outer=BorderWeight.THICK,
92
+ first_row_separator=BorderWeight.THICK,
93
+ inner_horizontal=BorderWeight.NONE,
94
+ inner_vertical=BorderWeight.NONE),
95
+ TableBorderStyle.OUTER_THICK_FIRST_ROW_THIN: _BorderComponents(
96
+ outer=BorderWeight.THICK,
97
+ first_row_separator=BorderWeight.THIN,
98
+ inner_horizontal=BorderWeight.NONE,
99
+ inner_vertical=BorderWeight.NONE),
100
+ TableBorderStyle.OUTER_FIRST_ROW_THICK_VERTICAL_THIN:
101
+ _BorderComponents(
102
+ outer=BorderWeight.THICK,
103
+ first_row_separator=BorderWeight.THICK,
104
+ inner_horizontal=BorderWeight.NONE,
105
+ inner_vertical=BorderWeight.THIN),
106
+ TableBorderStyle.OUTER_FIRST_ROW_THICK_INNER_THIN: _BorderComponents(
107
+ outer=BorderWeight.THICK,
108
+ first_row_separator=BorderWeight.THICK,
109
+ inner_horizontal=BorderWeight.THIN,
110
+ inner_vertical=BorderWeight.THIN),
111
+ TableBorderStyle.OUTER_THICK_INNER_THIN: _BorderComponents(
112
+ outer=BorderWeight.THICK,
113
+ first_row_separator=BorderWeight.NONE,
114
+ inner_horizontal=BorderWeight.THIN,
115
+ inner_vertical=BorderWeight.THIN),
116
+ TableBorderStyle.ALL_THIN: _BorderComponents(
117
+ outer=BorderWeight.THIN,
118
+ first_row_separator=BorderWeight.NONE,
119
+ inner_horizontal=BorderWeight.THIN,
120
+ inner_vertical=BorderWeight.THIN),
121
+ TableBorderStyle.ALL_THICK: _BorderComponents(
122
+ outer=BorderWeight.THICK,
123
+ first_row_separator=BorderWeight.NONE,
124
+ inner_horizontal=BorderWeight.THICK,
125
+ inner_vertical=BorderWeight.THICK)
126
+ }
127
+
128
+
129
+ class BorderHelper:
130
+ """Normalize one public table-border style for backend use."""
131
+
132
+ def __init__(self, border_style: TableBorderStyle,
133
+ capabilities: Capabilities):
134
+ """Initialize the normalized table-border helper."""
135
+ self.border_style = self._checked_style(border_style, capabilities)
136
+ components = _STYLE_COMPONENTS[self.border_style]
137
+ self.outer = components.outer
138
+ self.first_row_separator = components.first_row_separator
139
+ self.inner_horizontal = components.inner_horizontal
140
+ self.inner_vertical = components.inner_vertical
141
+
142
+ @staticmethod
143
+ def _checked_style(border_style: TableBorderStyle,
144
+ capabilities: Capabilities) -> TableBorderStyle:
145
+ """Return the effective border style after capability handling."""
146
+ if border_style == TableBorderStyle.NONE:
147
+ return border_style
148
+ capability = capabilities.can_write_borders
149
+ if capability.supported:
150
+ return border_style
151
+ if capability.strictness == Strictness.IGNORE:
152
+ return TableBorderStyle.NONE
153
+ raise CapabilityNotSupported('write borders to the table')
154
+
155
+ def has_borders(self) -> bool:
156
+ """Return whether any border is active in this normalized style."""
157
+ return (
158
+ self.outer != BorderWeight.NONE or
159
+ self.first_row_separator != BorderWeight.NONE or
160
+ self.inner_horizontal != BorderWeight.NONE or
161
+ self.inner_vertical != BorderWeight.NONE)
162
+
163
+ def _horizontal_boundary(self, boundary_index: int,
164
+ row_count: int) -> BorderWeight:
165
+ """Return the weight of one horizontal table boundary."""
166
+ if boundary_index in (0, row_count):
167
+ weight = self.outer
168
+ else:
169
+ weight = self.inner_horizontal
170
+ if boundary_index == 1:
171
+ weight = _thicker(weight, self.first_row_separator)
172
+ return weight
173
+
174
+ def _vertical_boundary(self, boundary_index: int,
175
+ column_count: int) -> BorderWeight:
176
+ """Return the weight of one vertical table boundary."""
177
+ if boundary_index in (0, column_count):
178
+ return self.outer
179
+ return self.inner_vertical
180
+
181
+ def cell_border(self, row_index: int, column_index: int, row_count: int,
182
+ column_count: int) -> CellBorder:
183
+ """Return the four border edges for one cell in a table."""
184
+ if row_count < 1:
185
+ raise ValueError('row_count must be at least 1.')
186
+ if column_count < 1:
187
+ raise ValueError('column_count must be at least 1.')
188
+ if not 0 <= row_index < row_count:
189
+ raise ValueError('row_index is outside the table.')
190
+ if not 0 <= column_index < column_count:
191
+ raise ValueError('column_index is outside the table.')
192
+ return CellBorder(
193
+ top=self._horizontal_boundary(row_index, row_count),
194
+ right=self._vertical_boundary(column_index + 1, column_count),
195
+ bottom=self._horizontal_boundary(row_index + 1, row_count),
196
+ left=self._vertical_boundary(column_index, column_count))
@@ -90,6 +90,9 @@ class Capabilities(NamedTuple):
90
90
  can_find_value_position: SingleCapability = SingleCapability()
91
91
  """The reader/writer class can find the position of a value."""
92
92
 
93
+ can_write_borders: SingleCapability = SingleCapability()
94
+ """The writer class can write borders to the table."""
95
+
93
96
 
94
97
  def single_capability_match(offered: SingleCapability,
95
98
  will_use: SingleCapability,
@@ -246,4 +249,5 @@ CAP_ALL_IMPLEMENTED = Capabilities(can_read=CAP_IMPLEMENTED,
246
249
  can_read_box=CAP_IMPLEMENTED,
247
250
  can_write_highlight=CAP_IMPLEMENTED,
248
251
  multi_sheet=CAP_IMPLEMENTED,
249
- can_find_value_position=CAP_IMPLEMENTED)
252
+ can_find_value_position=CAP_IMPLEMENTED,
253
+ can_write_borders=CAP_IMPLEMENTED)
@@ -12,11 +12,13 @@ from typing import NamedTuple, Callable, Optional
12
12
  from mformat.mformat import PathLike
13
13
  from tableio.capability import Capabilities, SingleCapability, Strictness, \
14
14
  CapabilityNotSupported
15
- from tableio.tableio_types import Box, Descriptor, FileAccess, Position
15
+ from tableio.tableio_types import Box, Descriptor, FileAccess, Position, \
16
+ TableBorderStyle
16
17
  from tableio.value_type import CellT, ListDataSeq, DictDataMap, \
17
18
  normalize_dict_data, ReadResult, ListData, Value, Fmt, DictData, \
18
19
  FmtListData, FmtDictData, FmtDictRow, row_strip_format_list, \
19
20
  row_strip_format_dict
21
+ from tableio.border_helper import BorderHelper
20
22
 
21
23
  __all__ = ['Box', 'Descriptor', 'FileAccess', 'Position', 'TableIO']
22
24
 
@@ -174,6 +176,8 @@ class TableIO:
174
176
  """If True, data will be written as a range that can be filtered."""
175
177
  box: Optional[Box]
176
178
  """The box to write the data into."""
179
+ borders: BorderHelper
180
+ """The normalized borders of the table."""
177
181
 
178
182
  class ImplMetaForDictWrite(NamedTuple):
179
183
  """Meta data for writing dict table to pass to implementation."""
@@ -185,22 +189,26 @@ class TableIO:
185
189
  first_row_format: Optional[Fmt]
186
190
  """The format specification for the first row."""
187
191
 
188
- def write_table_listdata(self, data: ListDataSeq[CellT],
189
- filtered_data_range: bool = False,
190
- box: Optional[Box] = None) -> Position:
192
+ def write_table_listdata(
193
+ self, data: ListDataSeq[CellT],
194
+ filtered_data_range: bool = False,
195
+ box: Optional[Box] = None,
196
+ border_style: TableBorderStyle = TableBorderStyle.NONE) -> \
197
+ Position:
191
198
  """Write a table of list data to the file.
192
199
 
193
200
  Write a table of list data to the file.
194
201
  If a box is provided the data will be written into the box.
195
202
  The data must fit into the box.
196
- Notice when spefifying a box: It is not allowed to write a
203
+ Notice when specifying a box: It is not allowed to write a
197
204
  table that partly overwrites an existing table.
198
205
  Args:
199
206
  data: The list data to write.
200
207
  filtered_data_range: If True, the data written will be
201
208
  marked as a data range that can be filtered.
202
209
  box: The box to write the data into. If box.bottom or box.right is
203
- not None, the data must fill the box.
210
+ not None, the data must fill the box.
211
+ border_style: The border style to apply to the written table.
204
212
  Raises:
205
213
  ValueError: If the data shape is invalid or does not fit in box.
206
214
  CapabilityNotSupported: If a requested capability is unsupported
@@ -214,27 +222,33 @@ class TableIO:
214
222
  c_box = self._check_box_write(box)
215
223
  self._check_listdimensions(data, c_box)
216
224
  c_filt_range = self._check_filtered_data_range(filtered_data_range)
225
+ c_borders = self._check_border_style(border_style)
217
226
  impl_meta: TableIO.ImplMetaForWrite = \
218
227
  TableIO.ImplMetaForWrite(filtered_data_range=c_filt_range,
219
- box=c_box)
228
+ box=c_box,
229
+ borders=c_borders)
220
230
  return self._write_table_listdata(data=data, impl_meta=impl_meta)
221
231
 
222
- def write_table_fmtlistdata(self, data: FmtListData,
223
- filtered_data_range: bool = False,
224
- box: Optional[Box] = None) -> Position:
232
+ def write_table_fmtlistdata(
233
+ self, data: FmtListData,
234
+ filtered_data_range: bool = False,
235
+ box: Optional[Box] = None,
236
+ border_style: TableBorderStyle = TableBorderStyle.NONE) -> \
237
+ Position:
225
238
  """Write a table of list data to the file.
226
239
 
227
240
  Write a table of list data to the file.
228
241
  If a box is provided the data will be written into the box.
229
242
  The data must fit into the box.
230
- Notice when spefifying a box: It is not allowed to write a
243
+ Notice when specifying a box: It is not allowed to write a
231
244
  table that partly overwrites an existing table.
232
245
  Args:
233
246
  data: The list data to write.
234
247
  filtered_data_range: If True, the data written will be
235
248
  marked as a data range that can be filtered.
236
249
  box: The box to write the data into. If box.bottom or box.right is
237
- not None, the data must fill the box.
250
+ not None, the data must fill the box.
251
+ border_style: The border style to apply to the written table.
238
252
  Raises:
239
253
  ValueError: If the data shape is invalid or does not fit in box.
240
254
  CapabilityNotSupported: If a requested capability is unsupported
@@ -248,25 +262,28 @@ class TableIO:
248
262
  c_box = self._check_box_write(box)
249
263
  self._check_listdimensions(row_strip_format_list(data), c_box)
250
264
  c_filt_range = self._check_filtered_data_range(filtered_data_range)
265
+ c_borders = self._check_border_style(border_style)
251
266
  impl_meta: TableIO.ImplMetaForWrite = \
252
267
  TableIO.ImplMetaForWrite(filtered_data_range=c_filt_range,
253
- box=c_box)
268
+ box=c_box, borders=c_borders)
254
269
  return self._write_table_fmtlistdata(data=data, impl_meta=impl_meta)
255
270
 
256
- def write_table_dictdata(self, # pylint: disable=too-many-arguments,too-many-positional-arguments # noqa: E501
257
- data: DictDataMap[CellT],
258
- column_order: list[str],
259
- first_row_format: Optional[Fmt] = None,
260
- missing_ok: bool = False,
261
- extra_ok: bool = False,
262
- filtered_data_range: bool = False,
263
- box: Optional[Box] = None) -> Position:
271
+ def write_table_dictdata( # pylint: disable=too-many-arguments,too-many-positional-arguments # noqa: E501
272
+ self, data: DictDataMap[CellT],
273
+ column_order: list[str],
274
+ first_row_format: Optional[Fmt] = None,
275
+ missing_ok: bool = False,
276
+ extra_ok: bool = False,
277
+ filtered_data_range: bool = False,
278
+ box: Optional[Box] = None,
279
+ border_style: TableBorderStyle = TableBorderStyle.NONE) -> \
280
+ Position:
264
281
  """Write a table of dict data to the file.
265
282
 
266
283
  Write a table of dict data to the file.
267
284
  If a box is provided the data will be written into the box.
268
285
  The data must fit into the box.
269
- Notice when spefifying a box: It is not allowed to write a
286
+ Notice when specifying a box: It is not allowed to write a
270
287
  table that partly overwrites an existing table.
271
288
  Args:
272
289
  data: The dict data to write.
@@ -284,7 +301,8 @@ class TableIO:
284
301
  filtered_data_range: If True, the data written will be
285
302
  marked as a data range that can be filtered.
286
303
  box: The box to write the data into. If box.bottom or box.right is
287
- not None, the data must fill the box.
304
+ not None, the data must fill the box.
305
+ border_style: The border style to apply to the written table.
288
306
  Raises:
289
307
  ValueError: If missing_ok is False and data is missing for a
290
308
  column in the column_order.
@@ -303,29 +321,32 @@ class TableIO:
303
321
  ndata = normalize_dict_data(data, column_order, missing_ok, extra_ok)
304
322
  self._check_dictdimensions(ndata, c_box)
305
323
  c_filt_range = self._check_filtered_data_range(filtered_data_range)
324
+ c_borders = self._check_border_style(border_style)
306
325
  common_impl: TableIO.ImplMetaForWrite = \
307
326
  TableIO.ImplMetaForWrite(filtered_data_range=c_filt_range,
308
- box=c_box)
327
+ box=c_box, borders=c_borders)
309
328
  impl_meta: TableIO.ImplMetaForDictWrite = \
310
329
  TableIO.ImplMetaForDictWrite(common_impl=common_impl,
311
330
  column_order=column_order,
312
331
  first_row_format=first_row_format)
313
332
  return self._write_table_dictdata(data=ndata, impl_meta=impl_meta)
314
333
 
315
- def write_table_fmtdictdata(self, # pylint: disable=too-many-arguments,too-many-positional-arguments # noqa: E501
316
- data: FmtDictData,
317
- column_order: list[str],
318
- first_row_format: Optional[Fmt] = None,
319
- missing_ok: bool = False,
320
- extra_ok: bool = False,
321
- filtered_data_range: bool = False,
322
- box: Optional[Box] = None) -> Position:
334
+ def write_table_fmtdictdata( # pylint: disable=too-many-arguments,too-many-positional-arguments # noqa: E501
335
+ self, data: FmtDictData,
336
+ column_order: list[str],
337
+ first_row_format: Optional[Fmt] = None,
338
+ missing_ok: bool = False,
339
+ extra_ok: bool = False,
340
+ filtered_data_range: bool = False,
341
+ box: Optional[Box] = None,
342
+ border_style: TableBorderStyle = TableBorderStyle.NONE) -> \
343
+ Position:
323
344
  """Write a table of dict data to the file.
324
345
 
325
346
  Write a table of dict data to the file.
326
347
  If a box is provided the data will be written into the box.
327
348
  The data must fit into the box.
328
- Notice when spefifying a box: It is not allowed to write a
349
+ Notice when specifying a box: It is not allowed to write a
329
350
  table that partly overwrites an existing table.
330
351
  Args:
331
352
  data: The dict data to write.
@@ -343,7 +364,8 @@ class TableIO:
343
364
  filtered_data_range: If True, the data written will be
344
365
  marked as a data range that can be filtered.
345
366
  box: The box to write the data into. If box.bottom or box.right is
346
- not None, the data must fill the box.
367
+ not None, the data must fill the box.
368
+ border_style: The border style to apply to the written table.
347
369
  Raises:
348
370
  ValueError: If missing_ok is False and data is missing for a
349
371
  column in the column_order.
@@ -370,10 +392,12 @@ class TableIO:
370
392
  FmtDictRow(values=row_values, fmt=row.fmt)
371
393
  for row, row_values in zip(data, normalized_values,
372
394
  strict=True)]
373
- c_filt_range = self._check_filtered_data_range(filtered_data_range)
374
395
  common_impl: TableIO.ImplMetaForWrite = \
375
- TableIO.ImplMetaForWrite(filtered_data_range=c_filt_range,
376
- box=c_box)
396
+ TableIO.ImplMetaForWrite(
397
+ filtered_data_range=self._check_filtered_data_range(
398
+ filtered_data_range),
399
+ box=c_box,
400
+ borders=self._check_border_style(border_style))
377
401
  impl_meta: TableIO.ImplMetaForDictWrite = \
378
402
  TableIO.ImplMetaForDictWrite(common_impl=common_impl,
379
403
  column_order=column_order,
@@ -749,6 +773,11 @@ class TableIO:
749
773
  return False
750
774
  raise CapabilityNotSupported('write a filtered data range')
751
775
 
776
+ def _check_border_style(self, border_style: TableBorderStyle) -> \
777
+ BorderHelper:
778
+ """Check if the requested table border style is supported."""
779
+ return BorderHelper(border_style, self.get_capabilities())
780
+
752
781
  def _write_heading(self, heading: str, level: int) -> Position:
753
782
  """Write a heading to the file.
754
783
 
@@ -181,7 +181,8 @@ class TableIOCsv(TableIOTextBased):
181
181
  filtered_data_range=not_sup_ignore,
182
182
  can_write_box=not_sup_strict,
183
183
  can_read_box=not_sup_strict,
184
- can_write_highlight=not_sup_ignore)
184
+ can_write_highlight=not_sup_ignore,
185
+ can_write_borders=not_sup_ignore)
185
186
 
186
187
  def _end_state(self) -> None:
187
188
  """End the state of the CSV file."""
@@ -8,13 +8,14 @@ from pathlib import Path
8
8
  from typing import Callable, Optional
9
9
  from xml.etree import ElementTree as ET
10
10
  from openpyxl import Workbook, load_workbook
11
- from openpyxl.styles import Font, PatternFill
11
+ from openpyxl.styles import Border, Font, PatternFill, Side
12
12
  from openpyxl.utils.cell import get_column_letter, range_boundaries
13
13
  from openpyxl.worksheet.table import Table
14
14
  from openpyxl.worksheet.worksheet import Worksheet
15
15
  from mformat.mformat import PathLike
16
16
  from tableio._archive_rewrite import rewrite_zip_archive, \
17
17
  temporary_output_path
18
+ from tableio.border_helper import BorderWeight, CellBorder
18
19
  from tableio.color import Color
19
20
  from tableio.tableio import Descriptor, FileAccess
20
21
  from tableio.tableio_excelbased import TableIOExcelBased
@@ -92,6 +93,7 @@ class TableIOExcelOpenPyXL(TableIOExcelBased):
92
93
  self.read_workbook: Optional[Workbook] = None
93
94
  self.worksheet: Optional[Worksheet] = None
94
95
  self.read_worksheet: Optional[Worksheet] = None
96
+ self._border_cache: dict[CellBorder, Border] = {}
95
97
 
96
98
  @classmethod
97
99
  def get_capabilities(cls) -> Capabilities:
@@ -109,6 +111,7 @@ class TableIOExcelOpenPyXL(TableIOExcelBased):
109
111
  """Open the Excel workbook."""
110
112
  if self.workbook is not None:
111
113
  raise RuntimeError(f'File {self.file_name} already open')
114
+ self._border_cache = {}
112
115
  if self.file_access == FileAccess.CREATE:
113
116
  workbook = Workbook()
114
117
  self.workbook = workbook
@@ -256,6 +259,35 @@ class TableIOExcelOpenPyXL(TableIOExcelBased):
256
259
  cell.font = Font(bold=fmt.bold, italic=fmt.italic)
257
260
  cell.fill = self._highlight_fill(fmt.highlight)
258
261
 
262
+ @staticmethod
263
+ def _border_side(weight: BorderWeight) -> Side:
264
+ """Return one OpenPyXL side object for the requested weight."""
265
+ if weight == BorderWeight.NONE:
266
+ return Side()
267
+ if weight == BorderWeight.THIN:
268
+ return Side(style='thin')
269
+ return Side(style='medium')
270
+
271
+ def _border_value(self, borders: CellBorder) -> Border:
272
+ """Return one cached OpenPyXL border object."""
273
+ cached = self._border_cache.get(borders)
274
+ if cached is not None:
275
+ return cached
276
+ border = Border(
277
+ left=self._border_side(borders.left),
278
+ right=self._border_side(borders.right),
279
+ top=self._border_side(borders.top),
280
+ bottom=self._border_side(borders.bottom))
281
+ self._border_cache[borders] = border
282
+ return border
283
+
284
+ def _set_cell_borders(self, sheet: object, row: int, column: int,
285
+ borders: CellBorder) -> None:
286
+ """Apply normalized borders to one worksheet cell."""
287
+ worksheet = get_checked_type(sheet, Worksheet)
288
+ cell = worksheet.cell(row=row + 1, column=column + 1)
289
+ cell.border = self._border_value(borders)
290
+
259
291
  def _apply_heading_style(self, row: int, column: int, level: int) -> None:
260
292
  """Apply the heading font to one worksheet cell."""
261
293
  assert self.worksheet is not None
@@ -341,7 +341,8 @@ class TableIOExcelPylightxl(TableIOExcelBased):
341
341
  can_read_box=CAP_IMPLEMENTED,
342
342
  can_write_highlight=CAP_IGNORED,
343
343
  multi_sheet=CAP_IMPLEMENTED,
344
- can_find_value_position=CAP_IMPLEMENTED)
344
+ can_find_value_position=CAP_IMPLEMENTED,
345
+ can_write_borders=CAP_IGNORED)
345
346
 
346
347
  def open(self) -> None:
347
348
  """Open the workbook."""