Skip to content

Commit 4700f38

Browse files
committed
feat: bump version to 0.5.2, enhance save functionality to support in-memory operations and improve README with new examples for saving without disk access
1 parent 5c3c8f7 commit 4700f38

File tree

9 files changed

+109
-43
lines changed

9 files changed

+109
-43
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,21 @@ workbook.save("output-openpyxl.xlsx", engine="openpyxl")
6464
workbook.save("output-xlsxwriter.xlsx", engine="xlsxwriter")
6565
```
6666

67+
### Saving without touching disk
68+
69+
`Workbook.save` accepts a filesystem path, any binary buffer (like `io.BytesIO()`), or no target at all to get the raw bytes back:
70+
71+
```python
72+
import io
73+
from pathlib import Path
74+
75+
buffer = io.BytesIO()
76+
workbook.save(buffer, engine="xlsxwriter") # writes into memory
77+
78+
raw_bytes = workbook.save(engine="openpyxl") # returns bytes when no target is provided
79+
Path("report.xlsx").write_bytes(raw_bytes)
80+
```
81+
6782
Both engines produce equivalent Excel files, but may have subtle differences in:
6883
- File size and memory usage
6984
- Rendering performance

llm.txt

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
Using xpyxl with xlsxwriter (external agents)
2-
- Install: dependency declared as `xlsxwriter>=3.1.0`. Import `import xpyxl as x`.
32
- Build: compose nodes (no coordinates) then save with the engine you want: `x.workbook()[x.sheet("Demo")[...]].save("out.xlsx", engine="xlsxwriter")`.
43
- Primitives: `x.cell[...]`, `x.row[...]`, `x.col[...]`, `x.table()[...]`, `x.vstack(..., gap=0, style=[...])`, `x.hstack(..., gap=0, style=[...])`, `x.space(rows=1, height=None)`, `x.sheet(name, background_color=None)`, `x.workbook()[...]`.
54
- Table inputs: sequence of sequences, sequence of dicts (header from keys), or dict-of-lists (columns zipped). `header_style=[...]`, `style=[...]`, `column_order=[...]`.
@@ -10,10 +9,26 @@ Precomputed styles (combine in `style=[...]`)
109
- Typography: `text_xs, text_sm, text_base, text_lg, text_xl, text_2xl, text_3xl, bold, italic, mono`.
1110
- Text colors: `text_red, text_green, text_blue, text_orange, text_purple, text_black, text_gray, text_muted, text_primary, text_white`.
1211
- Backgrounds: `bg_red, bg_primary, bg_muted, bg_success, bg_warning, bg_info`.
13-
- Alignment & layout: `text_left, text_center, text_right, align_top, align_middle, align_bottom, wrap, nowrap, wrap_shrink, allow_overflow, row_height(n), row_width(n)`.
14-
- Borders: `border_all, border_top, border_bottom, border_left, border_right, border_x, border_y, border_thin, border_medium, border_thick, border_dashed, border_dotted, border_double, border_none` plus color helpers `border_red/green/blue/orange/purple/black/gray/white/muted/primary`.
15-
- Tables: `table_bordered`, `table_banded`, `table_compact`.
16-
- Number/date formats: `number_comma`, `number_precision`, `percent`, `currency_usd`, `currency_eur`, `date_short`, `datetime_short`, `time_short`.
12+
- Alignment: `text_left, text_center, text_right, align_top, align_middle, align_bottom`.
13+
- Text wrapping: `wrap, nowrap, wrap_shrink, allow_overflow`.
14+
- Row sizing: `row_height(n), row_width(n)` (functions that take a float value).
15+
- Borders (sides): `border_all, border_top, border_bottom, border_left, border_right, border_x, border_y`.
16+
- Borders (styles): `border_thin, border_medium, border_thick, border_dashed, border_dotted, border_double, border_none`.
17+
- Borders (colors): `border_red, border_green, border_blue, border_orange, border_purple, border_black, border_gray, border_white, border_muted, border_primary`.
18+
- Tables: `table_bordered, table_banded, table_compact`.
19+
- Number/date formats: `number_comma, number_precision, percent, currency_usd, currency_eur, date_short, datetime_short, time_short`.
20+
21+
Custom styles with `x.Style()`
22+
- If precomputed styles don't meet your needs, use `x.Style()` with any combination of these options:
23+
- Font: `font_name="Arial"`, `font_size=12.0`, `font_size_delta=1.0`, `bold=True/False`, `italic=True/False`, `mono=True/False`.
24+
- Colors: `text_color="#FF0000"` (hex), `fill_color="#F0F0F0"` (hex).
25+
- Alignment: `horizontal_align="left"/"center"/"right"`, `vertical_align="top"/"center"/"bottom"`, `indent=2` (integer).
26+
- Text wrapping: `wrap_text=True/False`, `shrink_to_fit=True/False`, `auto_width=True/False`.
27+
- Row sizing: `row_height=20.0`, `row_width=100.0`.
28+
- Number format: `number_format="#,##0.00"` (Excel format string).
29+
- Borders: `border="thin"/"medium"/"thick"/"dashed"/"dotted"/"double"/"none"` (or "dashDot", "dashDotDot", "hair", "mediumDashDot", "mediumDashDotDot", "mediumDashed", "slantDashDot"), `border_color="#000000"`, `border_top=True/False`, `border_bottom=True/False`, `border_left=True/False`, `border_right=True/False`.
30+
- Tables: `table_bordered=True/False`, `table_banded=True/False`, `table_compact=True/False`.
31+
- Example: `x.Style(font_size=14.0, text_color="#FF5733", fill_color="#FFF5E6", border="medium", border_top=True, border_bottom=True)`.
1732

1833
Common patterns
1934
- Simple sheet: `x.sheet("Summary")[x.row(style=[x.text_2xl, x.bold])["Title"], x.row()["A", 1]]`.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "xpyxl"
3-
version = "0.5.1"
3+
version = "0.5.2"
44
description = "Create styled excel reports with declarative python."
55
readme = "README.md"
66
authors = [{ name = "dakixr", email = "[email protected]" }]

src/xpyxl/_workbook.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from pathlib import Path
4-
from typing import TYPE_CHECKING
4+
from typing import TYPE_CHECKING, BinaryIO
55

66
from openpyxl import Workbook as _OpenpyxlWorkbook
77

@@ -21,18 +21,24 @@ class Workbook:
2121
def __init__(self, node: WorkbookNode) -> None:
2222
self._node = node
2323

24-
def save(self, path: str | Path, *, engine: EngineName = "openpyxl") -> None:
25-
"""Save the workbook to a file.
24+
def save(
25+
self,
26+
target: str | Path | BinaryIO | None = None,
27+
*,
28+
engine: EngineName = "openpyxl",
29+
) -> bytes | None:
30+
"""Save the workbook to a file or binary stream.
2631
2732
Args:
28-
path: The file path to save to.
33+
target: File path or binary buffer to write to. Pass None to receive
34+
the rendered workbook as bytes.
2935
engine: The rendering engine to use. Options are "openpyxl" (default)
3036
or "xlsxwriter".
3137
"""
32-
engine_instance = get_engine(engine, path)
38+
engine_instance = get_engine(engine)
3339
for sheet in self._node.sheets:
3440
render_sheet(engine_instance, sheet)
35-
engine_instance.save()
41+
return engine_instance.save(target)
3642

3743
def to_openpyxl(self) -> _OpenpyxlWorkbook:
3844
"""Convert to an openpyxl Workbook object.
@@ -42,8 +48,8 @@ def to_openpyxl(self) -> _OpenpyxlWorkbook:
4248
"""
4349
from .engines.openpyxl_engine import OpenpyxlEngine
4450

45-
# Create a temporary path - we won't actually save to it
46-
engine = OpenpyxlEngine(Path("/tmp/temp.xlsx"))
51+
# Render with the openpyxl engine without persisting to disk
52+
engine = OpenpyxlEngine()
4753
for sheet in self._node.sheets:
4854
render_sheet(engine, sheet)
4955
return engine._workbook

src/xpyxl/engines/__init__.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,12 @@
22

33
from __future__ import annotations
44

5-
from typing import TYPE_CHECKING, Literal
5+
from typing import Literal
66

77
from .base import Engine
88
from .openpyxl_engine import OpenpyxlEngine
99
from .xlsxwriter_engine import XlsxWriterEngine
1010

11-
if TYPE_CHECKING:
12-
from pathlib import Path
13-
1411
__all__ = [
1512
"Engine",
1613
"OpenpyxlEngine",
@@ -22,13 +19,11 @@
2219
EngineName = Literal["openpyxl", "xlsxwriter"]
2320

2421

25-
def get_engine(name: EngineName, path: str | Path) -> Engine:
26-
"""Create an engine instance for the given name and output path."""
22+
def get_engine(name: EngineName) -> Engine:
23+
"""Create an engine instance for the given name."""
2724
if name == "openpyxl":
28-
return OpenpyxlEngine(path)
25+
return OpenpyxlEngine()
2926
elif name == "xlsxwriter":
30-
return XlsxWriterEngine(path)
27+
return XlsxWriterEngine()
3128
else:
3229
raise ValueError(f"Unknown engine: {name}")
33-
34-

src/xpyxl/engines/base.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
from abc import ABC, abstractmethod
66
from dataclasses import dataclass
77
from pathlib import Path
8-
from typing import TYPE_CHECKING
8+
from typing import TYPE_CHECKING, BinaryIO
99

1010
if TYPE_CHECKING:
1111
from ..styles import BorderStyleName
1212

13-
__all__ = ["Engine", "EffectiveStyle"]
13+
__all__ = ["Engine", "EffectiveStyle", "SaveTarget"]
1414

1515

1616
@dataclass
@@ -40,6 +40,9 @@ class EffectiveStyle:
4040
border_right: bool
4141

4242

43+
SaveTarget = str | Path | BinaryIO
44+
45+
4346
class Engine(ABC):
4447
"""Abstract base class for Excel rendering engines.
4548
@@ -53,8 +56,8 @@ class Engine(ABC):
5356
convert internally.
5457
"""
5558

56-
def __init__(self, path: str | Path) -> None:
57-
self._path = Path(path)
59+
def __init__(self) -> None:
60+
"""Initialize engine state without binding to an output target."""
5861

5962
@abstractmethod
6063
def create_sheet(self, name: str) -> None:
@@ -118,7 +121,13 @@ def fill_background(
118121
...
119122

120123
@abstractmethod
121-
def save(self) -> None:
122-
"""Save the workbook to the file path."""
123-
...
124+
def save(self, target: SaveTarget | None = None) -> bytes | None:
125+
"""Finalize and persist the workbook.
124126
127+
Args:
128+
target: Destination to write the workbook to. If None, the engine
129+
should return the workbook as bytes. When provided, the engine
130+
writes to the given path or binary file-like object and should
131+
return None.
132+
"""
133+
...

src/xpyxl/engines/openpyxl_engine.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from io import BytesIO
56
from pathlib import Path
67
from typing import TYPE_CHECKING, Any
78

@@ -10,7 +11,7 @@
1011
from openpyxl.utils import get_column_letter
1112

1213
from ..styles import to_argb
13-
from .base import EffectiveStyle, Engine
14+
from .base import EffectiveStyle, Engine, SaveTarget
1415

1516
if TYPE_CHECKING:
1617
from openpyxl.worksheet.worksheet import Worksheet
@@ -21,8 +22,8 @@
2122
class OpenpyxlEngine(Engine):
2223
"""Rendering engine using openpyxl."""
2324

24-
def __init__(self, path: str | Path) -> None:
25-
super().__init__(path)
25+
def __init__(self) -> None:
26+
super().__init__()
2627
self._workbook = Workbook()
2728
# Remove default sheet created by openpyxl
2829
default_sheet = self._workbook.active
@@ -232,5 +233,14 @@ def fill_background(
232233
for cell in row:
233234
cell.fill = sheet_fill
234235

235-
def save(self) -> None:
236-
self._workbook.save(str(self._path))
236+
def save(self, target: SaveTarget | None = None) -> bytes | None:
237+
if target is None:
238+
buffer = BytesIO()
239+
self._workbook.save(buffer)
240+
return buffer.getvalue()
241+
242+
if isinstance(target, (str, Path)):
243+
self._workbook.save(str(target))
244+
else:
245+
self._workbook.save(target)
246+
return None

src/xpyxl/engines/xlsxwriter_engine.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22

33
from __future__ import annotations
44

5+
from io import BytesIO
56
from pathlib import Path
67
from typing import TYPE_CHECKING, Any
78

89
import xlsxwriter
910

1011
from ..styles import normalize_hex
11-
from .base import EffectiveStyle, Engine
12+
from .base import EffectiveStyle, Engine, SaveTarget
1213

1314
if TYPE_CHECKING:
1415
from xlsxwriter.format import Format
@@ -40,12 +41,14 @@
4041
class XlsxWriterEngine(Engine):
4142
"""Rendering engine using xlsxwriter."""
4243

43-
def __init__(self, path: str | Path) -> None:
44-
super().__init__(path)
45-
self._workbook = xlsxwriter.Workbook(str(self._path))
44+
def __init__(self) -> None:
45+
super().__init__()
46+
self._buffer = BytesIO()
47+
self._workbook = xlsxwriter.Workbook(self._buffer, {"in_memory": True})
4648
self._current_sheet: Worksheet | None = None
4749
# Cache format objects to avoid duplicates
4850
self._format_cache: dict[tuple[Any, ...], Format] = {}
51+
self._closed = False
4952

5053
def create_sheet(self, name: str) -> None:
5154
self._current_sheet = self._workbook.add_worksheet(name)
@@ -216,5 +219,18 @@ def fill_background(
216219
for col_idx in range(max_col):
217220
self._current_sheet.write_blank(row_idx, col_idx, None, bg_fmt)
218221

219-
def save(self) -> None:
220-
self._workbook.close()
222+
def save(self, target: SaveTarget | None = None) -> bytes | None:
223+
if not self._closed:
224+
self._workbook.close()
225+
self._closed = True
226+
227+
data = self._buffer.getvalue()
228+
if target is None:
229+
return data
230+
231+
if isinstance(target, (str, Path)):
232+
Path(target).write_bytes(data)
233+
else:
234+
target.write(data)
235+
target.flush()
236+
return None

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)