Coverage for src / ezxl / io / _formatters.py: 40%

123 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-29 22:41 +0000

1# /////////////////////////////////////////////////////////////// 

2# _formatters - Closed-file Excel formatting via openpyxl 

3# Project: EzXl 

4# /////////////////////////////////////////////////////////////// 

5 

6""" 

7ExcelFormatter — fluent API for formatting Excel files without COM. 

8 

9Operates exclusively on **closed** workbook files using openpyxl. All 

10formatting operations are buffered internally and applied in a single 

11pass when ``save()`` is called. 

12 

13This module has no dependency on pywin32 or a running Excel process. 

14""" 

15 

16from __future__ import annotations 

17 

18# /////////////////////////////////////////////////////////////// 

19# IMPORTS 

20# /////////////////////////////////////////////////////////////// 

21# Standard library imports 

22from dataclasses import dataclass 

23from pathlib import Path 

24from typing import Any, cast 

25 

26# Third-party imports 

27import openpyxl 

28from ezplog.lib_mode import get_logger, get_printer 

29from openpyxl.styles import ( 

30 Alignment, 

31 Border, 

32 Font, 

33 PatternFill, 

34 Side, 

35) 

36 

37# /////////////////////////////////////////////////////////////// 

38# CONSTANTS 

39# /////////////////////////////////////////////////////////////// 

40 

41logger = get_logger(__name__) 

42printer = get_printer() 

43 

44# /////////////////////////////////////////////////////////////// 

45# INTERNAL DATA STRUCTURES 

46# /////////////////////////////////////////////////////////////// 

47# Each operation is stored as a dataclass so that save() can apply them 

48# in insertion order without repeatedly touching the openpyxl workbook. 

49 

50 

51@dataclass 

52class _ColumnWidthOp: 

53 col: str 

54 width: float 

55 

56 

57@dataclass 

58class _RowHeightOp: 

59 row: int 

60 height: float 

61 

62 

63@dataclass 

64class _FontOp: 

65 ref: str 

66 bold: bool 

67 italic: bool 

68 size: int | None 

69 color: str | None # hex without '#' 

70 

71 

72@dataclass 

73class _FillOp: 

74 ref: str 

75 color: str # hex without '#' 

76 

77 

78@dataclass 

79class _BorderOp: 

80 ref: str 

81 style: str 

82 

83 

84@dataclass 

85class _AlignOp: 

86 ref: str 

87 horizontal: str | None 

88 vertical: str | None 

89 wrap: bool 

90 

91 

92_Operation = _ColumnWidthOp | _RowHeightOp | _FontOp | _FillOp | _BorderOp | _AlignOp 

93 

94# /////////////////////////////////////////////////////////////// 

95# CLASSES 

96# /////////////////////////////////////////////////////////////// 

97 

98 

99class ExcelFormatter: 

100 """Fluent formatter for closed Excel workbook files. 

101 

102 All formatting operations are buffered and applied in a single write 

103 pass when ``save()`` is called. The workbook is opened with openpyxl 

104 only at save time, minimising I/O overhead. 

105 

106 The API is intentionally flat: no sheet selector is exposed here. The 

107 formatter operates on the **active sheet** of the workbook. Consumer 

108 libraries that need multi-sheet formatting should instantiate one 

109 ``ExcelFormatter`` per sheet operation. 

110 

111 Args: 

112 path: Path to an existing ``.xlsx`` workbook file. 

113 

114 Raises: 

115 FileNotFoundError: If ``path`` does not exist. 

116 ImportError: If openpyxl is not installed. 

117 

118 Example: 

119 >>> ( 

120 ... ExcelFormatter("report.xlsx") 

121 ... .column_width("A", 20) 

122 ... .font("A1", bold=True, size=14, color="FFFFFF") 

123 ... .fill("A1", "4F81BD") 

124 ... .save() 

125 ... ) 

126 """ 

127 

128 # /////////////////////////////////////////////////////////////// 

129 # INIT 

130 # /////////////////////////////////////////////////////////////// 

131 

132 def __init__(self, path: str | Path) -> None: 

133 self._path = Path(path).resolve() 

134 if not self._path.exists(): 

135 raise FileNotFoundError(f"ExcelFormatter: file not found: {self._path}") 

136 # Buffer of pending operations applied in order at save(). 

137 self._ops: list[_Operation] = [] 

138 

139 # /////////////////////////////////////////////////////////////// 

140 # PUBLIC METHODS — formatting operations (all return self for chaining) 

141 # /////////////////////////////////////////////////////////////// 

142 

143 def column_width(self, col: str, width: float) -> ExcelFormatter: 

144 """Set the width of a column. 

145 

146 Args: 

147 col: Column letter (e.g. ``"A"``, ``"BC"``). 

148 width: Column width in Excel character units. 

149 

150 Returns: 

151 ExcelFormatter: ``self`` for method chaining. 

152 

153 Example: 

154 >>> formatter.column_width("A", 20).column_width("B", 15) 

155 """ 

156 self._ops.append(_ColumnWidthOp(col=col, width=width)) 

157 return self 

158 

159 def row_height(self, row: int, height: float) -> ExcelFormatter: 

160 """Set the height of a row. 

161 

162 Args: 

163 row: 1-based row index. 

164 height: Row height in points. 

165 

166 Returns: 

167 ExcelFormatter: ``self`` for method chaining. 

168 

169 Example: 

170 >>> formatter.row_height(1, 30) 

171 """ 

172 self._ops.append(_RowHeightOp(row=row, height=height)) 

173 return self 

174 

175 def font( 

176 self, 

177 ref: str, 

178 *, 

179 bold: bool = False, 

180 italic: bool = False, 

181 size: int | None = None, 

182 color: str | None = None, 

183 ) -> ExcelFormatter: 

184 """Apply font formatting to a cell or range. 

185 

186 Args: 

187 ref: Cell or range address in A1 notation (e.g. ``"A1"`` or 

188 ``"A1:D1"``). 

189 bold: Apply bold weight. Defaults to ``False``. 

190 italic: Apply italic style. Defaults to ``False``. 

191 size: Font size in points. ``None`` leaves the size unchanged. 

192 color: Font colour as a 6-character hex string without ``#`` 

193 (e.g. ``"FF0000"`` for red). ``None`` leaves colour unchanged. 

194 

195 Returns: 

196 ExcelFormatter: ``self`` for method chaining. 

197 

198 Example: 

199 >>> formatter.font("A1", bold=True, size=12, color="FF0000") 

200 """ 

201 self._ops.append( 

202 _FontOp(ref=ref, bold=bold, italic=italic, size=size, color=color) 

203 ) 

204 return self 

205 

206 def fill(self, ref: str, color: str) -> ExcelFormatter: 

207 """Apply a solid background fill to a cell or range. 

208 

209 Args: 

210 ref: Cell or range address in A1 notation. 

211 color: Background colour as a 6-character hex string without 

212 ``#`` (e.g. ``"4F81BD"`` for a medium blue). 

213 

214 Returns: 

215 ExcelFormatter: ``self`` for method chaining. 

216 

217 Example: 

218 >>> formatter.fill("A1:D1", "4F81BD") 

219 """ 

220 self._ops.append(_FillOp(ref=ref, color=color)) 

221 return self 

222 

223 def border(self, ref: str, style: str = "thin") -> ExcelFormatter: 

224 """Apply a border to all edges of a cell or range. 

225 

226 Args: 

227 ref: Cell or range address in A1 notation. 

228 style: Border style name as understood by openpyxl 

229 (e.g. ``"thin"``, ``"medium"``, ``"thick"``, ``"dashed"``). 

230 Defaults to ``"thin"``. 

231 

232 Returns: 

233 ExcelFormatter: ``self`` for method chaining. 

234 

235 Example: 

236 >>> formatter.border("A1:D5", style="thin") 

237 """ 

238 self._ops.append(_BorderOp(ref=ref, style=style)) 

239 return self 

240 

241 def align( 

242 self, 

243 ref: str, 

244 *, 

245 horizontal: str | None = None, 

246 vertical: str | None = None, 

247 wrap: bool = False, 

248 ) -> ExcelFormatter: 

249 """Apply alignment to a cell or range. 

250 

251 Args: 

252 ref: Cell or range address in A1 notation. 

253 horizontal: Horizontal alignment. Accepted values: 

254 ``"left"``, ``"center"``, ``"right"``, ``"fill"``, 

255 ``"justify"``, ``"centerContinuous"``, ``"distributed"``. 

256 ``None`` leaves the setting unchanged. 

257 vertical: Vertical alignment. Accepted values: 

258 ``"top"``, ``"center"``, ``"bottom"``, ``"justify"``, 

259 ``"distributed"``. ``None`` leaves the setting unchanged. 

260 wrap: Enable text wrapping. Defaults to ``False``. 

261 

262 Returns: 

263 ExcelFormatter: ``self`` for method chaining. 

264 

265 Example: 

266 >>> formatter.align("A1", horizontal="center", vertical="top", wrap=True) 

267 """ 

268 self._ops.append( 

269 _AlignOp(ref=ref, horizontal=horizontal, vertical=vertical, wrap=wrap) 

270 ) 

271 return self 

272 

273 def save(self, dest: str | Path | None = None) -> None: 

274 """Apply all buffered operations and write the workbook. 

275 

276 Args: 

277 dest: Destination path. Pass ``None`` to overwrite the source 

278 file in place. Parent directories of ``dest`` must exist. 

279 

280 Raises: 

281 FormatterError: If any openpyxl operation fails. 

282 ImportError: If openpyxl is not installed. 

283 

284 Example: 

285 >>> formatter.save() # overwrite source 

286 >>> formatter.save("output/report.xlsx") # write to new path 

287 """ 

288 from ..exceptions import FormatterError 

289 

290 logger.debug( 

291 "ExcelFormatter.save: applying %d operations to '%s'.", 

292 len(self._ops), 

293 self._path, 

294 ) 

295 

296 try: 

297 wb = openpyxl.load_workbook(self._path) 

298 except Exception as exc: 

299 raise FormatterError( 

300 f"Failed to open workbook for formatting: {self._path} — {exc}", 

301 cause=exc, 

302 ) from exc 

303 

304 try: 

305 ws = wb.active 

306 if ws is None: 

307 raise FormatterError( 

308 f"Workbook '{self._path}' has no active sheet.", 

309 cause=None, 

310 ) 

311 

312 for op in self._ops: 

313 if isinstance(op, _ColumnWidthOp): 

314 ws.column_dimensions[op.col].width = op.width 

315 

316 elif isinstance(op, _RowHeightOp): 

317 ws.row_dimensions[op.row].height = op.height 

318 

319 elif isinstance(op, _FontOp): 

320 font_kwargs: dict[str, Any] = { 

321 "bold": op.bold, 

322 "italic": op.italic, 

323 } 

324 if op.size is not None: 

325 font_kwargs["size"] = op.size 

326 if op.color is not None: 

327 font_kwargs["color"] = op.color 

328 font_style = Font(**font_kwargs) 

329 for cell in _iter_cells(ws, op.ref): 

330 cell.font = font_style 

331 

332 elif isinstance(op, _FillOp): 

333 fill_style = PatternFill(fill_type="solid", fgColor=op.color) 

334 for cell in _iter_cells(ws, op.ref): 

335 cell.fill = fill_style 

336 

337 elif isinstance(op, _BorderOp): 

338 side = Side(border_style=cast(Any, op.style)) 

339 border_style = Border(left=side, right=side, top=side, bottom=side) 

340 for cell in _iter_cells(ws, op.ref): 

341 cell.border = border_style 

342 

343 elif isinstance(op, _AlignOp): 

344 align_kwargs: dict[str, Any] = {"wrap_text": op.wrap} 

345 if op.horizontal is not None: 

346 align_kwargs["horizontal"] = op.horizontal 

347 if op.vertical is not None: 

348 align_kwargs["vertical"] = op.vertical 

349 align_style = Alignment(**align_kwargs) 

350 for cell in _iter_cells(ws, op.ref): 

351 cell.alignment = align_style 

352 

353 except Exception as exc: 

354 raise FormatterError( 

355 f"Error applying formatting operation to '{self._path}': {exc}", 

356 cause=exc, 

357 ) from exc 

358 

359 out_path = Path(dest).resolve() if dest is not None else self._path 

360 

361 try: 

362 wb.save(str(out_path)) 

363 except Exception as exc: 

364 raise FormatterError( 

365 f"Failed to save formatted workbook to '{out_path}': {exc}", 

366 cause=exc, 

367 ) from exc 

368 

369 logger.debug("ExcelFormatter.save: written to '%s'.", out_path) 

370 printer.success( 

371 f"ExcelFormatter: formatting applied and saved to '{out_path}'." 

372 ) 

373 

374 

375# /////////////////////////////////////////////////////////////// 

376# HELPERS 

377# /////////////////////////////////////////////////////////////// 

378 

379 

380def _iter_cells(ws: Any, ref: str): 

381 """Yield individual Cell objects from a worksheet reference. 

382 

383 Handles both single-cell references (``"A1"``) and ranges (``"A1:D5"``). 

384 

385 Args: 

386 ws: An openpyxl Worksheet object. 

387 ref: Cell or range address in A1 notation. 

388 

389 Yields: 

390 openpyxl Cell objects. 

391 """ 

392 cell_range = ws[ref] 

393 # openpyxl returns a single Cell for a single address, or a tuple-of-tuples 

394 # for a range reference. 

395 if hasattr(cell_range, "__iter__") and not hasattr(cell_range, "value"): 

396 # Range: iterate rows then cells. 

397 for row in cell_range: 

398 if hasattr(row, "__iter__") and not hasattr(row, "value"): 

399 yield from row 

400 else: 

401 yield row 

402 else: 

403 yield cell_range