excel-orm 0.1.0__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.
@@ -12,4 +12,5 @@ wheels/
12
12
  .ruff_cache/
13
13
  .pytest_cache/
14
14
  .git/
15
- .env
15
+ .env
16
+ .pypirc
@@ -0,0 +1,80 @@
1
+ # .pre-commit-config.yaml
2
+ default_stages: [pre-commit]
3
+
4
+ minimum_pre_commit_version: "3.7.0"
5
+
6
+ exclude: |
7
+ (?x)(
8
+ ^\.venv/|
9
+ ^dist/|
10
+ ^build/|
11
+ ^\.mypy_cache/|
12
+ ^\.ruff_cache/|
13
+ ^\.pytest_cache/|
14
+ ^\.tox/|
15
+ ^node_modules/|
16
+ ^docs/_build/
17
+ )
18
+
19
+ repos:
20
+ # -------------------------
21
+ # Baseline hygiene hooks
22
+ # -------------------------
23
+ - repo: https://github.com/pre-commit/pre-commit-hooks
24
+ rev: v5.0.0
25
+ hooks:
26
+ - id: check-yaml
27
+ - id: check-toml
28
+ - id: end-of-file-fixer
29
+ - id: trailing-whitespace
30
+ args: [--markdown-linebreak-ext=md]
31
+ - id: check-added-large-files
32
+ args: ["--maxkb=1500"]
33
+ - id: detect-private-key
34
+ - id: debug-statements
35
+
36
+ # -------------------------
37
+ # uv: lockfile / export consistency
38
+ # Official hook repo: astral-sh/uv-pre-commit :contentReference[oaicite:1]{index=1}
39
+ # -------------------------
40
+ - repo: https://github.com/astral-sh/uv-pre-commit
41
+ rev: 0.9.18
42
+ hooks:
43
+ # Ensure uv.lock matches pyproject.toml changes
44
+ - id: uv-lock
45
+ stages: [pre-commit]
46
+
47
+ # -------------------------
48
+ # Ruff (lint + format)
49
+ # Official integration: astral-sh/ruff-pre-commit :contentReference[oaicite:2]{index=2}
50
+ # Order note when using --fix: lint before format :contentReference[oaicite:3]{index=3}
51
+ # -------------------------
52
+ - repo: https://github.com/astral-sh/ruff-pre-commit
53
+ rev: v0.14.10
54
+ hooks:
55
+ - id: ruff-check
56
+ args: [--fix, --exit-non-zero-on-fix]
57
+ stages: [pre-commit]
58
+ - id: ruff-format
59
+ stages: [pre-commit]
60
+
61
+ # -------------------------
62
+ # Local hooks that run via uv so they use your pinned toolchain.
63
+ # This is the cleanest way to integrate ty + pytest today.
64
+ # ty CLI: `ty check` :contentReference[oaicite:4]{index=4}
65
+ # -------------------------
66
+ - repo: local
67
+ hooks:
68
+ - id: ty-check
69
+ name: ty (type check)
70
+ entry: uv run ty check
71
+ language: system
72
+ pass_filenames: false
73
+ stages: [pre-push]
74
+
75
+ - id: pytest
76
+ name: pytest
77
+ entry: uv run pytest -q
78
+ language: system
79
+ pass_filenames: false
80
+ stages: [pre-push]
@@ -0,0 +1 @@
1
+ 3.13
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: excel-orm
3
- Version: 0.1.0
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.0"
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"
@@ -15,18 +15,16 @@ classifiers = [
15
15
  "License :: OSI Approved :: MIT License",
16
16
  "Operating System :: OS Independent",
17
17
  ]
18
- dependencies = [
19
- "openpyxl>=3.1.5",
20
- ]
18
+ dependencies = ["openpyxl>=3.1.5"]
21
19
 
22
20
  [dependency-groups]
23
21
  dev = [
24
- "build>=1.3.0",
25
- "pre-commit>=4.5.1",
26
- "pytest>=9.0.2",
27
- "ruff>=0.14.10",
28
- "twine>=6.2.0",
29
- "ty>=0.0.8",
22
+ "build>=1.3.0",
23
+ "pre-commit>=4.5.1",
24
+ "pytest>=9.0.2",
25
+ "ruff>=0.14.10",
26
+ "twine>=6.2.0",
27
+ "ty>=0.0.8",
30
28
  ]
31
29
 
32
30
  [tool.ruff]
@@ -58,5 +56,5 @@ Issues = "https://github.com/acdelrusso/excel-orm/issues"
58
56
  requires = ["hatchling>=1.25"]
59
57
  build-backend = "hatchling.build"
60
58
 
61
- [tool.hatch.build]
59
+ [tool.hatch.build.targets.wheel]
62
60
  packages = ["src/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
- 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
 
File without changes
@@ -0,0 +1,86 @@
1
+ from datetime import date
2
+
3
+ import pytest
4
+
5
+ from src.excel_orm import (
6
+ Column,
7
+ ExcelFile,
8
+ PivotSheetSpec,
9
+ SheetSpec,
10
+ date_column,
11
+ int_column,
12
+ text_column,
13
+ )
14
+
15
+
16
+ @pytest.fixture
17
+ def models():
18
+ class Car:
19
+ make: Column[str] = text_column(header="Make", not_null=True)
20
+ model: Column[str] = text_column(header="Model", not_null=True)
21
+ year: Column[int] = int_column(header="Year", not_null=True)
22
+
23
+ class ManufacturingPlant:
24
+ name: Column[str] = text_column(header="Factory Name", not_null=True)
25
+ location: Column[str] = text_column(header="Location")
26
+
27
+ return Car, ManufacturingPlant
28
+
29
+
30
+ @pytest.fixture
31
+ def excel_file(models):
32
+ Car, ManufacturingPlant = models
33
+ sheet = SheetSpec(
34
+ name="Cars",
35
+ models=[Car, ManufacturingPlant],
36
+ title_row=1,
37
+ header_row=2,
38
+ data_start_row=3,
39
+ template_table_gap=2,
40
+ )
41
+ return ExcelFile(sheets=[sheet])
42
+
43
+
44
+ @pytest.fixture
45
+ def demand_model():
46
+ class Demand:
47
+ dt: Column[date] = date_column(header="Date")
48
+ region: Column[str] = text_column(header="Region", not_null=True)
49
+ value: Column[int] = int_column(header="Value", not_null=True)
50
+
51
+ return Demand
52
+
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
+
65
+ @pytest.fixture
66
+ def pivot_excel_file(demand_model):
67
+ Demand = demand_model
68
+
69
+ # Pre-defined pivot values across the top of the sheet
70
+ pivot_values = [
71
+ date(2025, 6, 1),
72
+ date(2025, 7, 1),
73
+ date(2025, 8, 1),
74
+ ]
75
+
76
+ spec = PivotSheetSpec(
77
+ name="Demand",
78
+ model=Demand,
79
+ pivot_field="dt",
80
+ row_fields=["region"],
81
+ value_field="value",
82
+ pivot_values=pivot_values,
83
+ row_values=["NA", "EU"], # seed some regions
84
+ )
85
+
86
+ return ExcelFile(sheets=[spec])
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date, datetime
4
+
5
+ import pytest
6
+
7
+ from src.excel_orm import Column, bool_column, date_column, int_column, text_column
8
+
9
+
10
+ def test_descriptor_stores_in_values_dict():
11
+ class Foo:
12
+ a: Column[str] = text_column(header="A")
13
+
14
+ f = Foo()
15
+ f._values = {} # ty:ignore[unresolved-attribute]
16
+
17
+ f.a = "hello"
18
+ assert f._values["a"] == "hello" # ty:ignore[unresolved-attribute]
19
+ assert f.a == "hello"
20
+
21
+
22
+ def test_descriptor_class_level_access_returns_column():
23
+ class Foo:
24
+ a: Column[str] = text_column(header="A")
25
+
26
+ assert isinstance(Foo.a, Column)
27
+
28
+
29
+ def test_not_null_validation_raises_on_none_or_empty():
30
+ class Foo:
31
+ a: Column[str] = text_column(header="A", not_null=True)
32
+
33
+ f = Foo()
34
+ f._values = {} # ty:ignore[unresolved-attribute]
35
+
36
+ with pytest.raises(ValueError):
37
+ f.a = None # type: ignore[assignment]
38
+
39
+ with pytest.raises(ValueError):
40
+ f.a = ""
41
+
42
+
43
+ def test_text_column_strip_and_none_to_empty_string():
44
+ col = text_column(header="X", strip=True)
45
+ assert col.parse_cell(None) == ""
46
+ assert col.parse_cell(" hi ") == "hi"
47
+ assert col.parse_cell(123) == "123"
48
+
49
+
50
+ def test_text_column_no_strip():
51
+ col = text_column(header="X", strip=False)
52
+ assert col.parse_cell(" hi ") == " hi "
53
+
54
+
55
+ def test_int_column_parsing():
56
+ col = int_column(header="Y")
57
+ assert col.parse_cell(None) == 0
58
+ assert col.parse_cell("") == 0
59
+ assert col.parse_cell("42") == 42
60
+ assert col.parse_cell(7) == 7
61
+
62
+
63
+ def test_bool_column_parsing_valid_values():
64
+ col = bool_column(header="B")
65
+ assert col.parse_cell(None) is False
66
+ assert col.parse_cell("") is False
67
+ assert col.parse_cell(True) is True
68
+ assert col.parse_cell("YES") is True
69
+ assert col.parse_cell("0") is False
70
+ assert col.parse_cell("n") is False
71
+
72
+
73
+ def test_bool_column_invalid_raises():
74
+ col = bool_column(header="B")
75
+ with pytest.raises(ValueError):
76
+ col.parse_cell("maybe")
77
+
78
+
79
+ @pytest.mark.parametrize(
80
+ "raw, expected",
81
+ [
82
+ ("01-JUN-2025", date(2025, 6, 1)),
83
+ ("01-jun-2025", date(2025, 6, 1)), # case-insensitive month
84
+ ("2025-06-01", date(2025, 6, 1)),
85
+ ("2025/06/01", date(2025, 6, 1)),
86
+ ("06/01/2025", date(2025, 6, 1)), # per your formats list (US)
87
+ (datetime(2025, 6, 1, 12, 30), date(2025, 6, 1)),
88
+ (date(2025, 6, 1), date(2025, 6, 1)),
89
+ ("2025-06-01T13:45:00", date(2025, 6, 1)), # ISO datetime
90
+ ],
91
+ )
92
+ def test_date_column_tryparse_cascade(raw, expected):
93
+ col = date_column(header="D")
94
+ assert col.parse_cell(raw) == expected
95
+
96
+
97
+ def test_date_column_empty_raises():
98
+ col = date_column(header="D")
99
+ with pytest.raises(ValueError):
100
+ col.parse_cell(None)
101
+ with pytest.raises(ValueError):
102
+ col.parse_cell("")
103
+ with pytest.raises(ValueError):
104
+ col.parse_cell(" ")
105
+
106
+
107
+ def test_date_column_invalid_raises():
108
+ col = date_column(header="D")
109
+ with pytest.raises(ValueError):
110
+ col.parse_cell("not-a-date")