excel-orm 0.1.6__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: excel-orm
3
- Version: 0.1.6
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 tables laid out horizontally on a worksheet, and
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 tables laid out horizontally on a worksheet, and
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:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "excel-orm"
3
- version = "0.1.6"
3
+ version = "0.2.0"
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.12"
@@ -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
  ]
@@ -19,3 +19,8 @@ class RowBase:
19
19
 
20
20
  for k, v in kwargs.items():
21
21
  setattr(self, k, v)
22
+
23
+ self.validate()
24
+
25
+ def validate(self) -> None:
26
+ pass
@@ -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(spec.pivot_values) - 1
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(spec.pivot_values):
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 spec.row_values:
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