pyhandlexl 0.2.0__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.
- pyhandlexl-0.2.0/LICENSE +21 -0
- pyhandlexl-0.2.0/PKG-INFO +302 -0
- pyhandlexl-0.2.0/README.md +271 -0
- pyhandlexl-0.2.0/pyproject.toml +57 -0
- pyhandlexl-0.2.0/setup.cfg +4 -0
- pyhandlexl-0.2.0/src/pyhandlexl/__init__.py +46 -0
- pyhandlexl-0.2.0/src/pyhandlexl/_safety.py +116 -0
- pyhandlexl-0.2.0/src/pyhandlexl/core.py +250 -0
- pyhandlexl-0.2.0/src/pyhandlexl/errors.py +28 -0
- pyhandlexl-0.2.0/src/pyhandlexl/py.typed +0 -0
- pyhandlexl-0.2.0/src/pyhandlexl/table.py +373 -0
- pyhandlexl-0.2.0/src/pyhandlexl/validate.py +75 -0
- pyhandlexl-0.2.0/src/pyhandlexl.egg-info/PKG-INFO +302 -0
- pyhandlexl-0.2.0/src/pyhandlexl.egg-info/SOURCES.txt +20 -0
- pyhandlexl-0.2.0/src/pyhandlexl.egg-info/dependency_links.txt +1 -0
- pyhandlexl-0.2.0/src/pyhandlexl.egg-info/requires.txt +5 -0
- pyhandlexl-0.2.0/src/pyhandlexl.egg-info/top_level.txt +1 -0
- pyhandlexl-0.2.0/tests/test_core.py +205 -0
- pyhandlexl-0.2.0/tests/test_errors.py +48 -0
- pyhandlexl-0.2.0/tests/test_safety.py +129 -0
- pyhandlexl-0.2.0/tests/test_table.py +313 -0
- pyhandlexl-0.2.0/tests/test_validate.py +89 -0
pyhandlexl-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lewis Wainaina
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyhandlexl
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Use an Excel file as the database for your next project.
|
|
5
|
+
Author-email: Lewis Wainaina <lewyamendi@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/LewyAmendi/pyhandlexl
|
|
8
|
+
Project-URL: Repository, https://github.com/LewyAmendi/pyhandlexl
|
|
9
|
+
Project-URL: Issues, https://github.com/LewyAmendi/pyhandlexl/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/LewyAmendi/pyhandlexl/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: excel,xlsx,openpyxl,spreadsheet,xl
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Office/Business :: Financial :: Spreadsheet
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: openpyxl>=3.1
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
29
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# pyhandlexl
|
|
33
|
+
|
|
34
|
+
[](https://github.com/LewyAmendi/pyhandlexl/actions/workflows/ci.yml)
|
|
35
|
+
|
|
36
|
+
**Make an Excel file the database for your next project.**
|
|
37
|
+
|
|
38
|
+
A spreadsheet is the most portable data store there is: every machine opens it,
|
|
39
|
+
anyone can read or edit it without knowing a query language, it versions as a
|
|
40
|
+
single file, and there is no server to run. `pyhandlexl` makes driving one from
|
|
41
|
+
Python dependable — read and write raw cell values in a known order, with writes
|
|
42
|
+
that leave the file intact even when things go wrong.
|
|
43
|
+
|
|
44
|
+
Built on [openpyxl](https://openpyxl.readthedocs.io/) and built to grow.
|
|
45
|
+
|
|
46
|
+
- **`Table`** — the main way in. Row 1 holds your column headers, column A holds
|
|
47
|
+
your row labels, and everything from `B2` on is data. Read it, edit it by name,
|
|
48
|
+
write it back.
|
|
49
|
+
- **`read_sheet` / `write_sheet`** — direct grid access for sheets that aren't a
|
|
50
|
+
labelled table.
|
|
51
|
+
|
|
52
|
+
> **Version 0.1.0.** Usable today and under active development — expect new
|
|
53
|
+
> capabilities with each release, and some API changes as it matures.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
Not on PyPI yet. From source:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
git clone https://github.com/LewyAmendi/pyhandlexl
|
|
61
|
+
cd pyhandlexl
|
|
62
|
+
pip install -e .
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Requires Python 3.10+.
|
|
66
|
+
|
|
67
|
+
## Quickstart
|
|
68
|
+
|
|
69
|
+
Given `budget.xlsx`:
|
|
70
|
+
|
|
71
|
+
| | q1 | q2 |
|
|
72
|
+
|--------|----|----|
|
|
73
|
+
| **Alice** | 10 | 20 |
|
|
74
|
+
| **Bob** | 30 | 40 |
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from pyhandlexl import Table
|
|
78
|
+
|
|
79
|
+
t = Table.read("budget.xlsx")
|
|
80
|
+
|
|
81
|
+
t.read_cell(row="Alice", column="q2") # '20'
|
|
82
|
+
t.read_row("Bob") # ['30', '40']
|
|
83
|
+
t.read_column("q1") # ['10', '30']
|
|
84
|
+
|
|
85
|
+
t.set_cell(row="Alice", column="q1", value=99) # edit in place
|
|
86
|
+
t.add_row("Carol", [1, 2])
|
|
87
|
+
t.write("budget.xlsx") # one safe, atomic write
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
> **Values are always strings.** `read_sheet` and `Table` coerce every cell to
|
|
91
|
+
> `str` (empty cells become `""`). Convert to numbers yourself where you need to.
|
|
92
|
+
|
|
93
|
+
## The `Table` class
|
|
94
|
+
|
|
95
|
+
### Reading
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
Table.read(path, sheet=None, *, column_headers=True, row_labels=True)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
- `sheet=None` reads the active sheet; pass a name for a specific one.
|
|
102
|
+
- `column_headers=False` — row 1 is ordinary data, `column_headers` is empty.
|
|
103
|
+
- `row_labels=False` — column A is ordinary data, `row_labels` is empty.
|
|
104
|
+
|
|
105
|
+
Row labels and column headers are always `str` — required if you build a
|
|
106
|
+
`Table` by hand, too (`Table(..., column_headers=[1, 2])` raises `TypeError`).
|
|
107
|
+
|
|
108
|
+
### The whole table at once
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
t.corner # value of cell A1 (settable: t.corner = "name")
|
|
112
|
+
t.data # a TableData snapshot
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
d = t.data
|
|
117
|
+
d.rows # [['10', '20'], ['30', '40']] (B2 onward, by row)
|
|
118
|
+
d.columns # [['10', '30'], ['20', '40']] (same data, by column)
|
|
119
|
+
d.row_labels # ['Alice', 'Bob'] (column A, from A2)
|
|
120
|
+
d.column_headers # ['q1', 'q2'] (row 1, from B1)
|
|
121
|
+
d.corner # value of cell A1
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Every field is a fresh copy — mutating `t.data.rows` does not change the table.
|
|
125
|
+
|
|
126
|
+
### Access by label
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
t.read_row("Bob") # a data row (no label)
|
|
130
|
+
t.read_column("q1") # a data column (no header)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Unknown labels raise `KeyError`. If a label appears twice, the first match wins.
|
|
134
|
+
|
|
135
|
+
### `read_cell` / `set_cell` — a single value, by position or by label
|
|
136
|
+
|
|
137
|
+
Both take the same addressing: a ref like `"B2"`, or `row=`/`column=` as a
|
|
138
|
+
matching pair — **both ints** for a 1-based Excel position (row 1 is the
|
|
139
|
+
header row, column 1 is the label column), or **both strings** for a row
|
|
140
|
+
label / column header pair. Mixing types raises `TypeError`.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
t.read_cell("B2") # '10' — first data cell, by position
|
|
144
|
+
t.read_cell(row=2, column=2) # '10' — same thing, spelled out
|
|
145
|
+
t.read_cell(row="Alice", column="q1") # '10' — same value, by label
|
|
146
|
+
|
|
147
|
+
t.read_cell(row=1, column=2) # 'q1' — a column header
|
|
148
|
+
t.read_cell(row=2, column=1) # 'Alice' — a row label
|
|
149
|
+
t.read_cell("A1") # the corner
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
By position, `read_cell` can reach *any* cell — header, label, corner, or
|
|
153
|
+
data. By label it always reads data, wherever that row/column intersection
|
|
154
|
+
actually lives.
|
|
155
|
+
|
|
156
|
+
A string given through `row=`/`column=` is always a label lookup — it does
|
|
157
|
+
**not** accept a column letter like `"B"` for a position. Use a plain number
|
|
158
|
+
(`column=2`) or `ref="B2"` for letter-based positions.
|
|
159
|
+
|
|
160
|
+
### Editing (in place, returns `None`)
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
t.set_cell("B2", value=99) # by position — ref
|
|
164
|
+
t.set_cell(row=2, column=2, value=99) # by position — row=/column= as ints
|
|
165
|
+
t.set_cell(row="Alice", column="q1", value=99) # by label — row=/column= as strings
|
|
166
|
+
t.set_row("Bob", [50, 60]) # replace a row (length must match)
|
|
167
|
+
t.set_column("q1", [1, 2]) # replace a column (length must match)
|
|
168
|
+
|
|
169
|
+
t.add_row("Carol", [1, 2]) # append a labelled row
|
|
170
|
+
t.add_column("q3", [5, 6]) # append a labelled column
|
|
171
|
+
|
|
172
|
+
t.drop_row("Bob")
|
|
173
|
+
t.drop_column("q2")
|
|
174
|
+
|
|
175
|
+
t.rename_row("Alice", "ALICE")
|
|
176
|
+
t.rename_column("q1", "Q1")
|
|
177
|
+
t.corner = "name"
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`set_cell` only ever touches **data** — addressing a header, row label, or the
|
|
181
|
+
corner by position raises `ValueError`; use `rename_row`, `rename_column`, or
|
|
182
|
+
`t.corner = value` for those. Wrong-length values raise `ValueError`; unknown
|
|
183
|
+
labels raise `KeyError`; a non-`str` row label or column header (in `add_row`,
|
|
184
|
+
`add_column`, `rename_row`, `rename_column`) raises `TypeError`.
|
|
185
|
+
|
|
186
|
+
You can also build a table from nothing:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
t = Table([], column_headers=["q1", "q2"])
|
|
190
|
+
t.add_row("Alice", [10, 20])
|
|
191
|
+
t.write("new.xlsx")
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Writing
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
t.write(path, sheet=None)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Reassembles headers into row 1 and labels into column A, then writes the whole
|
|
201
|
+
sheet. Other sheets in the file are left untouched.
|
|
202
|
+
|
|
203
|
+
### Equality
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
t1 == t2 # compares data, headers, labels, corner
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Row count and row-label membership go through `t.data` instead of `len()`/`in`,
|
|
210
|
+
so the call site says what's being checked: `len(t.data.rows)`,
|
|
211
|
+
`"Bob" in t.data.row_labels`.
|
|
212
|
+
|
|
213
|
+
## The raw layer
|
|
214
|
+
|
|
215
|
+
For sheets that are not a labelled table — plain grids, exports, odd layouts.
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
from pyhandlexl import read_sheet, write_sheet, append_rows
|
|
219
|
+
|
|
220
|
+
read_sheet(path, sheet=None, *, pad=False)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Returns `list[list[str]]`. Trailing empty cells are trimmed from each row (a
|
|
224
|
+
fully empty row becomes `[]`); `pad=True` right-pads every row to the widest
|
|
225
|
+
row's length instead.
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
write_sheet(path, rows, sheet=None, *, orientation="rows")
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Replaces the target sheet with `rows` (other sheets untouched), creating the
|
|
232
|
+
file and sheet if needed. Values are written as-is — `str` stays `str`, `int`
|
|
233
|
+
stays `int`, `None` leaves the cell empty; there is no string-to-number
|
|
234
|
+
conversion. `orientation="columns"` writes each inner list *down a column*
|
|
235
|
+
instead of across a row.
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
append_rows(path, rows, sheet=None)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Appends after the last row. Empty input is a no-op.
|
|
242
|
+
|
|
243
|
+
## Sheet management
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
from pyhandlexl import (
|
|
247
|
+
list_sheets, sheet_exists, create_sheet, delete_sheet, rename_sheet,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
list_sheets(path) # ['Sheet1', 'Data']
|
|
251
|
+
sheet_exists(path, "Data") # True
|
|
252
|
+
create_sheet(path, "Results") # ValueError if it already exists
|
|
253
|
+
delete_sheet(path, "Old") # refuses to delete the last sheet
|
|
254
|
+
rename_sheet(path, "Old", "New")
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Sheet names are validated everywhere: max 31 characters, none of `\ / ? * [ ] :`,
|
|
258
|
+
and `"History"` is reserved by Excel.
|
|
259
|
+
|
|
260
|
+
## Safe writes
|
|
261
|
+
|
|
262
|
+
Every write goes through the same steps:
|
|
263
|
+
|
|
264
|
+
1. Save to a temporary file in the same directory.
|
|
265
|
+
2. Verify it is a readable `.xlsx`.
|
|
266
|
+
3. Atomically replace the original (`os.replace`).
|
|
267
|
+
|
|
268
|
+
If any step fails the temporary file is removed and the original is left exactly
|
|
269
|
+
as it was. If the target is locked (open in Excel), writes retry briefly before
|
|
270
|
+
raising `FileLockedError`.
|
|
271
|
+
|
|
272
|
+
## Errors
|
|
273
|
+
|
|
274
|
+
All raised exceptions derive from `PyhandlexlError`:
|
|
275
|
+
|
|
276
|
+
| Exception | Also a | Meaning |
|
|
277
|
+
|---|---|---|
|
|
278
|
+
| `SheetNameError` | `ValueError` | invalid worksheet name |
|
|
279
|
+
| `DimensionError` | `ValueError` | data exceeds Excel's 1,048,576 × 16,384 grid |
|
|
280
|
+
| `SheetNotFoundError` | `KeyError` | no worksheet with that name |
|
|
281
|
+
| `FileLockedError` | `OSError` | file stayed locked through every retry |
|
|
282
|
+
| `InvalidFileError` | — | file is missing or not a readable `.xlsx` |
|
|
283
|
+
|
|
284
|
+
## Not in scope
|
|
285
|
+
|
|
286
|
+
`pyhandlexl` deliberately does **not** handle: cell formatting, styles, fonts,
|
|
287
|
+
formulas, charts, images, merged cells, `.xls` (old format), or password
|
|
288
|
+
protection / encryption. For any of that, use openpyxl directly.
|
|
289
|
+
|
|
290
|
+
## Development
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
python -m venv .venv
|
|
294
|
+
source .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1
|
|
295
|
+
pip install -e ".[dev]"
|
|
296
|
+
pytest
|
|
297
|
+
ruff check . && ruff format --check .
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## License
|
|
301
|
+
|
|
302
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# pyhandlexl
|
|
2
|
+
|
|
3
|
+
[](https://github.com/LewyAmendi/pyhandlexl/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
**Make an Excel file the database for your next project.**
|
|
6
|
+
|
|
7
|
+
A spreadsheet is the most portable data store there is: every machine opens it,
|
|
8
|
+
anyone can read or edit it without knowing a query language, it versions as a
|
|
9
|
+
single file, and there is no server to run. `pyhandlexl` makes driving one from
|
|
10
|
+
Python dependable — read and write raw cell values in a known order, with writes
|
|
11
|
+
that leave the file intact even when things go wrong.
|
|
12
|
+
|
|
13
|
+
Built on [openpyxl](https://openpyxl.readthedocs.io/) and built to grow.
|
|
14
|
+
|
|
15
|
+
- **`Table`** — the main way in. Row 1 holds your column headers, column A holds
|
|
16
|
+
your row labels, and everything from `B2` on is data. Read it, edit it by name,
|
|
17
|
+
write it back.
|
|
18
|
+
- **`read_sheet` / `write_sheet`** — direct grid access for sheets that aren't a
|
|
19
|
+
labelled table.
|
|
20
|
+
|
|
21
|
+
> **Version 0.1.0.** Usable today and under active development — expect new
|
|
22
|
+
> capabilities with each release, and some API changes as it matures.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
Not on PyPI yet. From source:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git clone https://github.com/LewyAmendi/pyhandlexl
|
|
30
|
+
cd pyhandlexl
|
|
31
|
+
pip install -e .
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Requires Python 3.10+.
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
Given `budget.xlsx`:
|
|
39
|
+
|
|
40
|
+
| | q1 | q2 |
|
|
41
|
+
|--------|----|----|
|
|
42
|
+
| **Alice** | 10 | 20 |
|
|
43
|
+
| **Bob** | 30 | 40 |
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from pyhandlexl import Table
|
|
47
|
+
|
|
48
|
+
t = Table.read("budget.xlsx")
|
|
49
|
+
|
|
50
|
+
t.read_cell(row="Alice", column="q2") # '20'
|
|
51
|
+
t.read_row("Bob") # ['30', '40']
|
|
52
|
+
t.read_column("q1") # ['10', '30']
|
|
53
|
+
|
|
54
|
+
t.set_cell(row="Alice", column="q1", value=99) # edit in place
|
|
55
|
+
t.add_row("Carol", [1, 2])
|
|
56
|
+
t.write("budget.xlsx") # one safe, atomic write
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
> **Values are always strings.** `read_sheet` and `Table` coerce every cell to
|
|
60
|
+
> `str` (empty cells become `""`). Convert to numbers yourself where you need to.
|
|
61
|
+
|
|
62
|
+
## The `Table` class
|
|
63
|
+
|
|
64
|
+
### Reading
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
Table.read(path, sheet=None, *, column_headers=True, row_labels=True)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- `sheet=None` reads the active sheet; pass a name for a specific one.
|
|
71
|
+
- `column_headers=False` — row 1 is ordinary data, `column_headers` is empty.
|
|
72
|
+
- `row_labels=False` — column A is ordinary data, `row_labels` is empty.
|
|
73
|
+
|
|
74
|
+
Row labels and column headers are always `str` — required if you build a
|
|
75
|
+
`Table` by hand, too (`Table(..., column_headers=[1, 2])` raises `TypeError`).
|
|
76
|
+
|
|
77
|
+
### The whole table at once
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
t.corner # value of cell A1 (settable: t.corner = "name")
|
|
81
|
+
t.data # a TableData snapshot
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
d = t.data
|
|
86
|
+
d.rows # [['10', '20'], ['30', '40']] (B2 onward, by row)
|
|
87
|
+
d.columns # [['10', '30'], ['20', '40']] (same data, by column)
|
|
88
|
+
d.row_labels # ['Alice', 'Bob'] (column A, from A2)
|
|
89
|
+
d.column_headers # ['q1', 'q2'] (row 1, from B1)
|
|
90
|
+
d.corner # value of cell A1
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Every field is a fresh copy — mutating `t.data.rows` does not change the table.
|
|
94
|
+
|
|
95
|
+
### Access by label
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
t.read_row("Bob") # a data row (no label)
|
|
99
|
+
t.read_column("q1") # a data column (no header)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Unknown labels raise `KeyError`. If a label appears twice, the first match wins.
|
|
103
|
+
|
|
104
|
+
### `read_cell` / `set_cell` — a single value, by position or by label
|
|
105
|
+
|
|
106
|
+
Both take the same addressing: a ref like `"B2"`, or `row=`/`column=` as a
|
|
107
|
+
matching pair — **both ints** for a 1-based Excel position (row 1 is the
|
|
108
|
+
header row, column 1 is the label column), or **both strings** for a row
|
|
109
|
+
label / column header pair. Mixing types raises `TypeError`.
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
t.read_cell("B2") # '10' — first data cell, by position
|
|
113
|
+
t.read_cell(row=2, column=2) # '10' — same thing, spelled out
|
|
114
|
+
t.read_cell(row="Alice", column="q1") # '10' — same value, by label
|
|
115
|
+
|
|
116
|
+
t.read_cell(row=1, column=2) # 'q1' — a column header
|
|
117
|
+
t.read_cell(row=2, column=1) # 'Alice' — a row label
|
|
118
|
+
t.read_cell("A1") # the corner
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
By position, `read_cell` can reach *any* cell — header, label, corner, or
|
|
122
|
+
data. By label it always reads data, wherever that row/column intersection
|
|
123
|
+
actually lives.
|
|
124
|
+
|
|
125
|
+
A string given through `row=`/`column=` is always a label lookup — it does
|
|
126
|
+
**not** accept a column letter like `"B"` for a position. Use a plain number
|
|
127
|
+
(`column=2`) or `ref="B2"` for letter-based positions.
|
|
128
|
+
|
|
129
|
+
### Editing (in place, returns `None`)
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
t.set_cell("B2", value=99) # by position — ref
|
|
133
|
+
t.set_cell(row=2, column=2, value=99) # by position — row=/column= as ints
|
|
134
|
+
t.set_cell(row="Alice", column="q1", value=99) # by label — row=/column= as strings
|
|
135
|
+
t.set_row("Bob", [50, 60]) # replace a row (length must match)
|
|
136
|
+
t.set_column("q1", [1, 2]) # replace a column (length must match)
|
|
137
|
+
|
|
138
|
+
t.add_row("Carol", [1, 2]) # append a labelled row
|
|
139
|
+
t.add_column("q3", [5, 6]) # append a labelled column
|
|
140
|
+
|
|
141
|
+
t.drop_row("Bob")
|
|
142
|
+
t.drop_column("q2")
|
|
143
|
+
|
|
144
|
+
t.rename_row("Alice", "ALICE")
|
|
145
|
+
t.rename_column("q1", "Q1")
|
|
146
|
+
t.corner = "name"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`set_cell` only ever touches **data** — addressing a header, row label, or the
|
|
150
|
+
corner by position raises `ValueError`; use `rename_row`, `rename_column`, or
|
|
151
|
+
`t.corner = value` for those. Wrong-length values raise `ValueError`; unknown
|
|
152
|
+
labels raise `KeyError`; a non-`str` row label or column header (in `add_row`,
|
|
153
|
+
`add_column`, `rename_row`, `rename_column`) raises `TypeError`.
|
|
154
|
+
|
|
155
|
+
You can also build a table from nothing:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
t = Table([], column_headers=["q1", "q2"])
|
|
159
|
+
t.add_row("Alice", [10, 20])
|
|
160
|
+
t.write("new.xlsx")
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Writing
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
t.write(path, sheet=None)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Reassembles headers into row 1 and labels into column A, then writes the whole
|
|
170
|
+
sheet. Other sheets in the file are left untouched.
|
|
171
|
+
|
|
172
|
+
### Equality
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
t1 == t2 # compares data, headers, labels, corner
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Row count and row-label membership go through `t.data` instead of `len()`/`in`,
|
|
179
|
+
so the call site says what's being checked: `len(t.data.rows)`,
|
|
180
|
+
`"Bob" in t.data.row_labels`.
|
|
181
|
+
|
|
182
|
+
## The raw layer
|
|
183
|
+
|
|
184
|
+
For sheets that are not a labelled table — plain grids, exports, odd layouts.
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
from pyhandlexl import read_sheet, write_sheet, append_rows
|
|
188
|
+
|
|
189
|
+
read_sheet(path, sheet=None, *, pad=False)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Returns `list[list[str]]`. Trailing empty cells are trimmed from each row (a
|
|
193
|
+
fully empty row becomes `[]`); `pad=True` right-pads every row to the widest
|
|
194
|
+
row's length instead.
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
write_sheet(path, rows, sheet=None, *, orientation="rows")
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Replaces the target sheet with `rows` (other sheets untouched), creating the
|
|
201
|
+
file and sheet if needed. Values are written as-is — `str` stays `str`, `int`
|
|
202
|
+
stays `int`, `None` leaves the cell empty; there is no string-to-number
|
|
203
|
+
conversion. `orientation="columns"` writes each inner list *down a column*
|
|
204
|
+
instead of across a row.
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
append_rows(path, rows, sheet=None)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Appends after the last row. Empty input is a no-op.
|
|
211
|
+
|
|
212
|
+
## Sheet management
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
from pyhandlexl import (
|
|
216
|
+
list_sheets, sheet_exists, create_sheet, delete_sheet, rename_sheet,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
list_sheets(path) # ['Sheet1', 'Data']
|
|
220
|
+
sheet_exists(path, "Data") # True
|
|
221
|
+
create_sheet(path, "Results") # ValueError if it already exists
|
|
222
|
+
delete_sheet(path, "Old") # refuses to delete the last sheet
|
|
223
|
+
rename_sheet(path, "Old", "New")
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Sheet names are validated everywhere: max 31 characters, none of `\ / ? * [ ] :`,
|
|
227
|
+
and `"History"` is reserved by Excel.
|
|
228
|
+
|
|
229
|
+
## Safe writes
|
|
230
|
+
|
|
231
|
+
Every write goes through the same steps:
|
|
232
|
+
|
|
233
|
+
1. Save to a temporary file in the same directory.
|
|
234
|
+
2. Verify it is a readable `.xlsx`.
|
|
235
|
+
3. Atomically replace the original (`os.replace`).
|
|
236
|
+
|
|
237
|
+
If any step fails the temporary file is removed and the original is left exactly
|
|
238
|
+
as it was. If the target is locked (open in Excel), writes retry briefly before
|
|
239
|
+
raising `FileLockedError`.
|
|
240
|
+
|
|
241
|
+
## Errors
|
|
242
|
+
|
|
243
|
+
All raised exceptions derive from `PyhandlexlError`:
|
|
244
|
+
|
|
245
|
+
| Exception | Also a | Meaning |
|
|
246
|
+
|---|---|---|
|
|
247
|
+
| `SheetNameError` | `ValueError` | invalid worksheet name |
|
|
248
|
+
| `DimensionError` | `ValueError` | data exceeds Excel's 1,048,576 × 16,384 grid |
|
|
249
|
+
| `SheetNotFoundError` | `KeyError` | no worksheet with that name |
|
|
250
|
+
| `FileLockedError` | `OSError` | file stayed locked through every retry |
|
|
251
|
+
| `InvalidFileError` | — | file is missing or not a readable `.xlsx` |
|
|
252
|
+
|
|
253
|
+
## Not in scope
|
|
254
|
+
|
|
255
|
+
`pyhandlexl` deliberately does **not** handle: cell formatting, styles, fonts,
|
|
256
|
+
formulas, charts, images, merged cells, `.xls` (old format), or password
|
|
257
|
+
protection / encryption. For any of that, use openpyxl directly.
|
|
258
|
+
|
|
259
|
+
## Development
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
python -m venv .venv
|
|
263
|
+
source .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1
|
|
264
|
+
pip install -e ".[dev]"
|
|
265
|
+
pytest
|
|
266
|
+
ruff check . && ruff format --check .
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## License
|
|
270
|
+
|
|
271
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyhandlexl"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Use an Excel file as the database for your next project."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Lewis Wainaina", email = "lewyamendi@gmail.com" }]
|
|
14
|
+
keywords = ["excel", "xlsx", "openpyxl", "spreadsheet", "xl"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Office/Business :: Financial :: Spreadsheet",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = ["openpyxl>=3.1"]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = ["pytest>=8", "ruff>=0.6"]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/LewyAmendi/pyhandlexl"
|
|
35
|
+
Repository = "https://github.com/LewyAmendi/pyhandlexl"
|
|
36
|
+
Issues = "https://github.com/LewyAmendi/pyhandlexl/issues"
|
|
37
|
+
Changelog = "https://github.com/LewyAmendi/pyhandlexl/blob/main/CHANGELOG.md"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = ["src"]
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.package-data]
|
|
43
|
+
pyhandlexl = ["py.typed"]
|
|
44
|
+
|
|
45
|
+
[tool.ruff]
|
|
46
|
+
line-length = 100
|
|
47
|
+
target-version = "py310"
|
|
48
|
+
src = ["src", "tests"]
|
|
49
|
+
# Leave Markdown to the author — keep README example formatting hand-aligned.
|
|
50
|
+
extend-exclude = ["*.md"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
addopts = "-ra"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""pyhandlexl — read and write raw cell values in Excel .xlsx files."""
|
|
2
|
+
|
|
3
|
+
from pyhandlexl.core import (
|
|
4
|
+
append_rows,
|
|
5
|
+
create_sheet,
|
|
6
|
+
delete_sheet,
|
|
7
|
+
list_sheets,
|
|
8
|
+
read_sheet,
|
|
9
|
+
rename_sheet,
|
|
10
|
+
sheet_exists,
|
|
11
|
+
write_sheet,
|
|
12
|
+
)
|
|
13
|
+
from pyhandlexl.errors import (
|
|
14
|
+
DimensionError,
|
|
15
|
+
FileLockedError,
|
|
16
|
+
InvalidFileError,
|
|
17
|
+
PyhandlexlError,
|
|
18
|
+
SheetNameError,
|
|
19
|
+
SheetNotFoundError,
|
|
20
|
+
)
|
|
21
|
+
from pyhandlexl.table import Table, TableData
|
|
22
|
+
from pyhandlexl.validate import check_dimensions, check_sheet_name, is_valid_xlsx
|
|
23
|
+
|
|
24
|
+
__version__ = "0.2.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"DimensionError",
|
|
28
|
+
"FileLockedError",
|
|
29
|
+
"InvalidFileError",
|
|
30
|
+
"PyhandlexlError",
|
|
31
|
+
"SheetNameError",
|
|
32
|
+
"SheetNotFoundError",
|
|
33
|
+
"Table",
|
|
34
|
+
"TableData",
|
|
35
|
+
"append_rows",
|
|
36
|
+
"check_dimensions",
|
|
37
|
+
"check_sheet_name",
|
|
38
|
+
"create_sheet",
|
|
39
|
+
"delete_sheet",
|
|
40
|
+
"is_valid_xlsx",
|
|
41
|
+
"list_sheets",
|
|
42
|
+
"read_sheet",
|
|
43
|
+
"rename_sheet",
|
|
44
|
+
"sheet_exists",
|
|
45
|
+
"write_sheet",
|
|
46
|
+
]
|