excel-orm 0.1.0__tar.gz → 0.1.1__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.1
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.1"
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"]
File without changes
@@ -0,0 +1,75 @@
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 pivot_excel_file(demand_model):
56
+ Demand = demand_model
57
+
58
+ # Pre-defined pivot values across the top of the sheet
59
+ pivot_values = [
60
+ date(2025, 6, 1),
61
+ date(2025, 7, 1),
62
+ date(2025, 8, 1),
63
+ ]
64
+
65
+ spec = PivotSheetSpec(
66
+ name="Demand",
67
+ model=Demand,
68
+ pivot_field="dt",
69
+ row_field="region",
70
+ value_field="value",
71
+ pivot_values=pivot_values,
72
+ row_values=["NA", "EU"], # seed some regions
73
+ )
74
+
75
+ 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")
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date, datetime
4
+
5
+ import pytest
6
+ from openpyxl import load_workbook
7
+
8
+ from excel_orm import ExcelFile, PivotSheetSpec
9
+
10
+
11
+ def test_generate_template_creates_sheet_and_titles_and_headers(tmp_path, excel_file):
12
+ out = tmp_path / "template.xlsx"
13
+ excel_file.generate_template(str(out))
14
+
15
+ wb = load_workbook(out)
16
+ assert "Cars" in wb.sheetnames
17
+ ws = wb["Cars"]
18
+
19
+ # Cars block: title row merged across A..C, title value "Cars"
20
+ assert ws["A1"].value == "Cars"
21
+ merged = [str(rng) for rng in ws.merged_cells.ranges]
22
+ assert "A1:C1" in merged
23
+
24
+ # Cars headers on row 2
25
+ assert ws["A2"].value == "Make"
26
+ assert ws["B2"].value == "Model"
27
+ assert ws["C2"].value == "Year"
28
+
29
+ # ManufacturingPlant block starts after 2-col gap: Cars ends at C, so start at F
30
+ assert ws["F1"].value == "Manufacturing Plants"
31
+ assert "F1:G1" in merged
32
+ assert ws["F2"].value == "Factory Name"
33
+ assert ws["G2"].value == "Location"
34
+
35
+
36
+ def test_load_data_end_to_end(tmp_path, excel_file):
37
+ """
38
+ Create a workbook that matches the template layout, fill a few rows,
39
+ load it, and verify repositories.
40
+ """
41
+ out = tmp_path / "data.xlsx"
42
+ excel_file.generate_template(str(out))
43
+
44
+ # Fill data
45
+ from openpyxl import load_workbook
46
+
47
+ wb = load_workbook(out)
48
+ ws = wb["Cars"]
49
+
50
+ # Cars data at row 3+
51
+ ws["A3"].value = "Toyota"
52
+ ws["B3"].value = "Camry"
53
+ ws["C3"].value = 2020
54
+
55
+ ws["A4"].value = "Honda"
56
+ ws["B4"].value = "Civic"
57
+ ws["C4"].value = "2019" # string should parse to int
58
+
59
+ # Plants data at row 3+ (F,G)
60
+ ws["F3"].value = "Plant 1"
61
+ ws["G3"].value = "NJ"
62
+
63
+ ws["F4"].value = "Plant 2"
64
+ ws["G4"].value = "PA"
65
+
66
+ wb.save(out)
67
+
68
+ # Load
69
+ excel_file.load_data(str(out))
70
+
71
+ cars = excel_file.cars.all()
72
+ plants = excel_file.manufacturing_plants.all()
73
+
74
+ assert len(cars) == 2
75
+ assert cars[0].make == "Toyota"
76
+ assert cars[0].model == "Camry"
77
+ assert cars[0].year == 2020
78
+ assert cars[1].year == 2019
79
+
80
+ assert len(plants) == 2
81
+ assert plants[0].name == "Plant 1"
82
+ assert plants[0].location == "NJ"
83
+
84
+
85
+ def test_load_data_missing_sheet_raises(tmp_path, excel_file):
86
+ # Create a workbook with a different sheet name
87
+ from openpyxl import Workbook
88
+
89
+ p = tmp_path / "wrong.xlsx"
90
+ wb = Workbook()
91
+ wb.active.title = "NotCars"
92
+ wb.save(p)
93
+
94
+ with pytest.raises(ValueError):
95
+ excel_file.load_data(str(p))
96
+
97
+
98
+ def _as_date(x) -> date:
99
+ """openpyxl may return date or datetime depending on formatting; normalize."""
100
+ if isinstance(x, datetime):
101
+ return x.date()
102
+ if isinstance(x, date):
103
+ return x
104
+ raise TypeError(f"Expected date/datetime, got {type(x)}: {x!r}")
105
+
106
+
107
+ def test_generate_template_pivot_sheet_layout(tmp_path, pivot_excel_file, demand_model):
108
+ out = tmp_path / "pivot_template.xlsx"
109
+ pivot_excel_file.generate_template(str(out))
110
+
111
+ wb = load_workbook(out)
112
+ assert "Demand" in wb.sheetnames
113
+ ws = wb["Demand"]
114
+
115
+ # Title merged across A..D (A for row header + 3 pivot columns -> B,C,D)
116
+ assert ws["A1"].value == "Demands"
117
+ merged = [str(rng) for rng in ws.merged_cells.ranges]
118
+ assert "A1:D1" in merged
119
+
120
+ # Corner header for row keys (Region)
121
+ assert ws["A2"].value in {"Region"} # explicit
122
+
123
+ # Pivot headers across B2..D2
124
+ assert _as_date(ws["B2"].value) == date(2025, 6, 1)
125
+ assert _as_date(ws["C2"].value) == date(2025, 7, 1)
126
+ assert _as_date(ws["D2"].value) == date(2025, 8, 1)
127
+
128
+ # Seeded row values appear in A3, A4
129
+ assert ws["A3"].value == "NA"
130
+ assert ws["A4"].value == "EU"
131
+
132
+
133
+ def test_load_data_pivot_end_to_end_skips_blank_cells(tmp_path, pivot_excel_file):
134
+ """
135
+ Fill a demand pivot matrix:
136
+ rows: NA, EU
137
+ cols: Jun, Jul, Aug
138
+ Leave one cell blank and verify it is not emitted (include_blanks=False).
139
+ """
140
+ out = tmp_path / "pivot_data.xlsx"
141
+ pivot_excel_file.generate_template(str(out))
142
+
143
+ wb = load_workbook(out)
144
+ ws = wb["Demand"]
145
+
146
+ # NA row at row 3
147
+ ws["B3"].value = 10 # NA, Jun
148
+ ws["C3"].value = 20 # NA, Jul
149
+ ws["D3"].value = None # NA, Aug (blank -> should skip)
150
+
151
+ # EU row at row 4
152
+ ws["B4"].value = 5 # EU, Jun
153
+ ws["C4"].value = 0 # EU, Jul (explicit 0 should be included)
154
+ ws["D4"].value = 15 # EU, Aug
155
+
156
+ wb.save(out)
157
+
158
+ pivot_excel_file.load_data(str(out))
159
+
160
+ # Repo name is plural snake_case of Demand -> demands
161
+ rows = pivot_excel_file.demands.all()
162
+ assert len(rows) == 5 # 6 cells minus 1 blank = 5
163
+
164
+ # Compare as a set of tuples to avoid ordering assumptions
165
+ got = {(r.region, r.dt, r.value) for r in rows}
166
+ expected = {
167
+ ("NA", date(2025, 6, 1), 10),
168
+ ("NA", date(2025, 7, 1), 20),
169
+ # ("NA", date(2025, 8, 1), ?) skipped
170
+ ("EU", date(2025, 6, 1), 5),
171
+ ("EU", date(2025, 7, 1), 0),
172
+ ("EU", date(2025, 8, 1), 15),
173
+ }
174
+ assert got == expected
175
+
176
+
177
+ def test_load_data_pivot_stops_at_blank_region_row(tmp_path, demand_model):
178
+ """
179
+ If the region cell is blank, parsing should stop (contiguous-block rule for row labels).
180
+ """
181
+ pivot_values = [date(2025, 6, 1), date(2025, 7, 1)]
182
+ spec = PivotSheetSpec(
183
+ name="Demand",
184
+ model=demand_model,
185
+ pivot_field="dt",
186
+ row_field="region",
187
+ value_field="value",
188
+ pivot_values=pivot_values,
189
+ row_values=None, # user-entered regions
190
+ )
191
+ xf = ExcelFile(sheets=[spec])
192
+
193
+ out = tmp_path / "stop_rule.xlsx"
194
+ xf.generate_template(str(out))
195
+
196
+ wb = load_workbook(out)
197
+ ws = wb["Demand"]
198
+
199
+ # Row 3 has region, row 4 is blank region -> stop; row 5 should not be read
200
+ ws["A3"].value = "NA"
201
+ ws["B3"].value = 1
202
+ ws["C3"].value = 2
203
+
204
+ ws["A4"].value = "" # stop condition
205
+
206
+ ws["A5"].value = "EU"
207
+ ws["B5"].value = 9
208
+ ws["C5"].value = 9
209
+
210
+ wb.save(out)
211
+
212
+ xf.load_data(str(out))
213
+ rows = xf.demands.all() # ty:ignore[unresolved-attribute]
214
+
215
+ got = {(r.region, r.dt, r.value) for r in rows}
216
+ assert got == {
217
+ ("NA", date(2025, 6, 1), 1),
218
+ ("NA", date(2025, 7, 1), 2),
219
+ }
220
+
221
+
222
+ def test_load_data_missing_pivot_sheet_raises(tmp_path, pivot_excel_file):
223
+ # Create a workbook with a different sheet name
224
+ from openpyxl import Workbook
225
+
226
+ p = tmp_path / "wrong.xlsx"
227
+ wb = Workbook()
228
+ wb.active.title = "NotDemand"
229
+ wb.save(p)
230
+
231
+ with pytest.raises(ValueError):
232
+ pivot_excel_file.load_data(str(p))
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from src.excel_orm import Column, int_column, text_column
4
+ from src.excel_orm.orm import (
5
+ _camel_to_snake,
6
+ _display_name_for_model,
7
+ _get_model_columns,
8
+ _instantiate_model,
9
+ _pluralize,
10
+ _repo_name_for_model,
11
+ )
12
+
13
+
14
+ def test_camel_to_snake():
15
+ assert _camel_to_snake("Car") == "car"
16
+ assert _camel_to_snake("ManufacturingPlant") == "manufacturing_plant"
17
+ assert _camel_to_snake("HTTPServer") == "http_server"
18
+
19
+
20
+ def test_pluralize():
21
+ assert _pluralize("car") == "cars"
22
+ assert _pluralize("class") == "class" # your simple rule: if endswith s, keep as-is
23
+
24
+
25
+ def test_repo_and_display_name():
26
+ class ManufacturingPlant:
27
+ pass
28
+
29
+ assert _repo_name_for_model(ManufacturingPlant) == "manufacturing_plants"
30
+ assert _display_name_for_model(ManufacturingPlant) == "Manufacturing Plants"
31
+
32
+
33
+ def test_get_model_columns_respects_annotation_order():
34
+ class A:
35
+ x: Column[str] = text_column(header="X")
36
+ y: Column[int] = int_column(header="Y")
37
+
38
+ cols = _get_model_columns(A)
39
+ assert [c.name for c in cols] == ["x", "y"]
40
+
41
+
42
+ def test_instantiate_model_sets_defaults_and_requires_set_name():
43
+ class A:
44
+ x: Column[str] = text_column(header="X", default="dflt")
45
+
46
+ a = _instantiate_model(A)
47
+ assert a._values["x"] == "dflt" # ty:ignore[unresolved-attribute]