excel-orm 0.1.2__tar.gz → 0.1.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: excel-orm
3
- Version: 0.1.2
3
+ Version: 0.1.4
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
- It uses `openpyxl` for reading/writing Excel files and supports multiple model “tables” on the same worksheet.
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
- - **Typed column descriptors** (`text_column`, `int_column`, `bool_column`, `date_column`)
37
- - **Template generation** with:
38
- - merged **table title cells** (pluralized model name)
39
- - bold headers
40
- - sensible column widths
41
- - multiple tables laid out horizontally on the same sheet with a configurable gap
42
- - **Workbook parsing** into model-specific repositories:
43
- - `excel_file.cars.all()` `list[Car]`
44
- - `excel_file.manufacturing_plants.all()` → `list[ManufacturingPlant]`
45
- - **Validation hooks**
46
- - column-level `not_null`
47
- - optional row exclusion rules via `excludes`
48
- - optional model-level `validate()` method
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 (once published)
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.orm import ExcelFile, SheetSpec
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 simple list-like containers with an `all()` helper:
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
- * A merged title cell is written above each table (pluralized class name in title case).
148
- * Headers appear under the title.
149
- * Data rows begin at `data_start_row`.
150
- * Tables are placed horizontally with `template_table_gap` blank columns between them.
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
- * The library locates each model table by matching the expected header sequence.
155
- * It reads contiguous rows until a blank row is encountered.
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
- * Common business formats including `01-JUN-2025`
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"} # example pattern
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 (example)
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
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "excel-orm"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "A lightweight Excel ORM for generating templates and parsing typed row models."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -1,4 +1,12 @@
1
- from .column import Column, ColumnSpec, bool_column, date_column, int_column, text_column
1
+ from .base import RowBase
2
+ from .column import (
3
+ Column,
4
+ ColumnSpec,
5
+ bool_column,
6
+ date_column,
7
+ int_column,
8
+ text_column,
9
+ )
2
10
  from .orm import ExcelFile, PivotSheetSpec, SheetSpec
3
11
 
4
12
  __all__ = [
@@ -6,6 +14,7 @@ __all__ = [
6
14
  "ColumnSpec",
7
15
  "ExcelFile",
8
16
  "PivotSheetSpec",
17
+ "RowBase",
9
18
  "SheetSpec",
10
19
  "bool_column",
11
20
  "date_column",
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from excel_orm import Column
6
+
7
+
8
+ class RowBase:
9
+ __columns__: list[Column[Any]]
10
+
11
+ def __init__(self, **kwargs: Any) -> None:
12
+ self._values: dict[str, Any] = {}
13
+
14
+ for col in getattr(self, "__columns__", []):
15
+ if col.name is None:
16
+ continue
17
+ self._values[col.name] = col.spec.default
18
+
19
+ for k, v in kwargs.items():
20
+ setattr(self, k, v)
@@ -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
- def __get__(self, obj, objtype=None) -> T | Column | None:
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
- return obj._values.get(self.name) # centralized storage
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:
@@ -2,6 +2,7 @@ from datetime import date
2
2
 
3
3
  import pytest
4
4
 
5
+ from excel_orm.base import RowBase
5
6
  from src.excel_orm import (
6
7
  Column,
7
8
  ExcelFile,
@@ -15,12 +16,12 @@ from src.excel_orm import (
15
16
 
16
17
  @pytest.fixture
17
18
  def models():
18
- class Car:
19
+ class Car(RowBase):
19
20
  make: Column[str] = text_column(header="Make", not_null=True)
20
21
  model: Column[str] = text_column(header="Model", not_null=True)
21
22
  year: Column[int] = int_column(header="Year", not_null=True)
22
23
 
23
- class ManufacturingPlant:
24
+ class ManufacturingPlant(RowBase):
24
25
  name: Column[str] = text_column(header="Factory Name", not_null=True)
25
26
  location: Column[str] = text_column(header="Location")
26
27
 
@@ -53,7 +54,7 @@ def demand_model():
53
54
 
54
55
  @pytest.fixture
55
56
  def demand_two_pivot():
56
- class Demand:
57
+ class Demand(RowBase):
57
58
  dt: Column[date] = date_column(header="Date")
58
59
  region: Column[str] = text_column(header="Region", not_null=True)
59
60
  product: Column[str] = text_column(header="Product", not_null=True)
@@ -4,11 +4,18 @@ from datetime import date, datetime
4
4
 
5
5
  import pytest
6
6
 
7
- from src.excel_orm import Column, bool_column, date_column, int_column, text_column
7
+ from src.excel_orm import (
8
+ Column,
9
+ RowBase,
10
+ bool_column,
11
+ date_column,
12
+ int_column,
13
+ text_column,
14
+ )
8
15
 
9
16
 
10
17
  def test_descriptor_stores_in_values_dict():
11
- class Foo:
18
+ class Foo(RowBase):
12
19
  a: Column[str] = text_column(header="A")
13
20
 
14
21
  f = Foo()
@@ -20,14 +27,14 @@ def test_descriptor_stores_in_values_dict():
20
27
 
21
28
 
22
29
  def test_descriptor_class_level_access_returns_column():
23
- class Foo:
30
+ class Foo(RowBase):
24
31
  a: Column[str] = text_column(header="A")
25
32
 
26
33
  assert isinstance(Foo.a, Column)
27
34
 
28
35
 
29
36
  def test_not_null_validation_raises_on_none_or_empty():
30
- class Foo:
37
+ class Foo(RowBase):
31
38
  a: Column[str] = text_column(header="A", not_null=True)
32
39
 
33
40
  f = Foo()
@@ -108,3 +115,4 @@ def test_date_column_invalid_raises():
108
115
  col = date_column(header="D")
109
116
  with pytest.raises(ValueError):
110
117
  col.parse_cell("not-a-date")
118
+ col.parse_cell(" ")
@@ -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, RowBase, 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):
@@ -305,3 +306,36 @@ def test_pivot_two_row_fields_template_and_parse_end_to_end(tmp_path, demand_two
305
306
  ("EU", "ABC", date(2025, 7, 1), 0),
306
307
  }
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(RowBase):
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"
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from excel_orm import RowBase
3
4
  from src.excel_orm import Column, int_column, text_column
4
5
  from src.excel_orm.orm import (
5
6
  _camel_to_snake,
@@ -31,7 +32,7 @@ def test_repo_and_display_name():
31
32
 
32
33
 
33
34
  def test_get_model_columns_respects_annotation_order():
34
- class A:
35
+ class A(RowBase):
35
36
  x: Column[str] = text_column(header="X")
36
37
  y: Column[int] = int_column(header="Y")
37
38
 
@@ -40,7 +41,7 @@ def test_get_model_columns_respects_annotation_order():
40
41
 
41
42
 
42
43
  def test_instantiate_model_sets_defaults_and_requires_set_name():
43
- class A:
44
+ class A(RowBase):
44
45
  x: Column[str] = text_column(header="X", default="dflt")
45
46
 
46
47
  a = _instantiate_model(A)
@@ -187,7 +187,7 @@ wheels = [
187
187
 
188
188
  [[package]]
189
189
  name = "excel-orm"
190
- version = "0.1.2"
190
+ version = "0.1.4"
191
191
  source = { editable = "." }
192
192
  dependencies = [
193
193
  { name = "openpyxl" },
excel_orm-0.1.2/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