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.
- {excel_orm-0.1.0 → excel_orm-0.1.1}/.gitignore +2 -1
- excel_orm-0.1.1/.pre-commit-config.yaml +80 -0
- excel_orm-0.1.1/.python-version +1 -0
- {excel_orm-0.1.0 → excel_orm-0.1.1}/PKG-INFO +1 -1
- {excel_orm-0.1.0 → excel_orm-0.1.1}/pyproject.toml +9 -11
- excel_orm-0.1.1/tests/__init__.py +0 -0
- excel_orm-0.1.1/tests/conftest.py +75 -0
- excel_orm-0.1.1/tests/test_columns.py +110 -0
- excel_orm-0.1.1/tests/test_orm.py +232 -0
- excel_orm-0.1.1/tests/test_orm_helpers.py +47 -0
- excel_orm-0.1.1/uv.lock +709 -0
- {excel_orm-0.1.0 → excel_orm-0.1.1}/README.md +0 -0
- {excel_orm-0.1.0 → excel_orm-0.1.1/src}/excel_orm/__init__.py +0 -0
- {excel_orm-0.1.0 → excel_orm-0.1.1/src}/excel_orm/column.py +0 -0
- {excel_orm-0.1.0 → excel_orm-0.1.1/src}/excel_orm/orm.py +0 -0
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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]
|