excel-orm 0.1.1__tar.gz → 0.1.2__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.2}/PKG-INFO +1 -1
- {excel_orm-0.1.1 → excel_orm-0.1.2}/pyproject.toml +1 -1
- {excel_orm-0.1.1 → excel_orm-0.1.2}/src/excel_orm/orm.py +42 -19
- {excel_orm-0.1.1 → excel_orm-0.1.2}/tests/conftest.py +12 -1
- {excel_orm-0.1.1 → excel_orm-0.1.2}/tests/test_orm.py +76 -1
- {excel_orm-0.1.1 → excel_orm-0.1.2}/uv.lock +1 -1
- {excel_orm-0.1.1 → excel_orm-0.1.2}/.gitignore +0 -0
- {excel_orm-0.1.1 → excel_orm-0.1.2}/.pre-commit-config.yaml +0 -0
- {excel_orm-0.1.1 → excel_orm-0.1.2}/.python-version +0 -0
- {excel_orm-0.1.1 → excel_orm-0.1.2}/README.md +0 -0
- {excel_orm-0.1.1 → excel_orm-0.1.2}/src/excel_orm/__init__.py +0 -0
- {excel_orm-0.1.1 → excel_orm-0.1.2}/src/excel_orm/column.py +0 -0
- {excel_orm-0.1.1 → excel_orm-0.1.2}/tests/__init__.py +0 -0
- {excel_orm-0.1.1 → excel_orm-0.1.2}/tests/test_columns.py +0 -0
- {excel_orm-0.1.1 → excel_orm-0.1.2}/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.2
|
|
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,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
|
|
@@ -183,7 +183,7 @@ def test_load_data_pivot_stops_at_blank_region_row(tmp_path, demand_model):
|
|
|
183
183
|
name="Demand",
|
|
184
184
|
model=demand_model,
|
|
185
185
|
pivot_field="dt",
|
|
186
|
-
|
|
186
|
+
row_fields=["region"],
|
|
187
187
|
value_field="value",
|
|
188
188
|
pivot_values=pivot_values,
|
|
189
189
|
row_values=None, # user-entered regions
|
|
@@ -230,3 +230,78 @@ def test_load_data_missing_pivot_sheet_raises(tmp_path, pivot_excel_file):
|
|
|
230
230
|
|
|
231
231
|
with pytest.raises(ValueError):
|
|
232
232
|
pivot_excel_file.load_data(str(p))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_pivot_two_row_fields_template_and_parse_end_to_end(tmp_path, demand_two_pivot):
|
|
236
|
+
"""
|
|
237
|
+
Two row_fields case:
|
|
238
|
+
row_fields: [region, product]
|
|
239
|
+
pivot cols: Jun, Jul
|
|
240
|
+
row_values seeded with composite keys (NA/ABC, EU/ABC)
|
|
241
|
+
Validate:
|
|
242
|
+
- template layout places row headers in A2,B2
|
|
243
|
+
- pivot headers start at C2,D2
|
|
244
|
+
- seeded keys appear in A3,B3 and A4,B4
|
|
245
|
+
- parsing emits correct objects with BOTH row fields populated
|
|
246
|
+
"""
|
|
247
|
+
pivot_values = [date(2025, 6, 1), date(2025, 7, 1)]
|
|
248
|
+
spec = PivotSheetSpec(
|
|
249
|
+
name="Demand",
|
|
250
|
+
model=demand_two_pivot,
|
|
251
|
+
pivot_field="dt",
|
|
252
|
+
row_fields=["region", "product"],
|
|
253
|
+
value_field="value",
|
|
254
|
+
pivot_values=pivot_values,
|
|
255
|
+
row_values=[("NA", "ABC"), ("EU", "ABC")],
|
|
256
|
+
row_header_col=1,
|
|
257
|
+
include_blanks=False,
|
|
258
|
+
)
|
|
259
|
+
xf = ExcelFile(sheets=[spec])
|
|
260
|
+
|
|
261
|
+
out = tmp_path / "pivot_two_row_fields.xlsx"
|
|
262
|
+
xf.generate_template(str(out))
|
|
263
|
+
|
|
264
|
+
wb = load_workbook(out)
|
|
265
|
+
ws = wb["Demand"]
|
|
266
|
+
|
|
267
|
+
# Title merged across A..D (A,B row headers + C,D pivot headers)
|
|
268
|
+
assert ws["A1"].value == "Demands"
|
|
269
|
+
merged = [str(rng) for rng in ws.merged_cells.ranges]
|
|
270
|
+
assert "A1:D1" in merged
|
|
271
|
+
|
|
272
|
+
# Row field headers in A2,B2
|
|
273
|
+
assert ws["A2"].value == "Region"
|
|
274
|
+
assert ws["B2"].value == "Product"
|
|
275
|
+
|
|
276
|
+
# Pivot headers start at C2..D2
|
|
277
|
+
assert _as_date(ws["C2"].value) == date(2025, 6, 1)
|
|
278
|
+
assert _as_date(ws["D2"].value) == date(2025, 7, 1)
|
|
279
|
+
|
|
280
|
+
# Seeded composite keys appear across A/B
|
|
281
|
+
assert ws["A3"].value == "NA"
|
|
282
|
+
assert ws["B3"].value == "ABC"
|
|
283
|
+
assert ws["A4"].value == "EU"
|
|
284
|
+
assert ws["B4"].value == "ABC"
|
|
285
|
+
|
|
286
|
+
# Fill matrix values: (row 3: NA/ABC), (row 4: EU/ABC)
|
|
287
|
+
ws["C3"].value = 10 # NA/ABC, Jun
|
|
288
|
+
ws["D3"].value = 20 # NA/ABC, Jul
|
|
289
|
+
ws["C4"].value = 5 # EU/ABC, Jun
|
|
290
|
+
ws["D4"].value = 0 # EU/ABC, Jul
|
|
291
|
+
|
|
292
|
+
wb.save(out)
|
|
293
|
+
|
|
294
|
+
xf.load_data(str(out))
|
|
295
|
+
rows = xf.demands.all() # repo name plural of Demand
|
|
296
|
+
|
|
297
|
+
# Expect 4 objects (2 rows x 2 pivots)
|
|
298
|
+
assert len(rows) == 4
|
|
299
|
+
|
|
300
|
+
got = {(r.region, r.product, r.dt, r.value) for r in rows}
|
|
301
|
+
expected = {
|
|
302
|
+
("NA", "ABC", date(2025, 6, 1), 10),
|
|
303
|
+
("NA", "ABC", date(2025, 7, 1), 20),
|
|
304
|
+
("EU", "ABC", date(2025, 6, 1), 5),
|
|
305
|
+
("EU", "ABC", date(2025, 7, 1), 0),
|
|
306
|
+
}
|
|
307
|
+
assert got == expected
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|