excel-orm 0.1.1__tar.gz → 0.1.3__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.1 → excel_orm-0.1.3}/PKG-INFO +132 -47
- excel_orm-0.1.3/README.md +338 -0
- {excel_orm-0.1.1 → excel_orm-0.1.3}/pyproject.toml +1 -1
- {excel_orm-0.1.1 → excel_orm-0.1.3}/src/excel_orm/column.py +22 -6
- {excel_orm-0.1.1 → excel_orm-0.1.3}/src/excel_orm/orm.py +42 -19
- {excel_orm-0.1.1 → excel_orm-0.1.3}/tests/conftest.py +12 -1
- {excel_orm-0.1.1 → excel_orm-0.1.3}/tests/test_orm.py +111 -2
- {excel_orm-0.1.1 → excel_orm-0.1.3}/uv.lock +1 -1
- excel_orm-0.1.1/README.md +0 -253
- {excel_orm-0.1.1 → excel_orm-0.1.3}/.gitignore +0 -0
- {excel_orm-0.1.1 → excel_orm-0.1.3}/.pre-commit-config.yaml +0 -0
- {excel_orm-0.1.1 → excel_orm-0.1.3}/.python-version +0 -0
- {excel_orm-0.1.1 → excel_orm-0.1.3}/src/excel_orm/__init__.py +0 -0
- {excel_orm-0.1.1 → excel_orm-0.1.3}/tests/__init__.py +0 -0
- {excel_orm-0.1.1 → excel_orm-0.1.3}/tests/test_columns.py +0 -0
- {excel_orm-0.1.1 → excel_orm-0.1.3}/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.1.
|
|
3
|
+
Version: 0.1.3
|
|
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
|
|
@@ -23,38 +23,53 @@ Description-Content-Type: text/markdown
|
|
|
23
23
|
A lightweight, typed “Excel ORM” for generating Excel templates and parsing Excel workbooks into Python objects using column descriptors.
|
|
24
24
|
|
|
25
25
|
This project is designed for the common enterprise pattern where you:
|
|
26
|
-
1) generate a structured `.xlsx` template for users,
|
|
27
|
-
2) let users fill it in,
|
|
28
|
-
3) load the workbook back into Python, producing typed objects grouped by model.
|
|
29
26
|
|
|
30
|
-
|
|
27
|
+
1. generate a structured `.xlsx` template for users,
|
|
28
|
+
2. let users fill it in,
|
|
29
|
+
3. load the workbook back into Python, producing typed objects grouped by model.
|
|
30
|
+
|
|
31
|
+
It uses `openpyxl` for reading/writing Excel files and supports:
|
|
32
|
+
|
|
33
|
+
* multiple model “tables” laid out horizontally on a worksheet, and
|
|
34
|
+
* **pivot-style sheets** (matrix input) that expand into row objects.
|
|
31
35
|
|
|
32
36
|
---
|
|
33
37
|
|
|
34
38
|
## Features
|
|
35
39
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
40
|
+
* **Typed column descriptors** (`text_column`, `int_column`, `bool_column`, `date_column`)
|
|
41
|
+
* **Template generation** for standard tables:
|
|
42
|
+
|
|
43
|
+
* merged **table title cells** (pluralized model name)
|
|
44
|
+
* bold headers
|
|
45
|
+
* sensible column widths
|
|
46
|
+
* multiple tables laid out horizontally on the same sheet with a configurable gap
|
|
47
|
+
* **Template generation** for pivot sheets:
|
|
48
|
+
|
|
49
|
+
* merged title across the **row header block + pivot header span**
|
|
50
|
+
* bold row-field headers in the left block (`row_fields`)
|
|
51
|
+
* pivot column headers rendered across the top (`pivot_values`)
|
|
52
|
+
* optional seeding of row keys down the left block (`row_values`)
|
|
53
|
+
* **Workbook parsing** into model-specific repositories:
|
|
54
|
+
|
|
55
|
+
* `excel_file.cars.all()` → `list[Car]`
|
|
56
|
+
* `excel_file.manufacturing_plants.all()` → `list[ManufacturingPlant]`
|
|
57
|
+
* `excel_file.demands.all()` → `list[Demand]` (from pivot input)
|
|
58
|
+
* **Validation hooks**
|
|
59
|
+
|
|
60
|
+
* column-level `not_null`
|
|
61
|
+
* optional row exclusion rules via `excludes`
|
|
62
|
+
* optional model-level `validate()` method
|
|
49
63
|
|
|
50
64
|
---
|
|
51
65
|
|
|
52
66
|
## Installation
|
|
53
67
|
|
|
54
|
-
### From PyPI
|
|
68
|
+
### From PyPI
|
|
69
|
+
|
|
55
70
|
```bash
|
|
56
71
|
pip install excel-orm
|
|
57
|
-
|
|
72
|
+
```
|
|
58
73
|
|
|
59
74
|
### From source (uv)
|
|
60
75
|
|
|
@@ -72,7 +87,7 @@ uv sync
|
|
|
72
87
|
|
|
73
88
|
```python
|
|
74
89
|
from excel_orm.column import Column, text_column, int_column
|
|
75
|
-
from excel_orm
|
|
90
|
+
from excel_orm import ExcelFile, SheetSpec
|
|
76
91
|
|
|
77
92
|
class Car:
|
|
78
93
|
make: Column[str] = text_column(header="Make", not_null=True)
|
|
@@ -84,7 +99,7 @@ class ManufacturingPlant:
|
|
|
84
99
|
location: Column[str] = text_column(header="Location")
|
|
85
100
|
```
|
|
86
101
|
|
|
87
|
-
### 2) Declare a sheet containing multiple models
|
|
102
|
+
### 2) Declare a sheet containing multiple models (standard tables)
|
|
88
103
|
|
|
89
104
|
Each model becomes its own table on the same worksheet.
|
|
90
105
|
|
|
@@ -92,13 +107,9 @@ Each model becomes its own table on the same worksheet.
|
|
|
92
107
|
sheet = SheetSpec(
|
|
93
108
|
name="Cars",
|
|
94
109
|
models=[Car, ManufacturingPlant],
|
|
95
|
-
|
|
96
|
-
# Layout rows
|
|
97
110
|
title_row=1,
|
|
98
111
|
header_row=2,
|
|
99
112
|
data_start_row=3,
|
|
100
|
-
|
|
101
|
-
# Horizontal spacing between model tables
|
|
102
113
|
template_table_gap=2,
|
|
103
114
|
)
|
|
104
115
|
```
|
|
@@ -108,12 +119,8 @@ sheet = SheetSpec(
|
|
|
108
119
|
```python
|
|
109
120
|
excel_file = ExcelFile(sheets=[sheet])
|
|
110
121
|
|
|
111
|
-
# Generate a blank template workbook
|
|
112
122
|
excel_file.generate_template("car_inventory_template.xlsx")
|
|
113
|
-
|
|
114
123
|
# Users fill in data in Excel...
|
|
115
|
-
|
|
116
|
-
# Load the filled workbook into repositories
|
|
117
124
|
excel_file.load_data("car_inventory_data.xlsx")
|
|
118
125
|
|
|
119
126
|
cars = excel_file.cars.all()
|
|
@@ -125,6 +132,84 @@ print(plants[0].name, plants[0].location)
|
|
|
125
132
|
|
|
126
133
|
---
|
|
127
134
|
|
|
135
|
+
## Pivot Sheets
|
|
136
|
+
|
|
137
|
+
Pivot sheets are useful when users prefer matrix-style input (e.g., values by multiple row keys × time), but your application wants row objects.
|
|
138
|
+
|
|
139
|
+
### Pivot Layout Summary
|
|
140
|
+
|
|
141
|
+
For a pivot sheet:
|
|
142
|
+
|
|
143
|
+
* `row_fields` define the **leftmost columns** (starting at `row_header_col`)
|
|
144
|
+
* `pivot_values` are written across the top row (`header_row`) starting at `data_start_col`
|
|
145
|
+
* `data_start_col` is **computed** as:
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
data_start_col = len(row_fields) + 1
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Parsing behavior:
|
|
152
|
+
|
|
153
|
+
* pivot headers are read from `header_row` across the top until the first blank header cell
|
|
154
|
+
* parsing stops when the **row header block** (all `row_fields` columns) is blank for a row
|
|
155
|
+
* each non-blank pivot cell emits one object (unless `include_blanks=True`)
|
|
156
|
+
|
|
157
|
+
### 1) Define a “row” model
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
from datetime import date
|
|
161
|
+
from excel_orm.column import Column, text_column, date_column, int_column
|
|
162
|
+
|
|
163
|
+
class Demand:
|
|
164
|
+
region: Column[str] = text_column(header="Region", not_null=True)
|
|
165
|
+
product: Column[str] = text_column(header="Product", not_null=True)
|
|
166
|
+
dt: Column[date] = date_column(header="Date", not_null=True)
|
|
167
|
+
value: Column[int] = int_column(header="Value", not_null=True)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### 2) Create a `PivotSheetSpec` (multi-row-fields)
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
from datetime import date
|
|
174
|
+
from excel_orm import ExcelFile, PivotSheetSpec
|
|
175
|
+
|
|
176
|
+
spec = PivotSheetSpec(
|
|
177
|
+
name="Demand",
|
|
178
|
+
model=Demand,
|
|
179
|
+
pivot_field="dt",
|
|
180
|
+
row_fields=["region", "product"],
|
|
181
|
+
value_field="value",
|
|
182
|
+
pivot_values=[date(2025, 6, 1), date(2025, 7, 1), date(2025, 8, 1)],
|
|
183
|
+
# optional: seed row keys (must match row_fields arity)
|
|
184
|
+
row_values=[
|
|
185
|
+
("NA", "SKU-1"),
|
|
186
|
+
("NA", "SKU-2"),
|
|
187
|
+
("EU", "SKU-1"),
|
|
188
|
+
],
|
|
189
|
+
)
|
|
190
|
+
xf = ExcelFile(sheets=[spec])
|
|
191
|
+
xf.generate_template("demand_template.xlsx")
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### 3) Load a filled pivot sheet
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
xf.load_data("demand_filled.xlsx")
|
|
198
|
+
rows = xf.demands.all()
|
|
199
|
+
|
|
200
|
+
# each element is a Demand row:
|
|
201
|
+
# Demand(region="NA", product="SKU-1", dt=date(2025, 6, 1), value=10)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### `row_values` seeding rules
|
|
205
|
+
|
|
206
|
+
* If `len(row_fields) == 1`, each `row_values` element may be a **single scalar** value (backward compatible).
|
|
207
|
+
* If `len(row_fields) > 1`, each `row_values` element must be a **tuple/list with length == len(row_fields)**.
|
|
208
|
+
|
|
209
|
+
* Otherwise template generation raises `ValueError`.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
128
213
|
## How It Works
|
|
129
214
|
|
|
130
215
|
### Repositories
|
|
@@ -133,26 +218,27 @@ For each model you register, `ExcelFile` creates a repository attribute on the i
|
|
|
133
218
|
|
|
134
219
|
* `Car` → `excel_file.cars`
|
|
135
220
|
* `ManufacturingPlant` → `excel_file.manufacturing_plants`
|
|
221
|
+
* `Demand` → `excel_file.demands`
|
|
136
222
|
|
|
137
|
-
Repositories are
|
|
223
|
+
Repositories are list-like containers with an `all()` helper:
|
|
138
224
|
|
|
139
225
|
```python
|
|
140
226
|
cars = excel_file.cars.all() # list[Car]
|
|
141
227
|
```
|
|
142
228
|
|
|
143
|
-
### Multi-table Sheets
|
|
229
|
+
### Multi-table Sheets (standard tables)
|
|
144
230
|
|
|
145
231
|
A single worksheet can host multiple model tables. During template generation:
|
|
146
232
|
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
233
|
+
* a merged title cell is written above each table (pluralized class name in title case)
|
|
234
|
+
* headers appear under the title
|
|
235
|
+
* data rows begin at `data_start_row`
|
|
236
|
+
* tables are placed horizontally with `template_table_gap` blank columns between them
|
|
151
237
|
|
|
152
238
|
During parsing:
|
|
153
239
|
|
|
154
|
-
*
|
|
155
|
-
*
|
|
240
|
+
* the library locates each model table by matching the expected header sequence
|
|
241
|
+
* it reads contiguous rows until a blank row is encountered
|
|
156
242
|
|
|
157
243
|
---
|
|
158
244
|
|
|
@@ -167,8 +253,8 @@ class Example:
|
|
|
167
253
|
name: Column[str] = text_column(header="Name", not_null=True, strip=True)
|
|
168
254
|
```
|
|
169
255
|
|
|
170
|
-
* `None` parses to `""` (empty string)
|
|
171
|
-
* `strip=True` trims whitespace
|
|
256
|
+
* `None` parses to `""` (empty string)
|
|
257
|
+
* `strip=True` trims whitespace
|
|
172
258
|
|
|
173
259
|
### Integer
|
|
174
260
|
|
|
@@ -179,7 +265,7 @@ class Example:
|
|
|
179
265
|
qty: Column[int] = int_column(header="Qty", not_null=True)
|
|
180
266
|
```
|
|
181
267
|
|
|
182
|
-
* `None` or `""` parses to `0
|
|
268
|
+
* `None` or `""` parses to `0`
|
|
183
269
|
|
|
184
270
|
### Boolean
|
|
185
271
|
|
|
@@ -201,6 +287,7 @@ Invalid values raise `ValueError`.
|
|
|
201
287
|
### Date
|
|
202
288
|
|
|
203
289
|
```python
|
|
290
|
+
from datetime import date
|
|
204
291
|
from excel_orm.column import Column, date_column
|
|
205
292
|
|
|
206
293
|
class Example:
|
|
@@ -211,7 +298,7 @@ The date parser supports:
|
|
|
211
298
|
|
|
212
299
|
* Excel-native `datetime`/`date` values from `openpyxl`
|
|
213
300
|
* ISO strings like `2025-06-01` and `2025-06-01T13:45:00`
|
|
214
|
-
*
|
|
301
|
+
* common business formats including `01-JUN-2025`
|
|
215
302
|
|
|
216
303
|
Invalid/empty values raise `ValueError`.
|
|
217
304
|
|
|
@@ -230,15 +317,13 @@ If a `not_null=True` column parses to `None` or `""`, a `ValueError` is raised.
|
|
|
230
317
|
|
|
231
318
|
### Row exclusion: `excludes`
|
|
232
319
|
|
|
233
|
-
If you set `excludes`, rows matching those raw values in that column will be skipped.
|
|
320
|
+
If you set `excludes`, rows matching those raw values in that column will be skipped during parsing.
|
|
234
321
|
|
|
235
322
|
```python
|
|
236
323
|
status: Column[str] = text_column(header="Status")
|
|
237
|
-
status.spec.excludes = {"IGNORE", "SKIP"}
|
|
324
|
+
status.spec.excludes = {"IGNORE", "SKIP"}
|
|
238
325
|
```
|
|
239
326
|
|
|
240
|
-
(If you want a nicer API for excludes, consider adding it directly to the column factory signature.)
|
|
241
|
-
|
|
242
327
|
### Model-level: `validate()`
|
|
243
328
|
|
|
244
329
|
If your model defines a `validate(self)` method, it is called after a row is parsed.
|
|
@@ -263,7 +348,7 @@ class Car:
|
|
|
263
348
|
uv run pytest
|
|
264
349
|
```
|
|
265
350
|
|
|
266
|
-
### Lint/format
|
|
351
|
+
### Lint/format
|
|
267
352
|
|
|
268
353
|
If you use Ruff:
|
|
269
354
|
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
# Excel ORM
|
|
2
|
+
|
|
3
|
+
A lightweight, typed “Excel ORM” for generating Excel templates and parsing Excel workbooks into Python objects using column descriptors.
|
|
4
|
+
|
|
5
|
+
This project is designed for the common enterprise pattern where you:
|
|
6
|
+
|
|
7
|
+
1. generate a structured `.xlsx` template for users,
|
|
8
|
+
2. let users fill it in,
|
|
9
|
+
3. load the workbook back into Python, producing typed objects grouped by model.
|
|
10
|
+
|
|
11
|
+
It uses `openpyxl` for reading/writing Excel files and supports:
|
|
12
|
+
|
|
13
|
+
* multiple model “tables” laid out horizontally on a worksheet, and
|
|
14
|
+
* **pivot-style sheets** (matrix input) that expand into row objects.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
* **Typed column descriptors** (`text_column`, `int_column`, `bool_column`, `date_column`)
|
|
21
|
+
* **Template generation** for standard tables:
|
|
22
|
+
|
|
23
|
+
* merged **table title cells** (pluralized model name)
|
|
24
|
+
* bold headers
|
|
25
|
+
* sensible column widths
|
|
26
|
+
* multiple tables laid out horizontally on the same sheet with a configurable gap
|
|
27
|
+
* **Template generation** for pivot sheets:
|
|
28
|
+
|
|
29
|
+
* merged title across the **row header block + pivot header span**
|
|
30
|
+
* bold row-field headers in the left block (`row_fields`)
|
|
31
|
+
* pivot column headers rendered across the top (`pivot_values`)
|
|
32
|
+
* optional seeding of row keys down the left block (`row_values`)
|
|
33
|
+
* **Workbook parsing** into model-specific repositories:
|
|
34
|
+
|
|
35
|
+
* `excel_file.cars.all()` → `list[Car]`
|
|
36
|
+
* `excel_file.manufacturing_plants.all()` → `list[ManufacturingPlant]`
|
|
37
|
+
* `excel_file.demands.all()` → `list[Demand]` (from pivot input)
|
|
38
|
+
* **Validation hooks**
|
|
39
|
+
|
|
40
|
+
* column-level `not_null`
|
|
41
|
+
* optional row exclusion rules via `excludes`
|
|
42
|
+
* optional model-level `validate()` method
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
### From PyPI
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install excel-orm
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### From source (uv)
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
git clone <your-repo-url>
|
|
58
|
+
cd excel-orm
|
|
59
|
+
uv sync
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Quick Start
|
|
65
|
+
|
|
66
|
+
### 1) Define models using `Column[...]` descriptors
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from excel_orm.column import Column, text_column, int_column
|
|
70
|
+
from excel_orm import ExcelFile, SheetSpec
|
|
71
|
+
|
|
72
|
+
class Car:
|
|
73
|
+
make: Column[str] = text_column(header="Make", not_null=True)
|
|
74
|
+
model: Column[str] = text_column(header="Model", not_null=True)
|
|
75
|
+
year: Column[int] = int_column(header="Year", not_null=True)
|
|
76
|
+
|
|
77
|
+
class ManufacturingPlant:
|
|
78
|
+
name: Column[str] = text_column(header="Factory Name", not_null=True)
|
|
79
|
+
location: Column[str] = text_column(header="Location")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 2) Declare a sheet containing multiple models (standard tables)
|
|
83
|
+
|
|
84
|
+
Each model becomes its own table on the same worksheet.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
sheet = SheetSpec(
|
|
88
|
+
name="Cars",
|
|
89
|
+
models=[Car, ManufacturingPlant],
|
|
90
|
+
title_row=1,
|
|
91
|
+
header_row=2,
|
|
92
|
+
data_start_row=3,
|
|
93
|
+
template_table_gap=2,
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 3) Create an `ExcelFile`, generate a template, then load data
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
excel_file = ExcelFile(sheets=[sheet])
|
|
101
|
+
|
|
102
|
+
excel_file.generate_template("car_inventory_template.xlsx")
|
|
103
|
+
# Users fill in data in Excel...
|
|
104
|
+
excel_file.load_data("car_inventory_data.xlsx")
|
|
105
|
+
|
|
106
|
+
cars = excel_file.cars.all()
|
|
107
|
+
plants = excel_file.manufacturing_plants.all()
|
|
108
|
+
|
|
109
|
+
print(cars[0].make, cars[0].year)
|
|
110
|
+
print(plants[0].name, plants[0].location)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Pivot Sheets
|
|
116
|
+
|
|
117
|
+
Pivot sheets are useful when users prefer matrix-style input (e.g., values by multiple row keys × time), but your application wants row objects.
|
|
118
|
+
|
|
119
|
+
### Pivot Layout Summary
|
|
120
|
+
|
|
121
|
+
For a pivot sheet:
|
|
122
|
+
|
|
123
|
+
* `row_fields` define the **leftmost columns** (starting at `row_header_col`)
|
|
124
|
+
* `pivot_values` are written across the top row (`header_row`) starting at `data_start_col`
|
|
125
|
+
* `data_start_col` is **computed** as:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
data_start_col = len(row_fields) + 1
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Parsing behavior:
|
|
132
|
+
|
|
133
|
+
* pivot headers are read from `header_row` across the top until the first blank header cell
|
|
134
|
+
* parsing stops when the **row header block** (all `row_fields` columns) is blank for a row
|
|
135
|
+
* each non-blank pivot cell emits one object (unless `include_blanks=True`)
|
|
136
|
+
|
|
137
|
+
### 1) Define a “row” model
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from datetime import date
|
|
141
|
+
from excel_orm.column import Column, text_column, date_column, int_column
|
|
142
|
+
|
|
143
|
+
class Demand:
|
|
144
|
+
region: Column[str] = text_column(header="Region", not_null=True)
|
|
145
|
+
product: Column[str] = text_column(header="Product", not_null=True)
|
|
146
|
+
dt: Column[date] = date_column(header="Date", not_null=True)
|
|
147
|
+
value: Column[int] = int_column(header="Value", not_null=True)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### 2) Create a `PivotSheetSpec` (multi-row-fields)
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from datetime import date
|
|
154
|
+
from excel_orm import ExcelFile, PivotSheetSpec
|
|
155
|
+
|
|
156
|
+
spec = PivotSheetSpec(
|
|
157
|
+
name="Demand",
|
|
158
|
+
model=Demand,
|
|
159
|
+
pivot_field="dt",
|
|
160
|
+
row_fields=["region", "product"],
|
|
161
|
+
value_field="value",
|
|
162
|
+
pivot_values=[date(2025, 6, 1), date(2025, 7, 1), date(2025, 8, 1)],
|
|
163
|
+
# optional: seed row keys (must match row_fields arity)
|
|
164
|
+
row_values=[
|
|
165
|
+
("NA", "SKU-1"),
|
|
166
|
+
("NA", "SKU-2"),
|
|
167
|
+
("EU", "SKU-1"),
|
|
168
|
+
],
|
|
169
|
+
)
|
|
170
|
+
xf = ExcelFile(sheets=[spec])
|
|
171
|
+
xf.generate_template("demand_template.xlsx")
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### 3) Load a filled pivot sheet
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
xf.load_data("demand_filled.xlsx")
|
|
178
|
+
rows = xf.demands.all()
|
|
179
|
+
|
|
180
|
+
# each element is a Demand row:
|
|
181
|
+
# Demand(region="NA", product="SKU-1", dt=date(2025, 6, 1), value=10)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### `row_values` seeding rules
|
|
185
|
+
|
|
186
|
+
* If `len(row_fields) == 1`, each `row_values` element may be a **single scalar** value (backward compatible).
|
|
187
|
+
* If `len(row_fields) > 1`, each `row_values` element must be a **tuple/list with length == len(row_fields)**.
|
|
188
|
+
|
|
189
|
+
* Otherwise template generation raises `ValueError`.
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## How It Works
|
|
194
|
+
|
|
195
|
+
### Repositories
|
|
196
|
+
|
|
197
|
+
For each model you register, `ExcelFile` creates a repository attribute on the instance using a snake_case pluralized name:
|
|
198
|
+
|
|
199
|
+
* `Car` → `excel_file.cars`
|
|
200
|
+
* `ManufacturingPlant` → `excel_file.manufacturing_plants`
|
|
201
|
+
* `Demand` → `excel_file.demands`
|
|
202
|
+
|
|
203
|
+
Repositories are list-like containers with an `all()` helper:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
cars = excel_file.cars.all() # list[Car]
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Multi-table Sheets (standard tables)
|
|
210
|
+
|
|
211
|
+
A single worksheet can host multiple model tables. During template generation:
|
|
212
|
+
|
|
213
|
+
* a merged title cell is written above each table (pluralized class name in title case)
|
|
214
|
+
* headers appear under the title
|
|
215
|
+
* data rows begin at `data_start_row`
|
|
216
|
+
* tables are placed horizontally with `template_table_gap` blank columns between them
|
|
217
|
+
|
|
218
|
+
During parsing:
|
|
219
|
+
|
|
220
|
+
* the library locates each model table by matching the expected header sequence
|
|
221
|
+
* it reads contiguous rows until a blank row is encountered
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Column Types
|
|
226
|
+
|
|
227
|
+
### Text
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
from excel_orm.column import Column, text_column
|
|
231
|
+
|
|
232
|
+
class Example:
|
|
233
|
+
name: Column[str] = text_column(header="Name", not_null=True, strip=True)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
* `None` parses to `""` (empty string)
|
|
237
|
+
* `strip=True` trims whitespace
|
|
238
|
+
|
|
239
|
+
### Integer
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
from excel_orm.column import Column, int_column
|
|
243
|
+
|
|
244
|
+
class Example:
|
|
245
|
+
qty: Column[int] = int_column(header="Qty", not_null=True)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
* `None` or `""` parses to `0`
|
|
249
|
+
|
|
250
|
+
### Boolean
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
from excel_orm.column import Column, bool_column
|
|
254
|
+
|
|
255
|
+
class Example:
|
|
256
|
+
active: Column[bool] = bool_column(header="Active")
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Accepted values include:
|
|
260
|
+
|
|
261
|
+
* True: `true, t, yes, y, 1` (case-insensitive)
|
|
262
|
+
* False: `false, f, no, n, 0`
|
|
263
|
+
* `None` / empty parses to `False`
|
|
264
|
+
|
|
265
|
+
Invalid values raise `ValueError`.
|
|
266
|
+
|
|
267
|
+
### Date
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
from datetime import date
|
|
271
|
+
from excel_orm.column import Column, date_column
|
|
272
|
+
|
|
273
|
+
class Example:
|
|
274
|
+
start_date: Column[date] = date_column(header="Start Date")
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
The date parser supports:
|
|
278
|
+
|
|
279
|
+
* Excel-native `datetime`/`date` values from `openpyxl`
|
|
280
|
+
* ISO strings like `2025-06-01` and `2025-06-01T13:45:00`
|
|
281
|
+
* common business formats including `01-JUN-2025`
|
|
282
|
+
|
|
283
|
+
Invalid/empty values raise `ValueError`.
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Validation
|
|
288
|
+
|
|
289
|
+
### Column-level: `not_null`
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
class Car:
|
|
293
|
+
make: Column[str] = text_column(header="Make", not_null=True)
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
If a `not_null=True` column parses to `None` or `""`, a `ValueError` is raised.
|
|
297
|
+
|
|
298
|
+
### Row exclusion: `excludes`
|
|
299
|
+
|
|
300
|
+
If you set `excludes`, rows matching those raw values in that column will be skipped during parsing.
|
|
301
|
+
|
|
302
|
+
```python
|
|
303
|
+
status: Column[str] = text_column(header="Status")
|
|
304
|
+
status.spec.excludes = {"IGNORE", "SKIP"}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Model-level: `validate()`
|
|
308
|
+
|
|
309
|
+
If your model defines a `validate(self)` method, it is called after a row is parsed.
|
|
310
|
+
|
|
311
|
+
```python
|
|
312
|
+
class Car:
|
|
313
|
+
make: Column[str] = text_column(header="Make", not_null=True)
|
|
314
|
+
year: Column[int] = int_column(header="Year", not_null=True)
|
|
315
|
+
|
|
316
|
+
def validate(self) -> None:
|
|
317
|
+
if self.year < 1886:
|
|
318
|
+
raise ValueError("Invalid car year")
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
## Development
|
|
324
|
+
|
|
325
|
+
### Run tests
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
uv run pytest
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Lint/format
|
|
332
|
+
|
|
333
|
+
If you use Ruff:
|
|
334
|
+
|
|
335
|
+
```bash
|
|
336
|
+
uv run ruff check .
|
|
337
|
+
uv run ruff format .
|
|
338
|
+
```
|
|
@@ -3,11 +3,18 @@ from __future__ import annotations
|
|
|
3
3
|
from collections.abc import Callable
|
|
4
4
|
from dataclasses import dataclass
|
|
5
5
|
from datetime import date, datetime
|
|
6
|
-
from typing import Any, TypeVar
|
|
6
|
+
from typing import Any, Protocol, TypeVar, overload
|
|
7
7
|
|
|
8
8
|
T = TypeVar("T")
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
class HasValues(Protocol):
|
|
12
|
+
_values: dict[str, Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
Owner = TypeVar("Owner", bound=HasValues)
|
|
16
|
+
|
|
17
|
+
|
|
11
18
|
@dataclass(frozen=True)
|
|
12
19
|
class ColumnSpec[T]:
|
|
13
20
|
header: str | None = None # header string in Excel
|
|
@@ -22,22 +29,31 @@ class ColumnSpec[T]:
|
|
|
22
29
|
class Column[T]:
|
|
23
30
|
def __init__(self, spec: ColumnSpec[T]):
|
|
24
31
|
self.spec = spec
|
|
32
|
+
self.name: str | None = None
|
|
25
33
|
|
|
26
|
-
def __set_name__(self, owner, name: str):
|
|
34
|
+
def __set_name__(self, owner: type[Any], name: str) -> None:
|
|
27
35
|
self.name = name
|
|
28
|
-
# register in definition order
|
|
29
36
|
reg = owner.__dict__.get("__columns__")
|
|
30
37
|
if reg is None:
|
|
31
38
|
owner.__columns__ = []
|
|
32
39
|
owner.__columns__.append(self)
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
@overload
|
|
42
|
+
def __get__(self, obj: None, objtype: type[Owner] | None = None) -> Column[T]: ...
|
|
43
|
+
@overload
|
|
44
|
+
def __get__(self, obj: Owner, objtype: type[Owner] | None = None) -> T | None: ...
|
|
45
|
+
|
|
46
|
+
def __get__(self, obj: Owner | None, objtype: type[Owner] | None = None) -> T | Column | None:
|
|
35
47
|
if obj is None:
|
|
36
48
|
return self
|
|
37
|
-
|
|
49
|
+
if self.name is None:
|
|
50
|
+
raise RuntimeError("Column __set_name__ did not run.")
|
|
51
|
+
return obj._values.get(self.name)
|
|
38
52
|
|
|
39
|
-
def __set__(self, obj, value: T | None):
|
|
53
|
+
def __set__(self, obj: Owner, value: T | None) -> None:
|
|
40
54
|
self.validate(value)
|
|
55
|
+
if self.name is None:
|
|
56
|
+
raise RuntimeError("Column __set_name__ did not run.")
|
|
41
57
|
obj._values[self.name] = value
|
|
42
58
|
|
|
43
59
|
def parse_cell(self, raw: Any) -> T | None:
|
|
@@ -23,6 +23,8 @@ def _pluralize(s: str) -> str:
|
|
|
23
23
|
# keep deliberately simple; can be swapped for inflect later
|
|
24
24
|
if s.endswith("s"):
|
|
25
25
|
return s
|
|
26
|
+
if s.endswith("y") and len(s) >= 2 and s[-2] not in "aeiou":
|
|
27
|
+
return s[:-1] + "ies"
|
|
26
28
|
return s + "s"
|
|
27
29
|
|
|
28
30
|
|
|
@@ -84,7 +86,7 @@ class PivotSheetSpec:
|
|
|
84
86
|
|
|
85
87
|
# field names on the model
|
|
86
88
|
pivot_field: str
|
|
87
|
-
|
|
89
|
+
row_fields: list[str]
|
|
88
90
|
value_field: str
|
|
89
91
|
|
|
90
92
|
# layout
|
|
@@ -92,7 +94,6 @@ class PivotSheetSpec:
|
|
|
92
94
|
header_row: int = 2 # pivot headers
|
|
93
95
|
row_header_col: int = 1
|
|
94
96
|
data_start_row: int = 3
|
|
95
|
-
data_start_col: int = 2
|
|
96
97
|
|
|
97
98
|
# template: define the pivot column values (dates) to render across the top
|
|
98
99
|
pivot_values: list[Any] | None = None # e.g., list[date]; required for generation
|
|
@@ -102,6 +103,10 @@ class PivotSheetSpec:
|
|
|
102
103
|
|
|
103
104
|
include_blanks: bool = False # whether to load blank cells as data points
|
|
104
105
|
|
|
106
|
+
@property
|
|
107
|
+
def data_start_col(self) -> int:
|
|
108
|
+
return len(self.row_fields) + 1
|
|
109
|
+
|
|
105
110
|
|
|
106
111
|
AnySheetSpec = PivotSheetSpec | SheetSpec
|
|
107
112
|
|
|
@@ -274,39 +279,54 @@ class ExcelFile:
|
|
|
274
279
|
tcell.font = title_font
|
|
275
280
|
tcell.alignment = title_alignment
|
|
276
281
|
|
|
277
|
-
#
|
|
278
|
-
|
|
279
|
-
|
|
282
|
+
# Row field headers (left block)
|
|
283
|
+
for i, rf in enumerate(spec.row_fields):
|
|
284
|
+
c = spec.row_header_col + i
|
|
285
|
+
cell = ws.cell(spec.header_row, c, rf.title())
|
|
286
|
+
cell.font = header_font
|
|
287
|
+
col_letter = cell.column_letter
|
|
288
|
+
ws.column_dimensions[col_letter].width = max(12, min(40, len(str(rf)) + 4))
|
|
280
289
|
|
|
281
290
|
# Pivot headers across the top
|
|
282
291
|
for j, pv in enumerate(spec.pivot_values):
|
|
283
292
|
c = spec.data_start_col + j
|
|
284
293
|
cell = ws.cell(spec.header_row, c, pv)
|
|
285
294
|
cell.font = header_font
|
|
286
|
-
|
|
287
|
-
col_letter = ws.cell(spec.header_row, c).column_letter
|
|
295
|
+
col_letter = cell.column_letter
|
|
288
296
|
ws.column_dimensions[col_letter].width = 14
|
|
289
297
|
|
|
290
|
-
# Seed row keys (optional)
|
|
291
298
|
if spec.row_values:
|
|
292
299
|
for i, rv in enumerate(spec.row_values):
|
|
293
300
|
r = spec.data_start_row + i
|
|
294
|
-
|
|
301
|
+
|
|
302
|
+
if len(spec.row_fields) == 1:
|
|
303
|
+
# Back-compat: rv is a single value
|
|
304
|
+
ws.cell(r, spec.row_header_col, rv)
|
|
305
|
+
else:
|
|
306
|
+
# Expect rv to be a tuple/list matching row_fields
|
|
307
|
+
if not isinstance(rv, (tuple, list)) or len(rv) != len(spec.row_fields):
|
|
308
|
+
raise ValueError(
|
|
309
|
+
"For multi-row_fields, PivotSheetSpec.row_values must be a list of "
|
|
310
|
+
f"tuples/lists with length {len(spec.row_fields)}."
|
|
311
|
+
)
|
|
312
|
+
for k, part in enumerate(rv):
|
|
313
|
+
ws.cell(r, spec.row_header_col + k, part)
|
|
295
314
|
|
|
296
315
|
def _parse_pivot_sheet(self, ws: Worksheet, spec: PivotSheetSpec) -> None:
|
|
297
316
|
model = spec.model
|
|
298
317
|
cols = {c.name: c for c in _get_model_columns(model)} # Column descriptors by field name
|
|
299
318
|
|
|
300
319
|
# Validate fields exist
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
)
|
|
320
|
+
required = [spec.pivot_field, spec.value_field, *spec.row_fields]
|
|
321
|
+
missing = [f for f in required if f not in cols]
|
|
322
|
+
if missing:
|
|
323
|
+
raise ValueError(
|
|
324
|
+
f"{model.__name__} is missing Column field(s) required by PivotSheetSpec: {missing}"
|
|
325
|
+
)
|
|
306
326
|
|
|
307
327
|
pivot_col = cols[spec.pivot_field]
|
|
308
|
-
row_col = cols[spec.row_field]
|
|
309
328
|
val_col = cols[spec.value_field]
|
|
329
|
+
row_cols = [cols[f] for f in spec.row_fields]
|
|
310
330
|
|
|
311
331
|
# Determine pivot headers from sheet (or trust spec.pivot_values)
|
|
312
332
|
pivot_headers: list[Any] = []
|
|
@@ -326,11 +346,13 @@ class ExcelFile:
|
|
|
326
346
|
|
|
327
347
|
r = spec.data_start_row
|
|
328
348
|
while r <= ws.max_row:
|
|
329
|
-
|
|
330
|
-
|
|
349
|
+
raw_parts = [
|
|
350
|
+
ws.cell(r, spec.row_header_col + i).value for i in range(len(spec.row_fields))
|
|
351
|
+
]
|
|
352
|
+
if _row_is_blank(raw_parts):
|
|
331
353
|
break
|
|
332
354
|
|
|
333
|
-
|
|
355
|
+
row_parts = [row_cols[i].parse_cell(raw_parts[i]) for i in range(len(row_cols))]
|
|
334
356
|
|
|
335
357
|
for j, pivot_value in enumerate(pivot_headers):
|
|
336
358
|
c = spec.data_start_col + j
|
|
@@ -340,7 +362,8 @@ class ExcelFile:
|
|
|
340
362
|
|
|
341
363
|
obj = _instantiate_model(model)
|
|
342
364
|
|
|
343
|
-
|
|
365
|
+
for fname, parsed in zip(spec.row_fields, row_parts, strict=False):
|
|
366
|
+
setattr(obj, fname, parsed)
|
|
344
367
|
setattr(obj, spec.pivot_field, pivot_value)
|
|
345
368
|
setattr(obj, spec.value_field, val_col.parse_cell(raw_val))
|
|
346
369
|
|
|
@@ -51,6 +51,17 @@ def demand_model():
|
|
|
51
51
|
return Demand
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
@pytest.fixture
|
|
55
|
+
def demand_two_pivot():
|
|
56
|
+
class Demand:
|
|
57
|
+
dt: Column[date] = date_column(header="Date")
|
|
58
|
+
region: Column[str] = text_column(header="Region", not_null=True)
|
|
59
|
+
product: Column[str] = text_column(header="Product", not_null=True)
|
|
60
|
+
value: Column[int] = int_column(header="Value", not_null=True)
|
|
61
|
+
|
|
62
|
+
return Demand
|
|
63
|
+
|
|
64
|
+
|
|
54
65
|
@pytest.fixture
|
|
55
66
|
def pivot_excel_file(demand_model):
|
|
56
67
|
Demand = demand_model
|
|
@@ -66,7 +77,7 @@ def pivot_excel_file(demand_model):
|
|
|
66
77
|
name="Demand",
|
|
67
78
|
model=Demand,
|
|
68
79
|
pivot_field="dt",
|
|
69
|
-
|
|
80
|
+
row_fields=["region"],
|
|
70
81
|
value_field="value",
|
|
71
82
|
pivot_values=pivot_values,
|
|
72
83
|
row_values=["NA", "EU"], # seed some regions
|
|
@@ -5,7 +5,8 @@ from datetime import date, datetime
|
|
|
5
5
|
import pytest
|
|
6
6
|
from openpyxl import load_workbook
|
|
7
7
|
|
|
8
|
-
from excel_orm import ExcelFile, PivotSheetSpec
|
|
8
|
+
from excel_orm import Column, ExcelFile, PivotSheetSpec, text_column
|
|
9
|
+
from excel_orm.orm import SheetSpec
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def test_generate_template_creates_sheet_and_titles_and_headers(tmp_path, excel_file):
|
|
@@ -183,7 +184,7 @@ def test_load_data_pivot_stops_at_blank_region_row(tmp_path, demand_model):
|
|
|
183
184
|
name="Demand",
|
|
184
185
|
model=demand_model,
|
|
185
186
|
pivot_field="dt",
|
|
186
|
-
|
|
187
|
+
row_fields=["region"],
|
|
187
188
|
value_field="value",
|
|
188
189
|
pivot_values=pivot_values,
|
|
189
190
|
row_values=None, # user-entered regions
|
|
@@ -230,3 +231,111 @@ def test_load_data_missing_pivot_sheet_raises(tmp_path, pivot_excel_file):
|
|
|
230
231
|
|
|
231
232
|
with pytest.raises(ValueError):
|
|
232
233
|
pivot_excel_file.load_data(str(p))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_pivot_two_row_fields_template_and_parse_end_to_end(tmp_path, demand_two_pivot):
|
|
237
|
+
"""
|
|
238
|
+
Two row_fields case:
|
|
239
|
+
row_fields: [region, product]
|
|
240
|
+
pivot cols: Jun, Jul
|
|
241
|
+
row_values seeded with composite keys (NA/ABC, EU/ABC)
|
|
242
|
+
Validate:
|
|
243
|
+
- template layout places row headers in A2,B2
|
|
244
|
+
- pivot headers start at C2,D2
|
|
245
|
+
- seeded keys appear in A3,B3 and A4,B4
|
|
246
|
+
- parsing emits correct objects with BOTH row fields populated
|
|
247
|
+
"""
|
|
248
|
+
pivot_values = [date(2025, 6, 1), date(2025, 7, 1)]
|
|
249
|
+
spec = PivotSheetSpec(
|
|
250
|
+
name="Demand",
|
|
251
|
+
model=demand_two_pivot,
|
|
252
|
+
pivot_field="dt",
|
|
253
|
+
row_fields=["region", "product"],
|
|
254
|
+
value_field="value",
|
|
255
|
+
pivot_values=pivot_values,
|
|
256
|
+
row_values=[("NA", "ABC"), ("EU", "ABC")],
|
|
257
|
+
row_header_col=1,
|
|
258
|
+
include_blanks=False,
|
|
259
|
+
)
|
|
260
|
+
xf = ExcelFile(sheets=[spec])
|
|
261
|
+
|
|
262
|
+
out = tmp_path / "pivot_two_row_fields.xlsx"
|
|
263
|
+
xf.generate_template(str(out))
|
|
264
|
+
|
|
265
|
+
wb = load_workbook(out)
|
|
266
|
+
ws = wb["Demand"]
|
|
267
|
+
|
|
268
|
+
# Title merged across A..D (A,B row headers + C,D pivot headers)
|
|
269
|
+
assert ws["A1"].value == "Demands"
|
|
270
|
+
merged = [str(rng) for rng in ws.merged_cells.ranges]
|
|
271
|
+
assert "A1:D1" in merged
|
|
272
|
+
|
|
273
|
+
# Row field headers in A2,B2
|
|
274
|
+
assert ws["A2"].value == "Region"
|
|
275
|
+
assert ws["B2"].value == "Product"
|
|
276
|
+
|
|
277
|
+
# Pivot headers start at C2..D2
|
|
278
|
+
assert _as_date(ws["C2"].value) == date(2025, 6, 1)
|
|
279
|
+
assert _as_date(ws["D2"].value) == date(2025, 7, 1)
|
|
280
|
+
|
|
281
|
+
# Seeded composite keys appear across A/B
|
|
282
|
+
assert ws["A3"].value == "NA"
|
|
283
|
+
assert ws["B3"].value == "ABC"
|
|
284
|
+
assert ws["A4"].value == "EU"
|
|
285
|
+
assert ws["B4"].value == "ABC"
|
|
286
|
+
|
|
287
|
+
# Fill matrix values: (row 3: NA/ABC), (row 4: EU/ABC)
|
|
288
|
+
ws["C3"].value = 10 # NA/ABC, Jun
|
|
289
|
+
ws["D3"].value = 20 # NA/ABC, Jul
|
|
290
|
+
ws["C4"].value = 5 # EU/ABC, Jun
|
|
291
|
+
ws["D4"].value = 0 # EU/ABC, Jul
|
|
292
|
+
|
|
293
|
+
wb.save(out)
|
|
294
|
+
|
|
295
|
+
xf.load_data(str(out))
|
|
296
|
+
rows = xf.demands.all() # repo name plural of Demand
|
|
297
|
+
|
|
298
|
+
# Expect 4 objects (2 rows x 2 pivots)
|
|
299
|
+
assert len(rows) == 4
|
|
300
|
+
|
|
301
|
+
got = {(r.region, r.product, r.dt, r.value) for r in rows}
|
|
302
|
+
expected = {
|
|
303
|
+
("NA", "ABC", date(2025, 6, 1), 10),
|
|
304
|
+
("NA", "ABC", date(2025, 7, 1), 20),
|
|
305
|
+
("EU", "ABC", date(2025, 6, 1), 5),
|
|
306
|
+
("EU", "ABC", date(2025, 7, 1), 0),
|
|
307
|
+
}
|
|
308
|
+
assert got == expected
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_duplicate_header_names_do_not_clash(tmp_path, models):
|
|
312
|
+
Car, _ = models
|
|
313
|
+
|
|
314
|
+
class ManufacturingPlant:
|
|
315
|
+
name: Column[str] = text_column(header="Make", not_null=True)
|
|
316
|
+
location: Column[str] = text_column(header="location")
|
|
317
|
+
|
|
318
|
+
sheet = SheetSpec(name="Cars", models=[Car, ManufacturingPlant])
|
|
319
|
+
file = ExcelFile(sheets=[sheet])
|
|
320
|
+
out = tmp_path / "duplicate_headers.xlsx"
|
|
321
|
+
file.generate_template(out)
|
|
322
|
+
|
|
323
|
+
wb = load_workbook(out)
|
|
324
|
+
ws = wb["Cars"]
|
|
325
|
+
|
|
326
|
+
ws["A3"].value = "Acura"
|
|
327
|
+
ws["B3"].value = "TSX"
|
|
328
|
+
ws["C3"].value = 2005
|
|
329
|
+
|
|
330
|
+
ws["F3"].value = "Duplicate"
|
|
331
|
+
ws["G3"].value = "New Jersey"
|
|
332
|
+
|
|
333
|
+
wb.save(out)
|
|
334
|
+
|
|
335
|
+
file.load_data(out)
|
|
336
|
+
|
|
337
|
+
cars = file.cars.all()
|
|
338
|
+
plants = file.manufacturing_plants.all()
|
|
339
|
+
|
|
340
|
+
assert plants[0].name == "Duplicate"
|
|
341
|
+
assert plants[0].location == "New Jersey"
|
excel_orm-0.1.1/README.md
DELETED
|
@@ -1,253 +0,0 @@
|
|
|
1
|
-
# Excel ORM
|
|
2
|
-
|
|
3
|
-
A lightweight, typed “Excel ORM” for generating Excel templates and parsing Excel workbooks into Python objects using column descriptors.
|
|
4
|
-
|
|
5
|
-
This project is designed for the common enterprise pattern where you:
|
|
6
|
-
1) generate a structured `.xlsx` template for users,
|
|
7
|
-
2) let users fill it in,
|
|
8
|
-
3) load the workbook back into Python, producing typed objects grouped by model.
|
|
9
|
-
|
|
10
|
-
It uses `openpyxl` for reading/writing Excel files and supports multiple model “tables” on the same worksheet.
|
|
11
|
-
|
|
12
|
-
---
|
|
13
|
-
|
|
14
|
-
## Features
|
|
15
|
-
|
|
16
|
-
- **Typed column descriptors** (`text_column`, `int_column`, `bool_column`, `date_column`)
|
|
17
|
-
- **Template generation** with:
|
|
18
|
-
- merged **table title cells** (pluralized model name)
|
|
19
|
-
- bold headers
|
|
20
|
-
- sensible column widths
|
|
21
|
-
- multiple tables laid out horizontally on the same sheet with a configurable gap
|
|
22
|
-
- **Workbook parsing** into model-specific repositories:
|
|
23
|
-
- `excel_file.cars.all()` → `list[Car]`
|
|
24
|
-
- `excel_file.manufacturing_plants.all()` → `list[ManufacturingPlant]`
|
|
25
|
-
- **Validation hooks**
|
|
26
|
-
- column-level `not_null`
|
|
27
|
-
- optional row exclusion rules via `excludes`
|
|
28
|
-
- optional model-level `validate()` method
|
|
29
|
-
|
|
30
|
-
---
|
|
31
|
-
|
|
32
|
-
## Installation
|
|
33
|
-
|
|
34
|
-
### From PyPI (once published)
|
|
35
|
-
```bash
|
|
36
|
-
pip install excel-orm
|
|
37
|
-
````
|
|
38
|
-
|
|
39
|
-
### From source (uv)
|
|
40
|
-
|
|
41
|
-
```bash
|
|
42
|
-
git clone <your-repo-url>
|
|
43
|
-
cd excel-orm
|
|
44
|
-
uv sync
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
---
|
|
48
|
-
|
|
49
|
-
## Quick Start
|
|
50
|
-
|
|
51
|
-
### 1) Define models using `Column[...]` descriptors
|
|
52
|
-
|
|
53
|
-
```python
|
|
54
|
-
from excel_orm.column import Column, text_column, int_column
|
|
55
|
-
from excel_orm.orm import ExcelFile, SheetSpec
|
|
56
|
-
|
|
57
|
-
class Car:
|
|
58
|
-
make: Column[str] = text_column(header="Make", not_null=True)
|
|
59
|
-
model: Column[str] = text_column(header="Model", not_null=True)
|
|
60
|
-
year: Column[int] = int_column(header="Year", not_null=True)
|
|
61
|
-
|
|
62
|
-
class ManufacturingPlant:
|
|
63
|
-
name: Column[str] = text_column(header="Factory Name", not_null=True)
|
|
64
|
-
location: Column[str] = text_column(header="Location")
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
### 2) Declare a sheet containing multiple models
|
|
68
|
-
|
|
69
|
-
Each model becomes its own table on the same worksheet.
|
|
70
|
-
|
|
71
|
-
```python
|
|
72
|
-
sheet = SheetSpec(
|
|
73
|
-
name="Cars",
|
|
74
|
-
models=[Car, ManufacturingPlant],
|
|
75
|
-
|
|
76
|
-
# Layout rows
|
|
77
|
-
title_row=1,
|
|
78
|
-
header_row=2,
|
|
79
|
-
data_start_row=3,
|
|
80
|
-
|
|
81
|
-
# Horizontal spacing between model tables
|
|
82
|
-
template_table_gap=2,
|
|
83
|
-
)
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
### 3) Create an `ExcelFile`, generate a template, then load data
|
|
87
|
-
|
|
88
|
-
```python
|
|
89
|
-
excel_file = ExcelFile(sheets=[sheet])
|
|
90
|
-
|
|
91
|
-
# Generate a blank template workbook
|
|
92
|
-
excel_file.generate_template("car_inventory_template.xlsx")
|
|
93
|
-
|
|
94
|
-
# Users fill in data in Excel...
|
|
95
|
-
|
|
96
|
-
# Load the filled workbook into repositories
|
|
97
|
-
excel_file.load_data("car_inventory_data.xlsx")
|
|
98
|
-
|
|
99
|
-
cars = excel_file.cars.all()
|
|
100
|
-
plants = excel_file.manufacturing_plants.all()
|
|
101
|
-
|
|
102
|
-
print(cars[0].make, cars[0].year)
|
|
103
|
-
print(plants[0].name, plants[0].location)
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
---
|
|
107
|
-
|
|
108
|
-
## How It Works
|
|
109
|
-
|
|
110
|
-
### Repositories
|
|
111
|
-
|
|
112
|
-
For each model you register, `ExcelFile` creates a repository attribute on the instance using a snake_case pluralized name:
|
|
113
|
-
|
|
114
|
-
* `Car` → `excel_file.cars`
|
|
115
|
-
* `ManufacturingPlant` → `excel_file.manufacturing_plants`
|
|
116
|
-
|
|
117
|
-
Repositories are simple list-like containers with an `all()` helper:
|
|
118
|
-
|
|
119
|
-
```python
|
|
120
|
-
cars = excel_file.cars.all() # list[Car]
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
### Multi-table Sheets
|
|
124
|
-
|
|
125
|
-
A single worksheet can host multiple model tables. During template generation:
|
|
126
|
-
|
|
127
|
-
* A merged title cell is written above each table (pluralized class name in title case).
|
|
128
|
-
* Headers appear under the title.
|
|
129
|
-
* Data rows begin at `data_start_row`.
|
|
130
|
-
* Tables are placed horizontally with `template_table_gap` blank columns between them.
|
|
131
|
-
|
|
132
|
-
During parsing:
|
|
133
|
-
|
|
134
|
-
* The library locates each model table by matching the expected header sequence.
|
|
135
|
-
* It reads contiguous rows until a blank row is encountered.
|
|
136
|
-
|
|
137
|
-
---
|
|
138
|
-
|
|
139
|
-
## Column Types
|
|
140
|
-
|
|
141
|
-
### Text
|
|
142
|
-
|
|
143
|
-
```python
|
|
144
|
-
from excel_orm.column import Column, text_column
|
|
145
|
-
|
|
146
|
-
class Example:
|
|
147
|
-
name: Column[str] = text_column(header="Name", not_null=True, strip=True)
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
* `None` parses to `""` (empty string).
|
|
151
|
-
* `strip=True` trims whitespace.
|
|
152
|
-
|
|
153
|
-
### Integer
|
|
154
|
-
|
|
155
|
-
```python
|
|
156
|
-
from excel_orm.column import Column, int_column
|
|
157
|
-
|
|
158
|
-
class Example:
|
|
159
|
-
qty: Column[int] = int_column(header="Qty", not_null=True)
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
* `None` or `""` parses to `0`.
|
|
163
|
-
|
|
164
|
-
### Boolean
|
|
165
|
-
|
|
166
|
-
```python
|
|
167
|
-
from excel_orm.column import Column, bool_column
|
|
168
|
-
|
|
169
|
-
class Example:
|
|
170
|
-
active: Column[bool] = bool_column(header="Active")
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
Accepted values include:
|
|
174
|
-
|
|
175
|
-
* True: `true, t, yes, y, 1` (case-insensitive)
|
|
176
|
-
* False: `false, f, no, n, 0`
|
|
177
|
-
* `None` / empty parses to `False`
|
|
178
|
-
|
|
179
|
-
Invalid values raise `ValueError`.
|
|
180
|
-
|
|
181
|
-
### Date
|
|
182
|
-
|
|
183
|
-
```python
|
|
184
|
-
from excel_orm.column import Column, date_column
|
|
185
|
-
|
|
186
|
-
class Example:
|
|
187
|
-
start_date: Column[date] = date_column(header="Start Date")
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
The date parser supports:
|
|
191
|
-
|
|
192
|
-
* Excel-native `datetime`/`date` values from `openpyxl`
|
|
193
|
-
* ISO strings like `2025-06-01` and `2025-06-01T13:45:00`
|
|
194
|
-
* Common business formats including `01-JUN-2025`
|
|
195
|
-
|
|
196
|
-
Invalid/empty values raise `ValueError`.
|
|
197
|
-
|
|
198
|
-
---
|
|
199
|
-
|
|
200
|
-
## Validation
|
|
201
|
-
|
|
202
|
-
### Column-level: `not_null`
|
|
203
|
-
|
|
204
|
-
```python
|
|
205
|
-
class Car:
|
|
206
|
-
make: Column[str] = text_column(header="Make", not_null=True)
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
If a `not_null=True` column parses to `None` or `""`, a `ValueError` is raised.
|
|
210
|
-
|
|
211
|
-
### Row exclusion: `excludes`
|
|
212
|
-
|
|
213
|
-
If you set `excludes`, rows matching those raw values in that column will be skipped.
|
|
214
|
-
|
|
215
|
-
```python
|
|
216
|
-
status: Column[str] = text_column(header="Status")
|
|
217
|
-
status.spec.excludes = {"IGNORE", "SKIP"} # example pattern
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
(If you want a nicer API for excludes, consider adding it directly to the column factory signature.)
|
|
221
|
-
|
|
222
|
-
### Model-level: `validate()`
|
|
223
|
-
|
|
224
|
-
If your model defines a `validate(self)` method, it is called after a row is parsed.
|
|
225
|
-
|
|
226
|
-
```python
|
|
227
|
-
class Car:
|
|
228
|
-
make: Column[str] = text_column(header="Make", not_null=True)
|
|
229
|
-
year: Column[int] = int_column(header="Year", not_null=True)
|
|
230
|
-
|
|
231
|
-
def validate(self) -> None:
|
|
232
|
-
if self.year < 1886:
|
|
233
|
-
raise ValueError("Invalid car year")
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
---
|
|
237
|
-
|
|
238
|
-
## Development
|
|
239
|
-
|
|
240
|
-
### Run tests
|
|
241
|
-
|
|
242
|
-
```bash
|
|
243
|
-
uv run pytest
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
### Lint/format (example)
|
|
247
|
-
|
|
248
|
-
If you use Ruff:
|
|
249
|
-
|
|
250
|
-
```bash
|
|
251
|
-
uv run ruff check .
|
|
252
|
-
uv run ruff format .
|
|
253
|
-
```
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|