excel-orm 0.1.7__tar.gz → 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.
- {excel_orm-0.1.7 → excel_orm-0.2.0}/PKG-INFO +76 -3
- {excel_orm-0.1.7 → excel_orm-0.2.0}/README.md +75 -2
- {excel_orm-0.1.7 → excel_orm-0.2.0}/pyproject.toml +1 -1
- {excel_orm-0.1.7 → excel_orm-0.2.0}/src/excel_orm/__init__.py +2 -0
- {excel_orm-0.1.7 → excel_orm-0.2.0}/src/excel_orm/column.py +21 -0
- {excel_orm-0.1.7 → excel_orm-0.2.0}/src/excel_orm/orm.py +62 -6
- {excel_orm-0.1.7 → excel_orm-0.2.0}/tests/test_columns.py +9 -0
- excel_orm-0.2.0/tests/test_orm.py +730 -0
- {excel_orm-0.1.7 → excel_orm-0.2.0}/uv.lock +1 -1
- excel_orm-0.1.7/tests/test_orm.py +0 -341
- {excel_orm-0.1.7 → excel_orm-0.2.0}/.gitignore +0 -0
- {excel_orm-0.1.7 → excel_orm-0.2.0}/.pre-commit-config.yaml +0 -0
- {excel_orm-0.1.7 → excel_orm-0.2.0}/.python-version +0 -0
- {excel_orm-0.1.7 → excel_orm-0.2.0}/src/excel_orm/base.py +0 -0
- {excel_orm-0.1.7 → excel_orm-0.2.0}/tests/__init__.py +0 -0
- {excel_orm-0.1.7 → excel_orm-0.2.0}/tests/conftest.py +0 -0
- {excel_orm-0.1.7 → excel_orm-0.2.0}/tests/test_orm_helpers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: excel-orm
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A lightweight Excel ORM for generating templates and parsing typed row models.
|
|
5
5
|
Project-URL: Homepage, https://github.com/acdelrusso/excel-orm
|
|
6
6
|
Project-URL: Repository, https://github.com/acdelrusso/excel-orm
|
|
@@ -28,10 +28,15 @@ This project is designed for the common enterprise pattern where you:
|
|
|
28
28
|
2. let users fill it in,
|
|
29
29
|
3. load the workbook back into Python, producing typed objects grouped by model.
|
|
30
30
|
|
|
31
|
+
Or, going the other direction:
|
|
32
|
+
|
|
33
|
+
1. build a collection of model objects in Python,
|
|
34
|
+
2. write them directly to a structured `.xlsx` file in one call.
|
|
35
|
+
|
|
31
36
|
It uses `openpyxl` for reading/writing Excel files and supports:
|
|
32
37
|
|
|
33
|
-
* multiple model
|
|
34
|
-
* **pivot-style sheets** (matrix input) that expand into row objects.
|
|
38
|
+
* multiple model "tables" laid out horizontally on a worksheet, and
|
|
39
|
+
* **pivot-style sheets** (matrix input/output) that expand into row objects.
|
|
35
40
|
|
|
36
41
|
---
|
|
37
42
|
|
|
@@ -55,6 +60,11 @@ It uses `openpyxl` for reading/writing Excel files and supports:
|
|
|
55
60
|
* `excel_file.cars.all()` → `list[Car]`
|
|
56
61
|
* `excel_file.manufacturing_plants.all()` → `list[ManufacturingPlant]`
|
|
57
62
|
* `excel_file.demands.all()` → `list[Demand]` (from pivot input)
|
|
63
|
+
* **Two-way binding** — write Python objects back to Excel:
|
|
64
|
+
|
|
65
|
+
* `excel_file.add_data(objects)` to pre-populate repos
|
|
66
|
+
* `generate_template(filename)` writes data rows when repos are populated, or a blank template when they are empty
|
|
67
|
+
* pivot sheets write a fully populated matrix from repo contents
|
|
58
68
|
* **Validation hooks**
|
|
59
69
|
|
|
60
70
|
* column-level `not_null`
|
|
@@ -130,6 +140,35 @@ print(cars[0].make, cars[0].year)
|
|
|
130
140
|
print(plants[0].name, plants[0].location)
|
|
131
141
|
```
|
|
132
142
|
|
|
143
|
+
### 4) Write Python objects back to Excel
|
|
144
|
+
|
|
145
|
+
Instead of asking users to fill a template, you can populate an `ExcelFile` directly from Python objects and generate a pre-filled workbook.
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
excel_file = ExcelFile(sheets=[sheet])
|
|
149
|
+
|
|
150
|
+
cars = [
|
|
151
|
+
Car(make="Toyota", model="Camry", year=2020),
|
|
152
|
+
Car(make="Honda", model="Civic", year=2019),
|
|
153
|
+
]
|
|
154
|
+
plants = [
|
|
155
|
+
ManufacturingPlant(name="Plant A", location="NJ"),
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
excel_file.add_data(cars)
|
|
159
|
+
excel_file.add_data(plants)
|
|
160
|
+
|
|
161
|
+
# generate_template detects populated repos and writes data rows automatically.
|
|
162
|
+
excel_file.generate_template("car_inventory_filled.xlsx")
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
You can also append directly to a repo attribute:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
excel_file.cars.append(Car(make="Ford", model="F-150", year=2022))
|
|
169
|
+
excel_file.generate_template("car_inventory_filled.xlsx")
|
|
170
|
+
```
|
|
171
|
+
|
|
133
172
|
---
|
|
134
173
|
|
|
135
174
|
## Pivot Sheets
|
|
@@ -201,6 +240,23 @@ rows = xf.demands.all()
|
|
|
201
240
|
# Demand(region="NA", product="SKU-1", dt=date(2025, 6, 1), value=10)
|
|
202
241
|
```
|
|
203
242
|
|
|
243
|
+
### 4) Write pivot data from Python objects
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
xf = ExcelFile(sheets=[spec])
|
|
247
|
+
|
|
248
|
+
xf.add_data([
|
|
249
|
+
Demand(region="NA", product="SKU-1", dt=date(2025, 6, 1), value=10),
|
|
250
|
+
Demand(region="NA", product="SKU-1", dt=date(2025, 7, 1), value=20),
|
|
251
|
+
Demand(region="EU", product="SKU-1", dt=date(2025, 6, 1), value=5),
|
|
252
|
+
])
|
|
253
|
+
|
|
254
|
+
# Writes title, row/pivot headers, and a filled matrix in one call.
|
|
255
|
+
xf.generate_template("demand_output.xlsx")
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
If `pivot_values` is not set on the spec, the pivot column order is inferred from the insertion order of pivot field values across the repo objects. Missing `(row_key, pivot_value)` combinations write a blank cell.
|
|
259
|
+
|
|
204
260
|
### `row_values` seeding rules
|
|
205
261
|
|
|
206
262
|
* If `len(row_fields) == 1`, each `row_values` element may be a **single scalar** value (backward compatible).
|
|
@@ -226,6 +282,23 @@ Repositories are list-like containers with an `all()` helper:
|
|
|
226
282
|
cars = excel_file.cars.all() # list[Car]
|
|
227
283
|
```
|
|
228
284
|
|
|
285
|
+
You can populate repos in two ways before calling `generate_template()`:
|
|
286
|
+
|
|
287
|
+
**Via `add_data()`** (validated — raises `ValueError` for unregistered model types):
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
excel_file.add_data([Car(make="Toyota", model="Camry", year=2020)])
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
**Via direct list operations** on the repo attribute:
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
excel_file.cars.append(Car(make="Honda", model="Civic", year=2019))
|
|
297
|
+
excel_file.cars.extend([...])
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Populating repos before calling `generate_template()` writes the data into the generated file. Empty repos produce a blank template.
|
|
301
|
+
|
|
229
302
|
### Multi-table Sheets (standard tables)
|
|
230
303
|
|
|
231
304
|
A single worksheet can host multiple model tables. During template generation:
|
|
@@ -8,10 +8,15 @@ This project is designed for the common enterprise pattern where you:
|
|
|
8
8
|
2. let users fill it in,
|
|
9
9
|
3. load the workbook back into Python, producing typed objects grouped by model.
|
|
10
10
|
|
|
11
|
+
Or, going the other direction:
|
|
12
|
+
|
|
13
|
+
1. build a collection of model objects in Python,
|
|
14
|
+
2. write them directly to a structured `.xlsx` file in one call.
|
|
15
|
+
|
|
11
16
|
It uses `openpyxl` for reading/writing Excel files and supports:
|
|
12
17
|
|
|
13
|
-
* multiple model
|
|
14
|
-
* **pivot-style sheets** (matrix input) that expand into row objects.
|
|
18
|
+
* multiple model "tables" laid out horizontally on a worksheet, and
|
|
19
|
+
* **pivot-style sheets** (matrix input/output) that expand into row objects.
|
|
15
20
|
|
|
16
21
|
---
|
|
17
22
|
|
|
@@ -35,6 +40,11 @@ It uses `openpyxl` for reading/writing Excel files and supports:
|
|
|
35
40
|
* `excel_file.cars.all()` → `list[Car]`
|
|
36
41
|
* `excel_file.manufacturing_plants.all()` → `list[ManufacturingPlant]`
|
|
37
42
|
* `excel_file.demands.all()` → `list[Demand]` (from pivot input)
|
|
43
|
+
* **Two-way binding** — write Python objects back to Excel:
|
|
44
|
+
|
|
45
|
+
* `excel_file.add_data(objects)` to pre-populate repos
|
|
46
|
+
* `generate_template(filename)` writes data rows when repos are populated, or a blank template when they are empty
|
|
47
|
+
* pivot sheets write a fully populated matrix from repo contents
|
|
38
48
|
* **Validation hooks**
|
|
39
49
|
|
|
40
50
|
* column-level `not_null`
|
|
@@ -110,6 +120,35 @@ print(cars[0].make, cars[0].year)
|
|
|
110
120
|
print(plants[0].name, plants[0].location)
|
|
111
121
|
```
|
|
112
122
|
|
|
123
|
+
### 4) Write Python objects back to Excel
|
|
124
|
+
|
|
125
|
+
Instead of asking users to fill a template, you can populate an `ExcelFile` directly from Python objects and generate a pre-filled workbook.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
excel_file = ExcelFile(sheets=[sheet])
|
|
129
|
+
|
|
130
|
+
cars = [
|
|
131
|
+
Car(make="Toyota", model="Camry", year=2020),
|
|
132
|
+
Car(make="Honda", model="Civic", year=2019),
|
|
133
|
+
]
|
|
134
|
+
plants = [
|
|
135
|
+
ManufacturingPlant(name="Plant A", location="NJ"),
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
excel_file.add_data(cars)
|
|
139
|
+
excel_file.add_data(plants)
|
|
140
|
+
|
|
141
|
+
# generate_template detects populated repos and writes data rows automatically.
|
|
142
|
+
excel_file.generate_template("car_inventory_filled.xlsx")
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
You can also append directly to a repo attribute:
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
excel_file.cars.append(Car(make="Ford", model="F-150", year=2022))
|
|
149
|
+
excel_file.generate_template("car_inventory_filled.xlsx")
|
|
150
|
+
```
|
|
151
|
+
|
|
113
152
|
---
|
|
114
153
|
|
|
115
154
|
## Pivot Sheets
|
|
@@ -181,6 +220,23 @@ rows = xf.demands.all()
|
|
|
181
220
|
# Demand(region="NA", product="SKU-1", dt=date(2025, 6, 1), value=10)
|
|
182
221
|
```
|
|
183
222
|
|
|
223
|
+
### 4) Write pivot data from Python objects
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
xf = ExcelFile(sheets=[spec])
|
|
227
|
+
|
|
228
|
+
xf.add_data([
|
|
229
|
+
Demand(region="NA", product="SKU-1", dt=date(2025, 6, 1), value=10),
|
|
230
|
+
Demand(region="NA", product="SKU-1", dt=date(2025, 7, 1), value=20),
|
|
231
|
+
Demand(region="EU", product="SKU-1", dt=date(2025, 6, 1), value=5),
|
|
232
|
+
])
|
|
233
|
+
|
|
234
|
+
# Writes title, row/pivot headers, and a filled matrix in one call.
|
|
235
|
+
xf.generate_template("demand_output.xlsx")
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
If `pivot_values` is not set on the spec, the pivot column order is inferred from the insertion order of pivot field values across the repo objects. Missing `(row_key, pivot_value)` combinations write a blank cell.
|
|
239
|
+
|
|
184
240
|
### `row_values` seeding rules
|
|
185
241
|
|
|
186
242
|
* If `len(row_fields) == 1`, each `row_values` element may be a **single scalar** value (backward compatible).
|
|
@@ -206,6 +262,23 @@ Repositories are list-like containers with an `all()` helper:
|
|
|
206
262
|
cars = excel_file.cars.all() # list[Car]
|
|
207
263
|
```
|
|
208
264
|
|
|
265
|
+
You can populate repos in two ways before calling `generate_template()`:
|
|
266
|
+
|
|
267
|
+
**Via `add_data()`** (validated — raises `ValueError` for unregistered model types):
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
excel_file.add_data([Car(make="Toyota", model="Camry", year=2020)])
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
**Via direct list operations** on the repo attribute:
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
excel_file.cars.append(Car(make="Honda", model="Civic", year=2019))
|
|
277
|
+
excel_file.cars.extend([...])
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Populating repos before calling `generate_template()` writes the data into the generated file. Empty repos produce a blank template.
|
|
281
|
+
|
|
209
282
|
### Multi-table Sheets (standard tables)
|
|
210
283
|
|
|
211
284
|
A single worksheet can host multiple model tables. During template generation:
|
|
@@ -4,6 +4,7 @@ from .column import (
|
|
|
4
4
|
ColumnSpec,
|
|
5
5
|
bool_column,
|
|
6
6
|
date_column,
|
|
7
|
+
float_column,
|
|
7
8
|
int_column,
|
|
8
9
|
text_column,
|
|
9
10
|
)
|
|
@@ -18,6 +19,7 @@ __all__ = [
|
|
|
18
19
|
"SheetSpec",
|
|
19
20
|
"bool_column",
|
|
20
21
|
"date_column",
|
|
22
|
+
"float_column",
|
|
21
23
|
"int_column",
|
|
22
24
|
"text_column",
|
|
23
25
|
]
|
|
@@ -112,6 +112,27 @@ def int_column(
|
|
|
112
112
|
)
|
|
113
113
|
|
|
114
114
|
|
|
115
|
+
def float_column(
|
|
116
|
+
header: str | None = None,
|
|
117
|
+
*,
|
|
118
|
+
default: float | None = None,
|
|
119
|
+
not_null: bool = False,
|
|
120
|
+
) -> Column[float]:
|
|
121
|
+
def parse(raw: Any) -> float:
|
|
122
|
+
if raw is None or raw == "":
|
|
123
|
+
return 0.0
|
|
124
|
+
return float(raw)
|
|
125
|
+
|
|
126
|
+
return Column(
|
|
127
|
+
ColumnSpec[float](
|
|
128
|
+
header=header,
|
|
129
|
+
default=default,
|
|
130
|
+
not_null=not_null,
|
|
131
|
+
parser=parse,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
115
136
|
def bool_column(header: str | None = None, *, default: bool | None = None) -> Column[bool]:
|
|
116
137
|
def parse(raw: Any) -> bool:
|
|
117
138
|
if raw is None or raw == "":
|
|
@@ -130,6 +130,16 @@ class ExcelFile:
|
|
|
130
130
|
self._repos[model] = repo
|
|
131
131
|
setattr(self, repo_name, repo)
|
|
132
132
|
|
|
133
|
+
def add_data(self, objects: list[Any]) -> None:
|
|
134
|
+
for obj in objects:
|
|
135
|
+
model = type(obj)
|
|
136
|
+
repo = self._repos.get(model)
|
|
137
|
+
if repo is None:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"Model type '{model.__name__}' is not registered with this ExcelFile."
|
|
140
|
+
)
|
|
141
|
+
repo.append(obj)
|
|
142
|
+
|
|
133
143
|
def generate_template(self, filename: str) -> None:
|
|
134
144
|
wb = Workbook()
|
|
135
145
|
default_ws = wb.active
|
|
@@ -182,6 +192,13 @@ class ExcelFile:
|
|
|
182
192
|
col_letter = ws.cell(row=spec.header_row, column=c).column_letter
|
|
183
193
|
ws.column_dimensions[col_letter].width = max(12, min(40, len(str(h)) + 4))
|
|
184
194
|
|
|
195
|
+
repo = self._repos[model]
|
|
196
|
+
for row_idx, obj in enumerate(repo):
|
|
197
|
+
r = spec.data_start_row + row_idx
|
|
198
|
+
for j, col in enumerate(cols):
|
|
199
|
+
rendered = col.spec.renderer(getattr(obj, col.name))
|
|
200
|
+
ws.cell(row=r, column=start_col + j, value=rendered)
|
|
201
|
+
|
|
185
202
|
current_col = end_col + 1 + spec.template_table_gap
|
|
186
203
|
|
|
187
204
|
def load_data(self, filename: str) -> None:
|
|
@@ -259,15 +276,28 @@ class ExcelFile:
|
|
|
259
276
|
return None
|
|
260
277
|
|
|
261
278
|
def _write_pivot_sheet_template(self, ws: Worksheet, spec: PivotSheetSpec) -> None:
|
|
262
|
-
if not spec.pivot_values:
|
|
263
|
-
raise ValueError("PivotSheetSpec.pivot_values is required for template generation.")
|
|
264
|
-
|
|
265
279
|
title_font = Font(bold=True)
|
|
266
280
|
title_alignment = Alignment(horizontal="center", vertical="center")
|
|
267
281
|
header_font = Font(bold=True)
|
|
268
282
|
|
|
283
|
+
repo = self._repos[spec.model]
|
|
284
|
+
col_map = {c.name: c for c in _get_model_columns(spec.model)}
|
|
285
|
+
|
|
286
|
+
# Determine effective pivot values: prefer spec, infer from repo, else error
|
|
287
|
+
if spec.pivot_values:
|
|
288
|
+
effective_pivot_values = spec.pivot_values
|
|
289
|
+
elif repo:
|
|
290
|
+
seen_pv: dict[Any, None] = {}
|
|
291
|
+
for obj in repo:
|
|
292
|
+
seen_pv[getattr(obj, spec.pivot_field)] = None
|
|
293
|
+
effective_pivot_values = list(seen_pv.keys())
|
|
294
|
+
else:
|
|
295
|
+
raise ValueError(
|
|
296
|
+
"PivotSheetSpec.pivot_values is required for template generation when no data is present."
|
|
297
|
+
)
|
|
298
|
+
|
|
269
299
|
# Title merged across the pivot header span
|
|
270
|
-
end_col = spec.data_start_col + len(
|
|
300
|
+
end_col = spec.data_start_col + len(effective_pivot_values) - 1
|
|
271
301
|
|
|
272
302
|
ws.merge_cells(
|
|
273
303
|
start_row=spec.title_row,
|
|
@@ -288,14 +318,40 @@ class ExcelFile:
|
|
|
288
318
|
ws.column_dimensions[col_letter].width = max(12, min(40, len(str(rf)) + 4))
|
|
289
319
|
|
|
290
320
|
# Pivot headers across the top
|
|
291
|
-
for j, pv in enumerate(
|
|
321
|
+
for j, pv in enumerate(effective_pivot_values):
|
|
292
322
|
c = spec.data_start_col + j
|
|
293
323
|
cell = ws.cell(spec.header_row, c, pv)
|
|
294
324
|
cell.font = header_font
|
|
295
325
|
col_letter = cell.column_letter
|
|
296
326
|
ws.column_dimensions[col_letter].width = 14
|
|
297
327
|
|
|
298
|
-
if
|
|
328
|
+
if repo:
|
|
329
|
+
# Collect ordered unique row keys from repo
|
|
330
|
+
seen_row_keys: dict[tuple, None] = {}
|
|
331
|
+
for obj in repo:
|
|
332
|
+
key = tuple(getattr(obj, f) for f in spec.row_fields)
|
|
333
|
+
seen_row_keys[key] = None
|
|
334
|
+
row_keys = list(seen_row_keys.keys())
|
|
335
|
+
|
|
336
|
+
# Build (row_key, pivot_value) -> rendered cell value lookup
|
|
337
|
+
val_col_obj = col_map[spec.value_field]
|
|
338
|
+
lookup: dict[tuple, Any] = {}
|
|
339
|
+
for obj in repo:
|
|
340
|
+
row_key = tuple(getattr(obj, f) for f in spec.row_fields)
|
|
341
|
+
pv = getattr(obj, spec.pivot_field)
|
|
342
|
+
lookup[(row_key, pv)] = val_col_obj.spec.renderer(getattr(obj, spec.value_field))
|
|
343
|
+
|
|
344
|
+
# Write row keys and matrix values
|
|
345
|
+
for i, row_key in enumerate(row_keys):
|
|
346
|
+
r = spec.data_start_row + i
|
|
347
|
+
for k, part in enumerate(row_key):
|
|
348
|
+
rendered_key = col_map[spec.row_fields[k]].spec.renderer(part)
|
|
349
|
+
ws.cell(r, spec.row_header_col + k, rendered_key)
|
|
350
|
+
for j, pv in enumerate(effective_pivot_values):
|
|
351
|
+
c = spec.data_start_col + j
|
|
352
|
+
ws.cell(r, c, lookup.get((row_key, pv)))
|
|
353
|
+
|
|
354
|
+
elif spec.row_values:
|
|
299
355
|
for i, rv in enumerate(spec.row_values):
|
|
300
356
|
r = spec.data_start_row + i
|
|
301
357
|
|
|
@@ -9,6 +9,7 @@ from src.excel_orm import (
|
|
|
9
9
|
RowBase,
|
|
10
10
|
bool_column,
|
|
11
11
|
date_column,
|
|
12
|
+
float_column,
|
|
12
13
|
int_column,
|
|
13
14
|
text_column,
|
|
14
15
|
)
|
|
@@ -67,6 +68,14 @@ def test_int_column_parsing():
|
|
|
67
68
|
assert col.parse_cell(7) == 7
|
|
68
69
|
|
|
69
70
|
|
|
71
|
+
def test_float_column_parsing():
|
|
72
|
+
col = float_column(header="Z")
|
|
73
|
+
assert col.parse_cell(None) == 0.0
|
|
74
|
+
assert col.parse_cell("") == 0.0
|
|
75
|
+
assert col.parse_cell("3.14") == 3.14
|
|
76
|
+
assert col.parse_cell(2) == 2.0
|
|
77
|
+
|
|
78
|
+
|
|
70
79
|
def test_bool_column_parsing_valid_values():
|
|
71
80
|
col = bool_column(header="B")
|
|
72
81
|
assert col.parse_cell(None) is False
|