excel-orm 0.1.6__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,23 @@
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
+ )
10
+ from .orm import ExcelFile, PivotSheetSpec, SheetSpec
11
+
12
+ __all__ = [
13
+ "Column",
14
+ "ColumnSpec",
15
+ "ExcelFile",
16
+ "PivotSheetSpec",
17
+ "RowBase",
18
+ "SheetSpec",
19
+ "bool_column",
20
+ "date_column",
21
+ "int_column",
22
+ "text_column",
23
+ ]
excel_orm/base.py ADDED
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ if TYPE_CHECKING:
6
+ from excel_orm import Column
7
+
8
+
9
+ class RowBase:
10
+ __columns__: list[Column[Any]]
11
+
12
+ def __init__(self, **kwargs: Any) -> None:
13
+ self._values: dict[str, Any] = {}
14
+
15
+ for col in getattr(self, "__columns__", []):
16
+ if col.name is None:
17
+ continue
18
+ self._values[col.name] = col.spec.default
19
+
20
+ for k, v in kwargs.items():
21
+ setattr(self, k, v)
excel_orm/column.py ADDED
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from datetime import date, datetime
6
+ from typing import Any, Protocol, TypeVar, overload
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ class HasValues(Protocol):
12
+ _values: dict[str, Any]
13
+
14
+
15
+ Owner = TypeVar("Owner", bound=HasValues)
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ColumnSpec[T]:
20
+ header: str | None = None # header string in Excel
21
+ default: T | None = None
22
+ not_null: bool = False # parsed value cannot be None/empty
23
+ excludes: set[Any] | None = None # raw values that mark row as excluded
24
+ parser: Callable[[Any], T] = lambda x: x # raw -> parsed
25
+ renderer: Callable[[T | None], Any] = lambda x: x # parsed -> raw
26
+ validator: Callable[[T | None], None] = lambda _: None
27
+
28
+
29
+ class Column[T]:
30
+ def __init__(self, spec: ColumnSpec[T]):
31
+ self.spec = spec
32
+ self.name: str | None = None
33
+
34
+ def __set_name__(self, owner: type[Any], name: str) -> None:
35
+ self.name = name
36
+ reg = owner.__dict__.get("__columns__")
37
+ if reg is None:
38
+ owner.__columns__ = []
39
+ owner.__columns__.append(self)
40
+
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: ...
45
+
46
+ def __get__(self, obj: Owner | None, objtype: type[Owner] | None = None) -> T | Column:
47
+ if obj is None:
48
+ return self
49
+ if self.name is None:
50
+ raise RuntimeError("Column __set_name__ did not run.")
51
+ return obj._values.get(self.name)
52
+
53
+ def __set__(self, obj: Owner, value: T | None) -> None:
54
+ self.validate(value)
55
+ if self.name is None:
56
+ raise RuntimeError("Column __set_name__ did not run.")
57
+ obj._values[self.name] = value
58
+
59
+ def parse_cell(self, raw: Any) -> T | None:
60
+ return self.spec.parser(raw)
61
+
62
+ def validate(self, value: T | None) -> None:
63
+ if self.spec.not_null and (value is None or value == ""):
64
+ raise ValueError(f"{self.name} cannot be null/empty")
65
+ self.spec.validator(value)
66
+
67
+
68
+ def text_column(
69
+ header: str | None = None,
70
+ *,
71
+ default: str | None = None,
72
+ strip: bool = True,
73
+ not_null: bool = False,
74
+ ) -> Column[str]:
75
+ def parse(raw: Any) -> str:
76
+ if raw is None:
77
+ return ""
78
+ s = str(raw)
79
+ if strip:
80
+ s = s.strip()
81
+ return s
82
+
83
+ return Column(
84
+ ColumnSpec[str](
85
+ header=header,
86
+ default=default,
87
+ not_null=not_null,
88
+ parser=parse,
89
+ renderer=lambda v: "" if v is None else v,
90
+ )
91
+ )
92
+
93
+
94
+ def int_column(
95
+ header: str | None = None,
96
+ *,
97
+ default: int | None = None,
98
+ not_null: bool = False,
99
+ ) -> Column[int]:
100
+ def parse(raw: Any) -> int:
101
+ if raw is None or raw == "":
102
+ return 0
103
+ return int(raw)
104
+
105
+ return Column(
106
+ ColumnSpec[int](
107
+ header=header,
108
+ default=default,
109
+ not_null=not_null,
110
+ parser=parse,
111
+ )
112
+ )
113
+
114
+
115
+ def bool_column(header: str | None = None, *, default: bool | None = None) -> Column[bool]:
116
+ def parse(raw: Any) -> bool:
117
+ if raw is None or raw == "":
118
+ return False
119
+ if isinstance(raw, bool):
120
+ return raw
121
+ s = str(raw).strip().lower()
122
+ if s in {"true", "t", "yes", "y", "1"}:
123
+ return True
124
+ if s in {"false", "f", "no", "n", "0"}:
125
+ return False
126
+ raise ValueError(f"Invalid boolean: {raw}")
127
+
128
+ return Column(
129
+ ColumnSpec[bool](
130
+ header=header,
131
+ default=default,
132
+ parser=parse,
133
+ )
134
+ )
135
+
136
+
137
+ def date_column(header: str | None = None, *, default: date | None = None) -> Column[date]:
138
+ _DATE_FORMATS: tuple[str, ...] = (
139
+ "%d-%b-%Y", # 01-JUN-2025 (your requirement)
140
+ "%d-%b-%y", # 01-JUN-25
141
+ "%d %b %Y", # 01 JUN 2025
142
+ "%d %b %y", # 01 JUN 25
143
+ "%d/%b/%Y", # 01/JUN/2025
144
+ "%Y-%m-%d", # 2025-06-01
145
+ "%Y/%m/%d", # 2025/06/01
146
+ "%m/%d/%Y", # 06/01/2025
147
+ "%m/%d/%y", # 06/01/25
148
+ "%d/%m/%Y", # 01/06/2025
149
+ "%d/%m/%y", # 01/06/25
150
+ )
151
+
152
+ def parse(raw: Any) -> date:
153
+ if raw is None or raw == "":
154
+ raise ValueError("Date Value was empty")
155
+
156
+ if isinstance(raw, date) and not isinstance(raw, datetime):
157
+ return raw
158
+
159
+ if isinstance(raw, datetime):
160
+ return raw.date()
161
+
162
+ s = str(raw).strip()
163
+ if s == "":
164
+ raise ValueError("Date Value was empty")
165
+
166
+ # 1) ISO-8601 fast path (handles "2025-06-01" and "2025-06-01T13:45:00", etc.)
167
+ try:
168
+ dt = datetime.fromisoformat(s)
169
+ return dt.date()
170
+ except ValueError:
171
+ pass
172
+
173
+ # 2) Try known patterns (case-insensitive month abbreviations like JUN)
174
+ s_norm = s.upper()
175
+
176
+ for fmt in _DATE_FORMATS:
177
+ try:
178
+ return datetime.strptime(s_norm, fmt).date()
179
+ except ValueError:
180
+ continue
181
+
182
+ raise ValueError(f"Invalid date value: {raw!r}")
183
+
184
+ return Column(
185
+ ColumnSpec[date](
186
+ header=header,
187
+ default=default,
188
+ parser=parse,
189
+ renderer=lambda d: None if d is None else d, # openpyxl handles date types
190
+ )
191
+ )
excel_orm/orm.py ADDED
@@ -0,0 +1,376 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Any, TypeVar
6
+
7
+ from openpyxl import Workbook, load_workbook
8
+ from openpyxl.styles import Alignment, Font
9
+ from openpyxl.worksheet.worksheet import Worksheet
10
+
11
+ from .column import Column
12
+
13
+ M = TypeVar("M")
14
+
15
+
16
+ def _camel_to_snake(name: str) -> str:
17
+ s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
18
+ s2 = re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1)
19
+ return s2.lower()
20
+
21
+
22
+ def _pluralize(s: str) -> str:
23
+ # keep deliberately simple; can be swapped for inflect later
24
+ if s.endswith("s"):
25
+ return s
26
+ if s.endswith("y") and len(s) >= 2 and s[-2] not in "aeiou":
27
+ return s[:-1] + "ies"
28
+ return s + "s"
29
+
30
+
31
+ def _repo_name_for_model(model: type[Any]) -> str:
32
+ return _pluralize(_camel_to_snake(model.__name__))
33
+
34
+
35
+ def _display_name_for_model(model: type[Any]) -> str:
36
+ # "manufacturing_plants" -> "Manufacturing Plants"
37
+ return _repo_name_for_model(model).replace("_", " ").title()
38
+
39
+
40
+ def _get_model_columns(model: type[Any]) -> list[Column[Any]]:
41
+ return list(getattr(model, "__columns__", []))
42
+
43
+
44
+ def _normalize_header(v: Any) -> str:
45
+ if v is None:
46
+ return ""
47
+ return str(v).strip()
48
+
49
+
50
+ def _row_is_blank(values: list[Any]) -> bool:
51
+ return all(_normalize_header(v) == "" for v in values)
52
+
53
+
54
+ def _instantiate_model[M](model: type[M]) -> M:
55
+ obj = model.__new__(model)
56
+ obj._values = {}
57
+ # defaults
58
+ for col in _get_model_columns(model):
59
+ if col.name is None:
60
+ raise RuntimeError("Column __set_name__ did not run.")
61
+ obj._values[col.name] = col.spec.default
62
+ return obj
63
+
64
+
65
+ class Repository(list[M]):
66
+ def all(self) -> list[M]:
67
+ return list(self)
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class SheetSpec:
72
+ name: str
73
+ models: list[type[Any]]
74
+
75
+ title_row: int = 1
76
+ header_row: int = 2
77
+ data_start_row: int = 3
78
+
79
+ template_table_gap: int = 2
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class PivotSheetSpec:
84
+ name: str
85
+ model: type[Any] # single model only
86
+
87
+ # field names on the model
88
+ pivot_field: str
89
+ row_fields: list[str]
90
+ value_field: str
91
+
92
+ # layout
93
+ title_row: int = 1
94
+ header_row: int = 2 # pivot headers
95
+ row_header_col: int = 1
96
+ data_start_row: int = 3
97
+
98
+ # template: define the pivot column values (dates) to render across the top
99
+ pivot_values: list[Any] | None = None # e.g., list[date]; required for generation
100
+
101
+ # optional: seed row keys (regions) on template
102
+ row_values: list[Any] | None = None
103
+
104
+ include_blanks: bool = False # whether to load blank cells as data points
105
+
106
+ @property
107
+ def data_start_col(self) -> int:
108
+ return len(self.row_fields) + 1
109
+
110
+
111
+ AnySheetSpec = PivotSheetSpec | SheetSpec
112
+
113
+
114
+ class ExcelFile:
115
+ def __init__(self, *, sheets: list[AnySheetSpec]):
116
+ self.sheets = sheets
117
+
118
+ self._repos: dict[type[Any], Repository[Any]] = {}
119
+
120
+ for sheet in sheets:
121
+ models = [sheet.model] if isinstance(sheet, PivotSheetSpec) else sheet.models
122
+
123
+ for model in models:
124
+ repo_name = _repo_name_for_model(model)
125
+ if hasattr(self, repo_name):
126
+ raise ValueError(
127
+ f"Duplicate repo name '{repo_name}' for model {model.__name__}"
128
+ )
129
+ repo = Repository()
130
+ self._repos[model] = repo
131
+ setattr(self, repo_name, repo)
132
+
133
+ def generate_template(self, filename: str) -> None:
134
+ wb = Workbook()
135
+ default_ws = wb.active
136
+ if self.sheets:
137
+ wb.remove(default_ws)
138
+
139
+ for sheet in self.sheets:
140
+ ws = wb.create_sheet(title=sheet.name)
141
+ if isinstance(sheet, PivotSheetSpec):
142
+ self._write_pivot_sheet_template(ws, sheet)
143
+ else:
144
+ self._write_sheet_template(ws, sheet)
145
+
146
+ wb.save(filename)
147
+
148
+ def _write_sheet_template(self, ws: Worksheet, spec: SheetSpec) -> None:
149
+ current_col = 1 # 1-based index
150
+
151
+ title_font = Font(bold=True)
152
+ title_alignment = Alignment(horizontal="center", vertical="center")
153
+
154
+ header_font = Font(bold=True)
155
+
156
+ for model in spec.models:
157
+ cols = _get_model_columns(model)
158
+ headers = [c.spec.header or c.name for c in cols]
159
+ width = len(headers)
160
+
161
+ start_col = current_col
162
+ end_col = current_col + width - 1
163
+
164
+ # ---- merged title row ----
165
+ ws.merge_cells(
166
+ start_row=spec.title_row,
167
+ start_column=start_col,
168
+ end_row=spec.title_row,
169
+ end_column=end_col,
170
+ )
171
+ title_cell = ws.cell(
172
+ row=spec.title_row, column=start_col, value=_display_name_for_model(model)
173
+ )
174
+ title_cell.font = title_font
175
+ title_cell.alignment = title_alignment
176
+
177
+ for j, h in enumerate(headers):
178
+ c = start_col + j
179
+ cell = ws.cell(row=spec.header_row, column=c, value=h)
180
+ cell.font = header_font
181
+
182
+ col_letter = ws.cell(row=spec.header_row, column=c).column_letter
183
+ ws.column_dimensions[col_letter].width = max(12, min(40, len(str(h)) + 4))
184
+
185
+ current_col = end_col + 1 + spec.template_table_gap
186
+
187
+ def load_data(self, filename: str) -> None:
188
+ wb = load_workbook(filename=filename, data_only=True)
189
+ for repo in self._repos.values():
190
+ repo.clear()
191
+
192
+ for sheet_spec in self.sheets:
193
+ if sheet_spec.name not in wb.sheetnames:
194
+ raise ValueError(f"Workbook missing sheet '{sheet_spec.name}'")
195
+ ws = wb[sheet_spec.name]
196
+ if isinstance(sheet_spec, PivotSheetSpec):
197
+ self._parse_pivot_sheet(ws, sheet_spec)
198
+ else:
199
+ self._parse_sheet(ws, sheet_spec)
200
+
201
+ def _parse_sheet(self, ws: Worksheet, spec: SheetSpec) -> None:
202
+ for model in spec.models:
203
+ found = self._find_header(ws, spec, model)
204
+ if found is None:
205
+ continue
206
+
207
+ _, start_col = found
208
+ cols = _get_model_columns(model)
209
+ width = len(cols)
210
+
211
+ repo: Repository[Any] = self._repos[model]
212
+
213
+ r = spec.data_start_row
214
+ while r <= ws.max_row:
215
+ row_vals = [ws.cell(row=r, column=start_col + j).value for j in range(width)]
216
+ if _row_is_blank(row_vals):
217
+ break
218
+
219
+ # excludes (raw-value based)
220
+ if any(
221
+ col.spec.excludes and row_vals[i] in col.spec.excludes
222
+ for i, col in enumerate(cols)
223
+ ):
224
+ r += 1
225
+ continue
226
+
227
+ obj = _instantiate_model(model)
228
+ for i, col in enumerate(cols):
229
+ raw = row_vals[i]
230
+ parsed = col.parse_cell(raw)
231
+ setattr(obj, col.name, parsed)
232
+
233
+ validate = getattr(obj, "validate", None)
234
+ if callable(validate):
235
+ validate()
236
+
237
+ repo.append(obj)
238
+ r += 1
239
+
240
+ def _find_header(
241
+ self, ws: Worksheet, spec: SheetSpec, model: type[Any]
242
+ ) -> tuple[int, int] | None:
243
+ cols = _get_model_columns(model)
244
+ expected = [_normalize_header(c.spec.header) for c in cols]
245
+ if not expected:
246
+ return None
247
+
248
+ r = spec.header_row
249
+ width = len(expected)
250
+ max_c = ws.max_column or 0
251
+
252
+ for start_col in range(1, max_c - width + 2):
253
+ actual = [
254
+ _normalize_header(ws.cell(row=r, column=start_col + j).value) for j in range(width)
255
+ ]
256
+ if actual == expected:
257
+ return (r, start_col)
258
+
259
+ return None
260
+
261
+ 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
+ title_font = Font(bold=True)
266
+ title_alignment = Alignment(horizontal="center", vertical="center")
267
+ header_font = Font(bold=True)
268
+
269
+ # Title merged across the pivot header span
270
+ end_col = spec.data_start_col + len(spec.pivot_values) - 1
271
+
272
+ ws.merge_cells(
273
+ start_row=spec.title_row,
274
+ start_column=spec.row_header_col,
275
+ end_row=spec.title_row,
276
+ end_column=end_col,
277
+ )
278
+ tcell = ws.cell(spec.title_row, spec.row_header_col, _display_name_for_model(spec.model))
279
+ tcell.font = title_font
280
+ tcell.alignment = title_alignment
281
+
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))
289
+
290
+ # Pivot headers across the top
291
+ for j, pv in enumerate(spec.pivot_values):
292
+ c = spec.data_start_col + j
293
+ cell = ws.cell(spec.header_row, c, pv)
294
+ cell.font = header_font
295
+ col_letter = cell.column_letter
296
+ ws.column_dimensions[col_letter].width = 14
297
+
298
+ if spec.row_values:
299
+ for i, rv in enumerate(spec.row_values):
300
+ r = spec.data_start_row + i
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)
314
+
315
+ def _parse_pivot_sheet(self, ws: Worksheet, spec: PivotSheetSpec) -> None:
316
+ model = spec.model
317
+ cols = {c.name: c for c in _get_model_columns(model)} # Column descriptors by field name
318
+
319
+ # Validate fields exist
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
+ )
326
+
327
+ pivot_col = cols[spec.pivot_field]
328
+ val_col = cols[spec.value_field]
329
+ row_cols = [cols[f] for f in spec.row_fields]
330
+
331
+ # Determine pivot headers from sheet (or trust spec.pivot_values)
332
+ pivot_headers: list[Any] = []
333
+ j = 0
334
+ while True:
335
+ c = spec.data_start_col + j
336
+ raw = ws.cell(spec.header_row, c).value
337
+ if raw is None or str(raw).strip() == "":
338
+ break
339
+ pivot_headers.append(pivot_col.parse_cell(raw))
340
+ j += 1
341
+
342
+ if not pivot_headers:
343
+ return
344
+
345
+ repo: Repository[Any] = self._repos[model]
346
+
347
+ r = spec.data_start_row
348
+ while r <= ws.max_row:
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):
353
+ break
354
+
355
+ row_parts = [row_cols[i].parse_cell(raw_parts[i]) for i in range(len(row_cols))]
356
+
357
+ for j, pivot_value in enumerate(pivot_headers):
358
+ c = spec.data_start_col + j
359
+ raw_val = ws.cell(r, c).value
360
+ if not spec.include_blanks and (raw_val is None or raw_val == ""):
361
+ continue
362
+
363
+ obj = _instantiate_model(model)
364
+
365
+ for fname, parsed in zip(spec.row_fields, row_parts, strict=False):
366
+ setattr(obj, fname, parsed)
367
+ setattr(obj, spec.pivot_field, pivot_value)
368
+ setattr(obj, spec.value_field, val_col.parse_cell(raw_val))
369
+
370
+ validate = getattr(obj, "validate", None)
371
+ if callable(validate):
372
+ validate()
373
+
374
+ repo.append(obj)
375
+
376
+ r += 1
@@ -0,0 +1,358 @@
1
+ Metadata-Version: 2.4
2
+ Name: excel-orm
3
+ Version: 0.1.6
4
+ Summary: A lightweight Excel ORM for generating templates and parsing typed row models.
5
+ Project-URL: Homepage, https://github.com/acdelrusso/excel-orm
6
+ Project-URL: Repository, https://github.com/acdelrusso/excel-orm
7
+ Project-URL: Issues, https://github.com/acdelrusso/excel-orm/issues
8
+ Author: Anthony Del Russo
9
+ License: MIT
10
+ Keywords: etl,excel,openpyxl,orm
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: openpyxl>=3.1.5
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Excel ORM
22
+
23
+ A lightweight, typed “Excel ORM” for generating Excel templates and parsing Excel workbooks into Python objects using column descriptors.
24
+
25
+ This project is designed for the common enterprise pattern where you:
26
+
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.
35
+
36
+ ---
37
+
38
+ ## Features
39
+
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
63
+
64
+ ---
65
+
66
+ ## Installation
67
+
68
+ ### From PyPI
69
+
70
+ ```bash
71
+ pip install excel-orm
72
+ ```
73
+
74
+ ### From source (uv)
75
+
76
+ ```bash
77
+ git clone <your-repo-url>
78
+ cd excel-orm
79
+ uv sync
80
+ ```
81
+
82
+ ---
83
+
84
+ ## Quick Start
85
+
86
+ ### 1) Define models using `Column[...]` descriptors
87
+
88
+ ```python
89
+ from excel_orm.column import Column, text_column, int_column
90
+ from excel_orm import ExcelFile, SheetSpec
91
+
92
+ class Car:
93
+ make: Column[str] = text_column(header="Make", not_null=True)
94
+ model: Column[str] = text_column(header="Model", not_null=True)
95
+ year: Column[int] = int_column(header="Year", not_null=True)
96
+
97
+ class ManufacturingPlant:
98
+ name: Column[str] = text_column(header="Factory Name", not_null=True)
99
+ location: Column[str] = text_column(header="Location")
100
+ ```
101
+
102
+ ### 2) Declare a sheet containing multiple models (standard tables)
103
+
104
+ Each model becomes its own table on the same worksheet.
105
+
106
+ ```python
107
+ sheet = SheetSpec(
108
+ name="Cars",
109
+ models=[Car, ManufacturingPlant],
110
+ title_row=1,
111
+ header_row=2,
112
+ data_start_row=3,
113
+ template_table_gap=2,
114
+ )
115
+ ```
116
+
117
+ ### 3) Create an `ExcelFile`, generate a template, then load data
118
+
119
+ ```python
120
+ excel_file = ExcelFile(sheets=[sheet])
121
+
122
+ excel_file.generate_template("car_inventory_template.xlsx")
123
+ # Users fill in data in Excel...
124
+ excel_file.load_data("car_inventory_data.xlsx")
125
+
126
+ cars = excel_file.cars.all()
127
+ plants = excel_file.manufacturing_plants.all()
128
+
129
+ print(cars[0].make, cars[0].year)
130
+ print(plants[0].name, plants[0].location)
131
+ ```
132
+
133
+ ---
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
+
213
+ ## How It Works
214
+
215
+ ### Repositories
216
+
217
+ For each model you register, `ExcelFile` creates a repository attribute on the instance using a snake_case pluralized name:
218
+
219
+ * `Car` → `excel_file.cars`
220
+ * `ManufacturingPlant` → `excel_file.manufacturing_plants`
221
+ * `Demand` → `excel_file.demands`
222
+
223
+ Repositories are list-like containers with an `all()` helper:
224
+
225
+ ```python
226
+ cars = excel_file.cars.all() # list[Car]
227
+ ```
228
+
229
+ ### Multi-table Sheets (standard tables)
230
+
231
+ A single worksheet can host multiple model tables. During template generation:
232
+
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
237
+
238
+ During parsing:
239
+
240
+ * the library locates each model table by matching the expected header sequence
241
+ * it reads contiguous rows until a blank row is encountered
242
+
243
+ ---
244
+
245
+ ## Column Types
246
+
247
+ ### Text
248
+
249
+ ```python
250
+ from excel_orm.column import Column, text_column
251
+
252
+ class Example:
253
+ name: Column[str] = text_column(header="Name", not_null=True, strip=True)
254
+ ```
255
+
256
+ * `None` parses to `""` (empty string)
257
+ * `strip=True` trims whitespace
258
+
259
+ ### Integer
260
+
261
+ ```python
262
+ from excel_orm.column import Column, int_column
263
+
264
+ class Example:
265
+ qty: Column[int] = int_column(header="Qty", not_null=True)
266
+ ```
267
+
268
+ * `None` or `""` parses to `0`
269
+
270
+ ### Boolean
271
+
272
+ ```python
273
+ from excel_orm.column import Column, bool_column
274
+
275
+ class Example:
276
+ active: Column[bool] = bool_column(header="Active")
277
+ ```
278
+
279
+ Accepted values include:
280
+
281
+ * True: `true, t, yes, y, 1` (case-insensitive)
282
+ * False: `false, f, no, n, 0`
283
+ * `None` / empty parses to `False`
284
+
285
+ Invalid values raise `ValueError`.
286
+
287
+ ### Date
288
+
289
+ ```python
290
+ from datetime import date
291
+ from excel_orm.column import Column, date_column
292
+
293
+ class Example:
294
+ start_date: Column[date] = date_column(header="Start Date")
295
+ ```
296
+
297
+ The date parser supports:
298
+
299
+ * Excel-native `datetime`/`date` values from `openpyxl`
300
+ * ISO strings like `2025-06-01` and `2025-06-01T13:45:00`
301
+ * common business formats including `01-JUN-2025`
302
+
303
+ Invalid/empty values raise `ValueError`.
304
+
305
+ ---
306
+
307
+ ## Validation
308
+
309
+ ### Column-level: `not_null`
310
+
311
+ ```python
312
+ class Car:
313
+ make: Column[str] = text_column(header="Make", not_null=True)
314
+ ```
315
+
316
+ If a `not_null=True` column parses to `None` or `""`, a `ValueError` is raised.
317
+
318
+ ### Row exclusion: `excludes`
319
+
320
+ If you set `excludes`, rows matching those raw values in that column will be skipped during parsing.
321
+
322
+ ```python
323
+ status: Column[str] = text_column(header="Status")
324
+ status.spec.excludes = {"IGNORE", "SKIP"}
325
+ ```
326
+
327
+ ### Model-level: `validate()`
328
+
329
+ If your model defines a `validate(self)` method, it is called after a row is parsed.
330
+
331
+ ```python
332
+ class Car:
333
+ make: Column[str] = text_column(header="Make", not_null=True)
334
+ year: Column[int] = int_column(header="Year", not_null=True)
335
+
336
+ def validate(self) -> None:
337
+ if self.year < 1886:
338
+ raise ValueError("Invalid car year")
339
+ ```
340
+
341
+ ---
342
+
343
+ ## Development
344
+
345
+ ### Run tests
346
+
347
+ ```bash
348
+ uv run pytest
349
+ ```
350
+
351
+ ### Lint/format
352
+
353
+ If you use Ruff:
354
+
355
+ ```bash
356
+ uv run ruff check .
357
+ uv run ruff format .
358
+ ```
@@ -0,0 +1,7 @@
1
+ excel_orm/__init__.py,sha256=L8BOQiL1OGb7legxP59h1iBcJc9-oF7YNu5D-Bmr_VQ,392
2
+ excel_orm/base.py,sha256=P1jcbO93ohqQqQHLN-9V66pCRd9FdU_v91_0S9NKZ90,503
3
+ excel_orm/column.py,sha256=jburc06vvCvmPV1Xa0UQ3FQHqk7Darz9yJDyJZhT1gs,5443
4
+ excel_orm/orm.py,sha256=8vlJCdCxt-o13hq88JQnGkVPG3nLsd27Ooe7iAy_IkQ,12504
5
+ excel_orm-0.1.6.dist-info/METADATA,sha256=_2pZUEcCkl8BQNWmEGDbTk4lGVBwMtFwUy6oYh3Bgro,9311
6
+ excel_orm-0.1.6.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
7
+ excel_orm-0.1.6.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any