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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: excel-orm
3
- Version: 0.1.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "excel-orm"
3
- version = "0.1.1"
3
+ version = "0.1.2"
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"
@@ -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
- row_field: str
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
- # Top-left corner header (row field name)
278
- corner = ws.cell(spec.header_row, spec.row_header_col, spec.row_field.title())
279
- corner.font = header_font
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
- ws.cell(r, spec.row_header_col, rv)
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
- for fname in (spec.pivot_field, spec.row_field, spec.value_field):
302
- if fname not in cols:
303
- raise ValueError(
304
- f"{model.__name__} is missing Column field '{fname}' required by PivotSheetSpec."
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
- raw_row_key = ws.cell(r, spec.row_header_col).value
330
- if raw_row_key is None or str(raw_row_key).strip() == "":
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
- row_key = row_col.parse_cell(raw_row_key)
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
- setattr(obj, spec.row_field, row_key)
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
- row_field="region",
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
- row_field="region",
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
@@ -187,7 +187,7 @@ wheels = [
187
187
 
188
188
  [[package]]
189
189
  name = "excel-orm"
190
- version = "0.1.1"
190
+ version = "0.1.2"
191
191
  source = { editable = "." }
192
192
  dependencies = [
193
193
  { name = "openpyxl" },
File without changes
File without changes
File without changes
File without changes