odsslicer 0.1.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.
- odsslicer-0.1.0/.github/workflows/ci.yml +31 -0
- odsslicer-0.1.0/.github/workflows/publish.yml +52 -0
- odsslicer-0.1.0/.gitignore +17 -0
- odsslicer-0.1.0/LICENSE +21 -0
- odsslicer-0.1.0/PKG-INFO +364 -0
- odsslicer-0.1.0/README.md +336 -0
- odsslicer-0.1.0/example/.vscode/launch.json +15 -0
- odsslicer-0.1.0/example/VSCode.command +4 -0
- odsslicer-0.1.0/example/example.ods +0 -0
- odsslicer-0.1.0/example/example.py +75 -0
- odsslicer-0.1.0/pyproject.toml +47 -0
- odsslicer-0.1.0/rsc/OpenDocument-v1.2-part1.pdf +0 -0
- odsslicer-0.1.0/rsc/OpenDocument-v1.2-part2.pdf +0 -0
- odsslicer-0.1.0/rsc/content.xml +177 -0
- odsslicer-0.1.0/rsc/xml_ods_fmt.txt +57 -0
- odsslicer-0.1.0/setup.cfg +4 -0
- odsslicer-0.1.0/src/odsslicer/__init__.py +28 -0
- odsslicer-0.1.0/src/odsslicer/classes.py +982 -0
- odsslicer-0.1.0/src/odsslicer.egg-info/PKG-INFO +364 -0
- odsslicer-0.1.0/src/odsslicer.egg-info/SOURCES.txt +32 -0
- odsslicer-0.1.0/src/odsslicer.egg-info/dependency_links.txt +1 -0
- odsslicer-0.1.0/src/odsslicer.egg-info/requires.txt +6 -0
- odsslicer-0.1.0/src/odsslicer.egg-info/scm_file_list.json +29 -0
- odsslicer-0.1.0/src/odsslicer.egg-info/scm_version.json +8 -0
- odsslicer-0.1.0/src/odsslicer.egg-info/top_level.txt +1 -0
- odsslicer-0.1.0/tests/.vscode/launch.json +15 -0
- odsslicer-0.1.0/tests/Notes 2.ods +0 -0
- odsslicer-0.1.0/tests/Notes.ods +0 -0
- odsslicer-0.1.0/tests/TEST.ods +0 -0
- odsslicer-0.1.0/tests/VSCode.command +4 -0
- odsslicer-0.1.0/tests/conftest.py +51 -0
- odsslicer-0.1.0/tests/test_odsslicer.py +833 -0
- odsslicer-0.1.0/tests/tests.py +81 -0
- odsslicer-0.1.0/tests/tests2.py +15 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [master]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
with:
|
|
20
|
+
fetch-depth: 0 # full history so setuptools-scm can see tags
|
|
21
|
+
|
|
22
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
23
|
+
uses: actions/setup-python@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: ${{ matrix.python-version }}
|
|
26
|
+
|
|
27
|
+
- name: Install the package with test dependencies
|
|
28
|
+
run: pip install -e ".[test]"
|
|
29
|
+
|
|
30
|
+
- name: Run the test suite
|
|
31
|
+
run: pytest
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
with:
|
|
13
|
+
fetch-depth: 0 # full history + tags, needed by setuptools-scm to derive the version
|
|
14
|
+
|
|
15
|
+
- name: Set up Python
|
|
16
|
+
uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: "3.12"
|
|
19
|
+
|
|
20
|
+
- name: Install the package and run the test suite
|
|
21
|
+
run: |
|
|
22
|
+
pip install -e ".[test]"
|
|
23
|
+
pytest
|
|
24
|
+
|
|
25
|
+
- name: Build sdist and wheel
|
|
26
|
+
run: |
|
|
27
|
+
pip install build
|
|
28
|
+
python -m build
|
|
29
|
+
|
|
30
|
+
- name: Upload build artifacts
|
|
31
|
+
uses: actions/upload-artifact@v4
|
|
32
|
+
with:
|
|
33
|
+
name: dist
|
|
34
|
+
path: dist/
|
|
35
|
+
|
|
36
|
+
publish:
|
|
37
|
+
needs: build
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
environment:
|
|
40
|
+
name: pypi
|
|
41
|
+
url: https://pypi.org/project/odsslicer/
|
|
42
|
+
permissions:
|
|
43
|
+
id-token: write # required for PyPI trusted publishing (OIDC), no API token needed
|
|
44
|
+
steps:
|
|
45
|
+
- name: Download build artifacts
|
|
46
|
+
uses: actions/download-artifact@v4
|
|
47
|
+
with:
|
|
48
|
+
name: dist
|
|
49
|
+
path: dist/
|
|
50
|
+
|
|
51
|
+
- name: Publish to PyPI
|
|
52
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.pyc
|
|
3
|
+
.DS_Store
|
|
4
|
+
|
|
5
|
+
# packaging artifacts (setuptools/build, setuptools-scm)
|
|
6
|
+
/dist/
|
|
7
|
+
/build/
|
|
8
|
+
*.egg-info/
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
|
|
11
|
+
# scratch folder used during development to inspect raw ODS/XML internals,
|
|
12
|
+
# duplicates ods/tests/TEST.ods
|
|
13
|
+
/writing tests/
|
|
14
|
+
|
|
15
|
+
# artifacts generated by ODSReader.export_content_xml() / manual test runs
|
|
16
|
+
tests/*.xml
|
|
17
|
+
tests/Notes 2.txt
|
odsslicer-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anto
|
|
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.
|
odsslicer-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: odsslicer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A numpy-style reader/writer for .ods (OpenDocument Spreadsheet) files
|
|
5
|
+
Author-email: Anto <antonin.marchand@normalesup.org>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/antnardo/odsslicer
|
|
8
|
+
Project-URL: Repository, https://github.com/antnardo/odsslicer
|
|
9
|
+
Project-URL: Issues, https://github.com/antnardo/odsslicer/issues
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Office/Business :: Office Suites
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: beautifulsoup4>=4.9
|
|
23
|
+
Requires-Dist: lxml>=4.6
|
|
24
|
+
Requires-Dist: numpy>=1.20
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest; extra == "test"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# odsslicer
|
|
30
|
+
|
|
31
|
+
[](https://github.com/antnardo/odsslicer/actions/workflows/ci.yml)
|
|
32
|
+
|
|
33
|
+
Python reader for `.ods` files (OpenDocument Spreadsheet, LibreOffice/OpenOffice Calc), with a
|
|
34
|
+
numpy-inspired indexing API: `sheet["A1"]`, `sheet[0, 0]`, `sheet["A1:B3"]`, plain Python
|
|
35
|
+
slices, etc.
|
|
36
|
+
|
|
37
|
+
The module parses `content.xml` directly (via BeautifulSoup) and handles ODF cell types
|
|
38
|
+
(text, number, percentage, currency, date, time, boolean), formulas, as well as repeated and
|
|
39
|
+
merged rows/columns.
|
|
40
|
+
|
|
41
|
+
Write support: `cell.value = ...` then `reader.save(...)`. Repeated or merged cells are
|
|
42
|
+
automatically unrolled/unmerged in the background on first write access, and writing beyond a
|
|
43
|
+
sheet's current extent grows it automatically (new rows/columns) — see
|
|
44
|
+
[Writing](#writing-experimental) below for details and remaining limitations.
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
Not published on PyPI yet. Install directly from GitHub in the meantime:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install git+https://github.com/antnardo/odsslicer.git
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or clone it and install it editable (for local development):
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
git clone https://github.com/antnardo/odsslicer.git
|
|
58
|
+
cd odsslicer
|
|
59
|
+
pip install -e ".[test]"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Dependencies
|
|
63
|
+
|
|
64
|
+
- [`beautifulsoup4`](https://pypi.org/project/beautifulsoup4/) + `lxml` (XML parser)
|
|
65
|
+
- [`numpy`](https://pypi.org/project/numpy/)
|
|
66
|
+
|
|
67
|
+
Installed automatically as dependencies of the package above.
|
|
68
|
+
|
|
69
|
+
## Quick usage
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from odsslicer import ODSReader
|
|
73
|
+
from pathlib import Path
|
|
74
|
+
|
|
75
|
+
table = ODSReader(Path("workbook.ods"))
|
|
76
|
+
table.sheets_names # ["Sheet1", "Sheet2", ...]
|
|
77
|
+
table.sheets # list of Sheet (cached)
|
|
78
|
+
sheet = table.sheet("Sheet1")
|
|
79
|
+
|
|
80
|
+
sheet["A1"] # cell A1 (Cell)
|
|
81
|
+
sheet[0, 0] # equivalent: (row, col), 0-indexed
|
|
82
|
+
sheet[0] # entire row 1 (same as sheet["1"])
|
|
83
|
+
sheet[:, 0] # entire column A (same as sheet["A"])
|
|
84
|
+
sheet["A1:B3"] # block, equivalent to sheet[0:3, 0:2]
|
|
85
|
+
|
|
86
|
+
sheet["ZZZ100000"] # outside the data: returns an empty cell (value=None), no error
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
An address or slice outside the data always returns empty cells (`value=None`) of the correct
|
|
90
|
+
shape, rather than an error — the shape follows the same conventions as numpy (a (n, 1) column
|
|
91
|
+
stays 2D, see `to_vector()` below to flatten it).
|
|
92
|
+
|
|
93
|
+
### Cells (`Cell`)
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
cell = sheet["A1"]
|
|
97
|
+
cell.value # typed value (str / float / bool / datetime.date / datetime.time / None)
|
|
98
|
+
cell.text # text as displayed in the spreadsheet (always a str, or None)
|
|
99
|
+
str(cell) # == cell.text (or "None")
|
|
100
|
+
cell.format # "string" / "float" / "percentage" / "currency" / "date" / "time" / "boolean" / None
|
|
101
|
+
cell.row, cell.col # 0-indexed position
|
|
102
|
+
cell.address # spreadsheet-style address, e.g. "A1", "AZ12"
|
|
103
|
+
cell.is_formula # True if the cell holds an ODF formula
|
|
104
|
+
cell.is_empty # True if no value/text/format is set
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`Cell` supports the usual numeric conversions (`int()`, `float()`, `round()`, `abs()`, `-`,
|
|
108
|
+
`+`, `math.trunc/ceil/floor`) and comparisons (`==`, `<`, `>`, `<=`, `>=`), all of which operate
|
|
109
|
+
on `cell.value`. Note: comparing an empty cell (`value=None`) to a numeric cell raises a
|
|
110
|
+
`TypeError`, just like plain Python (`None < 3.4`).
|
|
111
|
+
|
|
112
|
+
Available formats are listed in `odsslicer.FORMATS` (ODF format -> conversion callable).
|
|
113
|
+
|
|
114
|
+
### Arrays (`ArrayValues`)
|
|
115
|
+
|
|
116
|
+
Any multi-cell selection (`sheet[0]`, `sheet[:, 0]`, `sheet["A1:B3"]`, iterating over a
|
|
117
|
+
`Sheet`...) returns an `ArrayValues` object, a wrapper around a list of `Cell` (1D) or a list
|
|
118
|
+
of lists of `Cell` (2D):
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
arr = sheet["A1:B3"]
|
|
122
|
+
arr.dimension # 0 (a single cell), 1 (row/column), or 2 (block)
|
|
123
|
+
arr.size # numpy-style shape, e.g. (3, 2)
|
|
124
|
+
arr.to_list() # raw values (list or list of list), without the Cell objects
|
|
125
|
+
arr.to_numpy() # np.array of the values
|
|
126
|
+
arr.to_vector() # for a (n, 1) shape: returns a 1D ArrayValues of size (n,)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Equality (`==`) between two `ArrayValues` compares the values (`to_list()`), not the identity
|
|
130
|
+
of the `Cell` objects.
|
|
131
|
+
|
|
132
|
+
### Iteration
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
for row in sheet: # equivalent to sheet[:]
|
|
136
|
+
for cell in row:
|
|
137
|
+
print(cell.address, cell.value)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Writing (experimental)
|
|
141
|
+
|
|
142
|
+
`Cell.value` is writable — the new value replaces the underlying XML content directly in
|
|
143
|
+
memory:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from odsslicer import ODSReader
|
|
147
|
+
|
|
148
|
+
table = ODSReader("workbook.ods")
|
|
149
|
+
sheet = table.sheet("Sheet1")
|
|
150
|
+
|
|
151
|
+
sheet["A1"].value = "new text"
|
|
152
|
+
sheet["A2"].value = 42.5
|
|
153
|
+
sheet["A3"].value = None # clears the cell
|
|
154
|
+
|
|
155
|
+
table.save("modified_workbook.ods") # or table.save() to overwrite the source file
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Accepted types for writing: `str`, `int`/`float`, `bool`, `datetime.date`, `datetime.time`,
|
|
159
|
+
and `None` (clears the cell). Writing a number over a cell already formatted as `percentage`
|
|
160
|
+
or `currency` keeps that format. Writing over a cell that held a formula erases the formula
|
|
161
|
+
(`is_formula` becomes `False` again).
|
|
162
|
+
|
|
163
|
+
`ODSReader.save(path=None)` rewrites the `.ods`: `content.xml` is regenerated from the
|
|
164
|
+
in-memory tree, every other zip member (`styles.xml`, `meta.xml`, `settings.xml`,
|
|
165
|
+
`manifest.xml`, thumbnail...) is copied through unchanged from the source file, and the ODF
|
|
166
|
+
convention (`mimetype` first, uncompressed) is respected. With no argument, `save()`
|
|
167
|
+
overwrites the source file.
|
|
168
|
+
|
|
169
|
+
### Automatic unrolling of repeated and merged cells
|
|
170
|
+
|
|
171
|
+
ODS compresses identical rows/columns into a single XML element shared between several
|
|
172
|
+
`Cell`s, and represents a merge via a top-left "master" cell (carrying the
|
|
173
|
+
`table:number-*-spanned` attributes) plus hidden `table:covered-table-cell` cells. Writing to
|
|
174
|
+
one of these cells automatically triggers, in the background, the "unrolling" of the
|
|
175
|
+
structure involved — the compressed row/column is split into individual XML elements, and/or
|
|
176
|
+
the merge is undone — before the new value is applied:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
sheet["C5"].value = 42 # C5 was part of a block of 6 compressed rows: the block is split
|
|
180
|
+
# into 6 independent rows, only C5's value changes, the other 35
|
|
181
|
+
# cells in the block keep their original value
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Writing to a merged cell (master or hidden) undoes the whole merge: every previously hidden
|
|
185
|
+
cell becomes independent again and reveals its own value — ODF already stores it internally
|
|
186
|
+
under `table:covered-table-cell`, exactly as LibreOffice would when manually un-merging.
|
|
187
|
+
`Cell` objects already obtained before the write remain valid and are automatically repointed
|
|
188
|
+
to their new individual XML element; `sheet.size` never changes as a result of unrolling (the
|
|
189
|
+
logical row/column count was already that value).
|
|
190
|
+
|
|
191
|
+
### Automatic sheet growth
|
|
192
|
+
|
|
193
|
+
Writing to an address outside the current extent (`sheet.size`) grows the sheet instead of
|
|
194
|
+
raising an error: existing rows are widened with blank cells if the requested column exceeds
|
|
195
|
+
the current width, then new (full-width, blank) rows are appended if the requested row
|
|
196
|
+
exceeds the current height — including growing a completely empty sheet
|
|
197
|
+
(`sheet.size == (0, 0)`) from scratch:
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
sheet.size # (9, 2)
|
|
201
|
+
sheet["E12"].value = "corner"
|
|
202
|
+
sheet.size # (12, 5): rows 10-12 added, columns C-E added, everything else blank
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`sheet.size`/`n_rows`/`n_cols` immediately reflect the new extent, and a plain read
|
|
206
|
+
(`sheet.get_row(50)`, `sheet["Z1"].value` with no assignment) never grows anything — only a
|
|
207
|
+
write (`.value = ...`) triggers growth. New rows/cells don't inherit any particular style
|
|
208
|
+
(default formatting).
|
|
209
|
+
|
|
210
|
+
### Displayed text: learned from an example rather than a raw conversion
|
|
211
|
+
|
|
212
|
+
ODF doesn't just store a cell's value (`office:value`): it also stores the text as displayed
|
|
213
|
+
(`text:p`), typically formatted according to the document's locale (decimal separator,
|
|
214
|
+
`%`/`€` suffix, date format...). Rather than imposing an arbitrary format on write,
|
|
215
|
+
`odsslicer` looks for **another cell of the same format** in the document (preferring the
|
|
216
|
+
cell's own prior content if it already had a value), compares its raw value to its displayed
|
|
217
|
+
text to infer a pattern (decimal separator, decimal count, prefix/suffix, or a date pattern
|
|
218
|
+
like `%d/%m/%y` etc.), checks that the pattern reproduces the example exactly, then applies it
|
|
219
|
+
to the new value:
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
sheet["A6"].text # "200.00 %" (value 2.0)
|
|
223
|
+
sheet["A6"].value = 0.5
|
|
224
|
+
sheet["A6"].text # "50.00 %" — same style as the cell's previous content
|
|
225
|
+
|
|
226
|
+
sheet["A8"].text # "28/02/21" (day/month/2-digit-year format)
|
|
227
|
+
sheet["A8"].value = date(2030, 1, 5)
|
|
228
|
+
sheet["A8"].text # "05/01/30"
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
If no example is found, or if the inferred pattern doesn't reproduce the example's text
|
|
232
|
+
exactly (and is therefore deemed unreliable), `odsslicer` falls back to a plain Python
|
|
233
|
+
conversion rather than producing incoherent text. For "general" numbers (plain `float`
|
|
234
|
+
format, not percentage/currency), only the decimal separator is reused — never the decimal
|
|
235
|
+
count, which would truncate the new value's precision.
|
|
236
|
+
|
|
237
|
+
### What is **not** supported
|
|
238
|
+
|
|
239
|
+
- No formula writing, no creating new sheets.
|
|
240
|
+
- No real ODF formatting engine (resolving `styles.xml`, the document's locale, the actual
|
|
241
|
+
currency): the inference above is a learn-by-example heuristic, not a read of the cell's
|
|
242
|
+
style — it can silently fail (falling back to a plain conversion) for a format no other
|
|
243
|
+
cell in the document already illustrates.
|
|
244
|
+
|
|
245
|
+
## Cell addressing
|
|
246
|
+
|
|
247
|
+
`Sheet.address(string, n_rows=1)` converts a text address into a Python index/slice:
|
|
248
|
+
|
|
249
|
+
| Notation | Result |
|
|
250
|
+
|---------------|------------------------------------------|
|
|
251
|
+
| `"A1"` | `(0, 0)` — (row, col) |
|
|
252
|
+
| `"1"` | `0` — single row |
|
|
253
|
+
| `"A"` | `(slice(n_rows), 0)` — entire column |
|
|
254
|
+
| `"A1:B3"` | `(slice(0, 3), slice(0, 2))` |
|
|
255
|
+
| `"A:B"` | `(slice(n_rows), slice(0, 2))` |
|
|
256
|
+
| `"1:2"` | `slice(0, 2)` |
|
|
257
|
+
|
|
258
|
+
A malformed address (`"1A"`, `"A:2"`, `"2:A"`, `"B:A"`...) raises a `ValueError`.
|
|
259
|
+
|
|
260
|
+
`Sheet.string_address(row, col)` performs the reverse conversion (0-indexed index -> `"A1"`,
|
|
261
|
+
`"AZ12"`...) and `Sheet.string_to_col("AZ")` converts column letters to an index — both use
|
|
262
|
+
the usual spreadsheet bijective base-26 numbering (`Z` = 25, `AA` = 26, `AZ` = 51, `BA` =
|
|
263
|
+
52...).
|
|
264
|
+
|
|
265
|
+
## Known limitations
|
|
266
|
+
|
|
267
|
+
- **Writing**: see the detailed limitations in [Writing (experimental)](#writing-experimental)
|
|
268
|
+
above.
|
|
269
|
+
- **Formulas**: the value cached by the spreadsheet is read (`office:value`), the formula
|
|
270
|
+
itself is not re-evaluated (and writing obviously doesn't recompute anything either).
|
|
271
|
+
- Sheets/rows/columns beyond `MAX_REPEAT_ROWS` / `MAX_REPEAT_COLS` (see `src/odsslicer/classes.py`) are
|
|
272
|
+
detected and discarded to avoid materializing rows or columns of size `2**20`/`2**10`
|
|
273
|
+
created by LibreOffice for a sheet's default styling — a `[WARNING]` is printed if a row
|
|
274
|
+
length inconsistency is detected after this cleanup.
|
|
275
|
+
|
|
276
|
+
## Tests
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
git clone https://github.com/antnardo/odsslicer.git
|
|
280
|
+
cd odsslicer
|
|
281
|
+
pip install -e ".[test]"
|
|
282
|
+
pytest
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
The suite (`tests/test_odsslicer.py`) covers addressing (`Sheet.address`,
|
|
286
|
+
`Sheet.string_address`/`string_to_col`), cell types, repeated and merged rows/columns, empty
|
|
287
|
+
sheets, `ArrayValues`, writing (`Cell.value = ...`, `ODSReader.save()` and its safeguards), as
|
|
288
|
+
well as regression tests for the fixed bugs (see below). It runs on every push/PR via
|
|
289
|
+
[GitHub Actions](.github/workflows/ci.yml) across Python 3.10 to 3.13.
|
|
290
|
+
|
|
291
|
+
## Are there already equivalent PyPI modules?
|
|
292
|
+
|
|
293
|
+
| Package | Read/Write | Latest release | Status |
|
|
294
|
+
|---|---|---|---|
|
|
295
|
+
| `odfpy` | Low-level R/W | Jan. 2020 | Nearly abandoned (82 open issues), but still the brick pandas uses internally |
|
|
296
|
+
| `pyexcel-ods(3)` | R/W | > 1 year | Inactive |
|
|
297
|
+
| `ezodf` | R/W | Dec. 2015 | Abandoned for 10 years |
|
|
298
|
+
| `pandas` (`engine="odf"`) | Read (delegates to odfpy) | follows pandas | Convenient but loses formulas/fine-grained formats |
|
|
299
|
+
| `python-calamine` | Read-only (Rust), fast | active | The most actively maintained option for pure reading |
|
|
300
|
+
| `odfdo` (modern fork of odfpy) | Full R/W | recent, regular | Actively maintained, DOM-like API |
|
|
301
|
+
| `pandas-ods-reader` | Read-only -> DataFrame | May 2025 | Maintained, limited scope |
|
|
302
|
+
|
|
303
|
+
None of these packages offer a numpy-style API (`sheet["A1"]`, slicing by cell address) or the
|
|
304
|
+
same granularity on cell formats (currency/percentage/date/time) and merged/repeated cells —
|
|
305
|
+
that's the main argument for publishing this module rather than simply recommending `odfdo`
|
|
306
|
+
or `python-calamine`.
|
|
307
|
+
|
|
308
|
+
## Versions
|
|
309
|
+
|
|
310
|
+
Version numbers are derived automatically from git tags (nothing to bump by hand in the
|
|
311
|
+
source) and follow [Semantic Versioning](https://semver.org/) — while the major version stays
|
|
312
|
+
`0`, the API can still change between minor versions. See the
|
|
313
|
+
[Releases](https://github.com/antnardo/odsslicer/releases) page for the changelog of each
|
|
314
|
+
version.
|
|
315
|
+
|
|
316
|
+
## License
|
|
317
|
+
|
|
318
|
+
[MIT](LICENSE) — reuse with essentially no restriction, just keep the copyright notice.
|
|
319
|
+
|
|
320
|
+
## Project name
|
|
321
|
+
|
|
322
|
+
Chosen name: **`odsslicer`** (available on PyPI as of 2026-07-29), to reflect the module's
|
|
323
|
+
real differentiator — numpy-style indexing/slicing by cell address — rather than a generic
|
|
324
|
+
"ods reader".
|
|
325
|
+
|
|
326
|
+
## History of fixes made before publication
|
|
327
|
+
|
|
328
|
+
While rereading the module for this publication, the following bugs (present in the internal
|
|
329
|
+
version) were fixed in `src/odsslicer/classes.py`:
|
|
330
|
+
|
|
331
|
+
1. **`Sheet.string_address`** produced a wrong address for most multi-letter columns (e.g.
|
|
332
|
+
column 27 → `"BB1"` instead of `"AB1"`, column 51 → `"ZZ1"` instead of `"AZ1"`) due to a
|
|
333
|
+
poorly implemented base-26 numbering. Fixed with the standard bijective numbering
|
|
334
|
+
algorithm.
|
|
335
|
+
2. **`Sheet.get_col`** compared the requested column index to `self.n_rows` instead of
|
|
336
|
+
`self.n_cols` to detect an out-of-range access: on any sheet with more rows than columns,
|
|
337
|
+
requesting an out-of-range column raised an `IndexError` instead of returning an empty
|
|
338
|
+
column.
|
|
339
|
+
3. The `[WARNING]` for rows of differing lengths **never** fired, even in the presence of a
|
|
340
|
+
genuine inconsistency: `rows_len` was a `map` iterator already exhausted once by `max()`,
|
|
341
|
+
hence empty on the second read used to compute the warning.
|
|
342
|
+
4. **`Sheet.empty_row` / `Sheet.empty_col`**, when explicitly passed the `slice` argument,
|
|
343
|
+
returned one fewer element than expected (an element count was recomputed and then
|
|
344
|
+
mistakenly reused as a `range` stop bound).
|
|
345
|
+
5. **`ODSReader.sheets`** was a single-use generator property (couldn't `len()` it or iterate
|
|
346
|
+
it twice), with dead code after the `yield` that could never execute. Replaced with a
|
|
347
|
+
plain, reusable list.
|
|
348
|
+
6. `Cell.__floot__` (a typo for `__floor__`) was fixed — with no observed functional impact
|
|
349
|
+
(Python fell back to `__float__` for `math.floor()`), but it kept a misleading name.
|
|
350
|
+
7. **Reading boolean cells**: the `"boolean"` format looked up the value in `office:value`
|
|
351
|
+
(like numbers) instead of the actual ODF attribute `office:boolean-value`, and converted
|
|
352
|
+
it with `bool(s)` — which returns `True` for the non-empty string `"false"`. A real ODF
|
|
353
|
+
boolean cell therefore always read back as `False`. Fixed (reading and writing are now
|
|
354
|
+
symmetric for this format).
|
|
355
|
+
8. **`Cell.text`/`str(cell)` returned the literal string `"None"`** instead of the actual
|
|
356
|
+
text, in two common cases: a cell whose `<text:p>` is empty (typically a formula whose
|
|
357
|
+
cached result is an empty string) and a cell whose text is spread across several nodes
|
|
358
|
+
(`<text:span>` for partial formatting, e.g. "1st" with "st" as superscript). In both
|
|
359
|
+
cases, `text:p.string` (bs4) is `None` whenever there isn't *exactly* one text child, and
|
|
360
|
+
the old code did `str(p.string)`, turning that `None` into the string `"None"`. Fixed by
|
|
361
|
+
using `p.get_text()`, which correctly concatenates all descendant text (and returns `""`
|
|
362
|
+
for a genuinely empty cell).
|
|
363
|
+
|
|
364
|
+
All of these cases are covered by regression tests in `tests/test_odsslicer.py`.
|