excel-orm 0.1.6__py3-none-any.whl
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/__init__.py +23 -0
- excel_orm/base.py +21 -0
- excel_orm/column.py +191 -0
- excel_orm/orm.py +376 -0
- excel_orm-0.1.6.dist-info/METADATA +358 -0
- excel_orm-0.1.6.dist-info/RECORD +7 -0
- excel_orm-0.1.6.dist-info/WHEEL +4 -0
excel_orm/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .base import RowBase
|
|
2
|
+
from .column import (
|
|
3
|
+
Column,
|
|
4
|
+
ColumnSpec,
|
|
5
|
+
bool_column,
|
|
6
|
+
date_column,
|
|
7
|
+
int_column,
|
|
8
|
+
text_column,
|
|
9
|
+
)
|
|
10
|
+
from .orm import ExcelFile, PivotSheetSpec, SheetSpec
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Column",
|
|
14
|
+
"ColumnSpec",
|
|
15
|
+
"ExcelFile",
|
|
16
|
+
"PivotSheetSpec",
|
|
17
|
+
"RowBase",
|
|
18
|
+
"SheetSpec",
|
|
19
|
+
"bool_column",
|
|
20
|
+
"date_column",
|
|
21
|
+
"int_column",
|
|
22
|
+
"text_column",
|
|
23
|
+
]
|
excel_orm/base.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from excel_orm import Column
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RowBase:
|
|
10
|
+
__columns__: list[Column[Any]]
|
|
11
|
+
|
|
12
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
13
|
+
self._values: dict[str, Any] = {}
|
|
14
|
+
|
|
15
|
+
for col in getattr(self, "__columns__", []):
|
|
16
|
+
if col.name is None:
|
|
17
|
+
continue
|
|
18
|
+
self._values[col.name] = col.spec.default
|
|
19
|
+
|
|
20
|
+
for k, v in kwargs.items():
|
|
21
|
+
setattr(self, k, v)
|
excel_orm/column.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import date, datetime
|
|
6
|
+
from typing import Any, Protocol, TypeVar, overload
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HasValues(Protocol):
|
|
12
|
+
_values: dict[str, Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
Owner = TypeVar("Owner", bound=HasValues)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ColumnSpec[T]:
|
|
20
|
+
header: str | None = None # header string in Excel
|
|
21
|
+
default: T | None = None
|
|
22
|
+
not_null: bool = False # parsed value cannot be None/empty
|
|
23
|
+
excludes: set[Any] | None = None # raw values that mark row as excluded
|
|
24
|
+
parser: Callable[[Any], T] = lambda x: x # raw -> parsed
|
|
25
|
+
renderer: Callable[[T | None], Any] = lambda x: x # parsed -> raw
|
|
26
|
+
validator: Callable[[T | None], None] = lambda _: None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Column[T]:
|
|
30
|
+
def __init__(self, spec: ColumnSpec[T]):
|
|
31
|
+
self.spec = spec
|
|
32
|
+
self.name: str | None = None
|
|
33
|
+
|
|
34
|
+
def __set_name__(self, owner: type[Any], name: str) -> None:
|
|
35
|
+
self.name = name
|
|
36
|
+
reg = owner.__dict__.get("__columns__")
|
|
37
|
+
if reg is None:
|
|
38
|
+
owner.__columns__ = []
|
|
39
|
+
owner.__columns__.append(self)
|
|
40
|
+
|
|
41
|
+
@overload
|
|
42
|
+
def __get__(self, obj: None, objtype: type[Owner] | None = None) -> Column[T]: ...
|
|
43
|
+
@overload
|
|
44
|
+
def __get__(self, obj: Owner, objtype: type[Owner] | None = None) -> T: ...
|
|
45
|
+
|
|
46
|
+
def __get__(self, obj: Owner | None, objtype: type[Owner] | None = None) -> T | Column:
|
|
47
|
+
if obj is None:
|
|
48
|
+
return self
|
|
49
|
+
if self.name is None:
|
|
50
|
+
raise RuntimeError("Column __set_name__ did not run.")
|
|
51
|
+
return obj._values.get(self.name)
|
|
52
|
+
|
|
53
|
+
def __set__(self, obj: Owner, value: T | None) -> None:
|
|
54
|
+
self.validate(value)
|
|
55
|
+
if self.name is None:
|
|
56
|
+
raise RuntimeError("Column __set_name__ did not run.")
|
|
57
|
+
obj._values[self.name] = value
|
|
58
|
+
|
|
59
|
+
def parse_cell(self, raw: Any) -> T | None:
|
|
60
|
+
return self.spec.parser(raw)
|
|
61
|
+
|
|
62
|
+
def validate(self, value: T | None) -> None:
|
|
63
|
+
if self.spec.not_null and (value is None or value == ""):
|
|
64
|
+
raise ValueError(f"{self.name} cannot be null/empty")
|
|
65
|
+
self.spec.validator(value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def text_column(
|
|
69
|
+
header: str | None = None,
|
|
70
|
+
*,
|
|
71
|
+
default: str | None = None,
|
|
72
|
+
strip: bool = True,
|
|
73
|
+
not_null: bool = False,
|
|
74
|
+
) -> Column[str]:
|
|
75
|
+
def parse(raw: Any) -> str:
|
|
76
|
+
if raw is None:
|
|
77
|
+
return ""
|
|
78
|
+
s = str(raw)
|
|
79
|
+
if strip:
|
|
80
|
+
s = s.strip()
|
|
81
|
+
return s
|
|
82
|
+
|
|
83
|
+
return Column(
|
|
84
|
+
ColumnSpec[str](
|
|
85
|
+
header=header,
|
|
86
|
+
default=default,
|
|
87
|
+
not_null=not_null,
|
|
88
|
+
parser=parse,
|
|
89
|
+
renderer=lambda v: "" if v is None else v,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def int_column(
|
|
95
|
+
header: str | None = None,
|
|
96
|
+
*,
|
|
97
|
+
default: int | None = None,
|
|
98
|
+
not_null: bool = False,
|
|
99
|
+
) -> Column[int]:
|
|
100
|
+
def parse(raw: Any) -> int:
|
|
101
|
+
if raw is None or raw == "":
|
|
102
|
+
return 0
|
|
103
|
+
return int(raw)
|
|
104
|
+
|
|
105
|
+
return Column(
|
|
106
|
+
ColumnSpec[int](
|
|
107
|
+
header=header,
|
|
108
|
+
default=default,
|
|
109
|
+
not_null=not_null,
|
|
110
|
+
parser=parse,
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def bool_column(header: str | None = None, *, default: bool | None = None) -> Column[bool]:
|
|
116
|
+
def parse(raw: Any) -> bool:
|
|
117
|
+
if raw is None or raw == "":
|
|
118
|
+
return False
|
|
119
|
+
if isinstance(raw, bool):
|
|
120
|
+
return raw
|
|
121
|
+
s = str(raw).strip().lower()
|
|
122
|
+
if s in {"true", "t", "yes", "y", "1"}:
|
|
123
|
+
return True
|
|
124
|
+
if s in {"false", "f", "no", "n", "0"}:
|
|
125
|
+
return False
|
|
126
|
+
raise ValueError(f"Invalid boolean: {raw}")
|
|
127
|
+
|
|
128
|
+
return Column(
|
|
129
|
+
ColumnSpec[bool](
|
|
130
|
+
header=header,
|
|
131
|
+
default=default,
|
|
132
|
+
parser=parse,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def date_column(header: str | None = None, *, default: date | None = None) -> Column[date]:
|
|
138
|
+
_DATE_FORMATS: tuple[str, ...] = (
|
|
139
|
+
"%d-%b-%Y", # 01-JUN-2025 (your requirement)
|
|
140
|
+
"%d-%b-%y", # 01-JUN-25
|
|
141
|
+
"%d %b %Y", # 01 JUN 2025
|
|
142
|
+
"%d %b %y", # 01 JUN 25
|
|
143
|
+
"%d/%b/%Y", # 01/JUN/2025
|
|
144
|
+
"%Y-%m-%d", # 2025-06-01
|
|
145
|
+
"%Y/%m/%d", # 2025/06/01
|
|
146
|
+
"%m/%d/%Y", # 06/01/2025
|
|
147
|
+
"%m/%d/%y", # 06/01/25
|
|
148
|
+
"%d/%m/%Y", # 01/06/2025
|
|
149
|
+
"%d/%m/%y", # 01/06/25
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def parse(raw: Any) -> date:
|
|
153
|
+
if raw is None or raw == "":
|
|
154
|
+
raise ValueError("Date Value was empty")
|
|
155
|
+
|
|
156
|
+
if isinstance(raw, date) and not isinstance(raw, datetime):
|
|
157
|
+
return raw
|
|
158
|
+
|
|
159
|
+
if isinstance(raw, datetime):
|
|
160
|
+
return raw.date()
|
|
161
|
+
|
|
162
|
+
s = str(raw).strip()
|
|
163
|
+
if s == "":
|
|
164
|
+
raise ValueError("Date Value was empty")
|
|
165
|
+
|
|
166
|
+
# 1) ISO-8601 fast path (handles "2025-06-01" and "2025-06-01T13:45:00", etc.)
|
|
167
|
+
try:
|
|
168
|
+
dt = datetime.fromisoformat(s)
|
|
169
|
+
return dt.date()
|
|
170
|
+
except ValueError:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
# 2) Try known patterns (case-insensitive month abbreviations like JUN)
|
|
174
|
+
s_norm = s.upper()
|
|
175
|
+
|
|
176
|
+
for fmt in _DATE_FORMATS:
|
|
177
|
+
try:
|
|
178
|
+
return datetime.strptime(s_norm, fmt).date()
|
|
179
|
+
except ValueError:
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
raise ValueError(f"Invalid date value: {raw!r}")
|
|
183
|
+
|
|
184
|
+
return Column(
|
|
185
|
+
ColumnSpec[date](
|
|
186
|
+
header=header,
|
|
187
|
+
default=default,
|
|
188
|
+
parser=parse,
|
|
189
|
+
renderer=lambda d: None if d is None else d, # openpyxl handles date types
|
|
190
|
+
)
|
|
191
|
+
)
|
excel_orm/orm.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, TypeVar
|
|
6
|
+
|
|
7
|
+
from openpyxl import Workbook, load_workbook
|
|
8
|
+
from openpyxl.styles import Alignment, Font
|
|
9
|
+
from openpyxl.worksheet.worksheet import Worksheet
|
|
10
|
+
|
|
11
|
+
from .column import Column
|
|
12
|
+
|
|
13
|
+
M = TypeVar("M")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _camel_to_snake(name: str) -> str:
|
|
17
|
+
s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
|
|
18
|
+
s2 = re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1)
|
|
19
|
+
return s2.lower()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _pluralize(s: str) -> str:
|
|
23
|
+
# keep deliberately simple; can be swapped for inflect later
|
|
24
|
+
if s.endswith("s"):
|
|
25
|
+
return s
|
|
26
|
+
if s.endswith("y") and len(s) >= 2 and s[-2] not in "aeiou":
|
|
27
|
+
return s[:-1] + "ies"
|
|
28
|
+
return s + "s"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _repo_name_for_model(model: type[Any]) -> str:
|
|
32
|
+
return _pluralize(_camel_to_snake(model.__name__))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _display_name_for_model(model: type[Any]) -> str:
|
|
36
|
+
# "manufacturing_plants" -> "Manufacturing Plants"
|
|
37
|
+
return _repo_name_for_model(model).replace("_", " ").title()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_model_columns(model: type[Any]) -> list[Column[Any]]:
|
|
41
|
+
return list(getattr(model, "__columns__", []))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _normalize_header(v: Any) -> str:
|
|
45
|
+
if v is None:
|
|
46
|
+
return ""
|
|
47
|
+
return str(v).strip()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _row_is_blank(values: list[Any]) -> bool:
|
|
51
|
+
return all(_normalize_header(v) == "" for v in values)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _instantiate_model[M](model: type[M]) -> M:
|
|
55
|
+
obj = model.__new__(model)
|
|
56
|
+
obj._values = {}
|
|
57
|
+
# defaults
|
|
58
|
+
for col in _get_model_columns(model):
|
|
59
|
+
if col.name is None:
|
|
60
|
+
raise RuntimeError("Column __set_name__ did not run.")
|
|
61
|
+
obj._values[col.name] = col.spec.default
|
|
62
|
+
return obj
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Repository(list[M]):
|
|
66
|
+
def all(self) -> list[M]:
|
|
67
|
+
return list(self)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class SheetSpec:
|
|
72
|
+
name: str
|
|
73
|
+
models: list[type[Any]]
|
|
74
|
+
|
|
75
|
+
title_row: int = 1
|
|
76
|
+
header_row: int = 2
|
|
77
|
+
data_start_row: int = 3
|
|
78
|
+
|
|
79
|
+
template_table_gap: int = 2
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class PivotSheetSpec:
|
|
84
|
+
name: str
|
|
85
|
+
model: type[Any] # single model only
|
|
86
|
+
|
|
87
|
+
# field names on the model
|
|
88
|
+
pivot_field: str
|
|
89
|
+
row_fields: list[str]
|
|
90
|
+
value_field: str
|
|
91
|
+
|
|
92
|
+
# layout
|
|
93
|
+
title_row: int = 1
|
|
94
|
+
header_row: int = 2 # pivot headers
|
|
95
|
+
row_header_col: int = 1
|
|
96
|
+
data_start_row: int = 3
|
|
97
|
+
|
|
98
|
+
# template: define the pivot column values (dates) to render across the top
|
|
99
|
+
pivot_values: list[Any] | None = None # e.g., list[date]; required for generation
|
|
100
|
+
|
|
101
|
+
# optional: seed row keys (regions) on template
|
|
102
|
+
row_values: list[Any] | None = None
|
|
103
|
+
|
|
104
|
+
include_blanks: bool = False # whether to load blank cells as data points
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def data_start_col(self) -> int:
|
|
108
|
+
return len(self.row_fields) + 1
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
AnySheetSpec = PivotSheetSpec | SheetSpec
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ExcelFile:
|
|
115
|
+
def __init__(self, *, sheets: list[AnySheetSpec]):
|
|
116
|
+
self.sheets = sheets
|
|
117
|
+
|
|
118
|
+
self._repos: dict[type[Any], Repository[Any]] = {}
|
|
119
|
+
|
|
120
|
+
for sheet in sheets:
|
|
121
|
+
models = [sheet.model] if isinstance(sheet, PivotSheetSpec) else sheet.models
|
|
122
|
+
|
|
123
|
+
for model in models:
|
|
124
|
+
repo_name = _repo_name_for_model(model)
|
|
125
|
+
if hasattr(self, repo_name):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"Duplicate repo name '{repo_name}' for model {model.__name__}"
|
|
128
|
+
)
|
|
129
|
+
repo = Repository()
|
|
130
|
+
self._repos[model] = repo
|
|
131
|
+
setattr(self, repo_name, repo)
|
|
132
|
+
|
|
133
|
+
def generate_template(self, filename: str) -> None:
|
|
134
|
+
wb = Workbook()
|
|
135
|
+
default_ws = wb.active
|
|
136
|
+
if self.sheets:
|
|
137
|
+
wb.remove(default_ws)
|
|
138
|
+
|
|
139
|
+
for sheet in self.sheets:
|
|
140
|
+
ws = wb.create_sheet(title=sheet.name)
|
|
141
|
+
if isinstance(sheet, PivotSheetSpec):
|
|
142
|
+
self._write_pivot_sheet_template(ws, sheet)
|
|
143
|
+
else:
|
|
144
|
+
self._write_sheet_template(ws, sheet)
|
|
145
|
+
|
|
146
|
+
wb.save(filename)
|
|
147
|
+
|
|
148
|
+
def _write_sheet_template(self, ws: Worksheet, spec: SheetSpec) -> None:
|
|
149
|
+
current_col = 1 # 1-based index
|
|
150
|
+
|
|
151
|
+
title_font = Font(bold=True)
|
|
152
|
+
title_alignment = Alignment(horizontal="center", vertical="center")
|
|
153
|
+
|
|
154
|
+
header_font = Font(bold=True)
|
|
155
|
+
|
|
156
|
+
for model in spec.models:
|
|
157
|
+
cols = _get_model_columns(model)
|
|
158
|
+
headers = [c.spec.header or c.name for c in cols]
|
|
159
|
+
width = len(headers)
|
|
160
|
+
|
|
161
|
+
start_col = current_col
|
|
162
|
+
end_col = current_col + width - 1
|
|
163
|
+
|
|
164
|
+
# ---- merged title row ----
|
|
165
|
+
ws.merge_cells(
|
|
166
|
+
start_row=spec.title_row,
|
|
167
|
+
start_column=start_col,
|
|
168
|
+
end_row=spec.title_row,
|
|
169
|
+
end_column=end_col,
|
|
170
|
+
)
|
|
171
|
+
title_cell = ws.cell(
|
|
172
|
+
row=spec.title_row, column=start_col, value=_display_name_for_model(model)
|
|
173
|
+
)
|
|
174
|
+
title_cell.font = title_font
|
|
175
|
+
title_cell.alignment = title_alignment
|
|
176
|
+
|
|
177
|
+
for j, h in enumerate(headers):
|
|
178
|
+
c = start_col + j
|
|
179
|
+
cell = ws.cell(row=spec.header_row, column=c, value=h)
|
|
180
|
+
cell.font = header_font
|
|
181
|
+
|
|
182
|
+
col_letter = ws.cell(row=spec.header_row, column=c).column_letter
|
|
183
|
+
ws.column_dimensions[col_letter].width = max(12, min(40, len(str(h)) + 4))
|
|
184
|
+
|
|
185
|
+
current_col = end_col + 1 + spec.template_table_gap
|
|
186
|
+
|
|
187
|
+
def load_data(self, filename: str) -> None:
|
|
188
|
+
wb = load_workbook(filename=filename, data_only=True)
|
|
189
|
+
for repo in self._repos.values():
|
|
190
|
+
repo.clear()
|
|
191
|
+
|
|
192
|
+
for sheet_spec in self.sheets:
|
|
193
|
+
if sheet_spec.name not in wb.sheetnames:
|
|
194
|
+
raise ValueError(f"Workbook missing sheet '{sheet_spec.name}'")
|
|
195
|
+
ws = wb[sheet_spec.name]
|
|
196
|
+
if isinstance(sheet_spec, PivotSheetSpec):
|
|
197
|
+
self._parse_pivot_sheet(ws, sheet_spec)
|
|
198
|
+
else:
|
|
199
|
+
self._parse_sheet(ws, sheet_spec)
|
|
200
|
+
|
|
201
|
+
def _parse_sheet(self, ws: Worksheet, spec: SheetSpec) -> None:
|
|
202
|
+
for model in spec.models:
|
|
203
|
+
found = self._find_header(ws, spec, model)
|
|
204
|
+
if found is None:
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
_, start_col = found
|
|
208
|
+
cols = _get_model_columns(model)
|
|
209
|
+
width = len(cols)
|
|
210
|
+
|
|
211
|
+
repo: Repository[Any] = self._repos[model]
|
|
212
|
+
|
|
213
|
+
r = spec.data_start_row
|
|
214
|
+
while r <= ws.max_row:
|
|
215
|
+
row_vals = [ws.cell(row=r, column=start_col + j).value for j in range(width)]
|
|
216
|
+
if _row_is_blank(row_vals):
|
|
217
|
+
break
|
|
218
|
+
|
|
219
|
+
# excludes (raw-value based)
|
|
220
|
+
if any(
|
|
221
|
+
col.spec.excludes and row_vals[i] in col.spec.excludes
|
|
222
|
+
for i, col in enumerate(cols)
|
|
223
|
+
):
|
|
224
|
+
r += 1
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
obj = _instantiate_model(model)
|
|
228
|
+
for i, col in enumerate(cols):
|
|
229
|
+
raw = row_vals[i]
|
|
230
|
+
parsed = col.parse_cell(raw)
|
|
231
|
+
setattr(obj, col.name, parsed)
|
|
232
|
+
|
|
233
|
+
validate = getattr(obj, "validate", None)
|
|
234
|
+
if callable(validate):
|
|
235
|
+
validate()
|
|
236
|
+
|
|
237
|
+
repo.append(obj)
|
|
238
|
+
r += 1
|
|
239
|
+
|
|
240
|
+
def _find_header(
|
|
241
|
+
self, ws: Worksheet, spec: SheetSpec, model: type[Any]
|
|
242
|
+
) -> tuple[int, int] | None:
|
|
243
|
+
cols = _get_model_columns(model)
|
|
244
|
+
expected = [_normalize_header(c.spec.header) for c in cols]
|
|
245
|
+
if not expected:
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
r = spec.header_row
|
|
249
|
+
width = len(expected)
|
|
250
|
+
max_c = ws.max_column or 0
|
|
251
|
+
|
|
252
|
+
for start_col in range(1, max_c - width + 2):
|
|
253
|
+
actual = [
|
|
254
|
+
_normalize_header(ws.cell(row=r, column=start_col + j).value) for j in range(width)
|
|
255
|
+
]
|
|
256
|
+
if actual == expected:
|
|
257
|
+
return (r, start_col)
|
|
258
|
+
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
def _write_pivot_sheet_template(self, ws: Worksheet, spec: PivotSheetSpec) -> None:
|
|
262
|
+
if not spec.pivot_values:
|
|
263
|
+
raise ValueError("PivotSheetSpec.pivot_values is required for template generation.")
|
|
264
|
+
|
|
265
|
+
title_font = Font(bold=True)
|
|
266
|
+
title_alignment = Alignment(horizontal="center", vertical="center")
|
|
267
|
+
header_font = Font(bold=True)
|
|
268
|
+
|
|
269
|
+
# Title merged across the pivot header span
|
|
270
|
+
end_col = spec.data_start_col + len(spec.pivot_values) - 1
|
|
271
|
+
|
|
272
|
+
ws.merge_cells(
|
|
273
|
+
start_row=spec.title_row,
|
|
274
|
+
start_column=spec.row_header_col,
|
|
275
|
+
end_row=spec.title_row,
|
|
276
|
+
end_column=end_col,
|
|
277
|
+
)
|
|
278
|
+
tcell = ws.cell(spec.title_row, spec.row_header_col, _display_name_for_model(spec.model))
|
|
279
|
+
tcell.font = title_font
|
|
280
|
+
tcell.alignment = title_alignment
|
|
281
|
+
|
|
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))
|
|
289
|
+
|
|
290
|
+
# Pivot headers across the top
|
|
291
|
+
for j, pv in enumerate(spec.pivot_values):
|
|
292
|
+
c = spec.data_start_col + j
|
|
293
|
+
cell = ws.cell(spec.header_row, c, pv)
|
|
294
|
+
cell.font = header_font
|
|
295
|
+
col_letter = cell.column_letter
|
|
296
|
+
ws.column_dimensions[col_letter].width = 14
|
|
297
|
+
|
|
298
|
+
if spec.row_values:
|
|
299
|
+
for i, rv in enumerate(spec.row_values):
|
|
300
|
+
r = spec.data_start_row + i
|
|
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)
|
|
314
|
+
|
|
315
|
+
def _parse_pivot_sheet(self, ws: Worksheet, spec: PivotSheetSpec) -> None:
|
|
316
|
+
model = spec.model
|
|
317
|
+
cols = {c.name: c for c in _get_model_columns(model)} # Column descriptors by field name
|
|
318
|
+
|
|
319
|
+
# Validate fields exist
|
|
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
|
+
)
|
|
326
|
+
|
|
327
|
+
pivot_col = cols[spec.pivot_field]
|
|
328
|
+
val_col = cols[spec.value_field]
|
|
329
|
+
row_cols = [cols[f] for f in spec.row_fields]
|
|
330
|
+
|
|
331
|
+
# Determine pivot headers from sheet (or trust spec.pivot_values)
|
|
332
|
+
pivot_headers: list[Any] = []
|
|
333
|
+
j = 0
|
|
334
|
+
while True:
|
|
335
|
+
c = spec.data_start_col + j
|
|
336
|
+
raw = ws.cell(spec.header_row, c).value
|
|
337
|
+
if raw is None or str(raw).strip() == "":
|
|
338
|
+
break
|
|
339
|
+
pivot_headers.append(pivot_col.parse_cell(raw))
|
|
340
|
+
j += 1
|
|
341
|
+
|
|
342
|
+
if not pivot_headers:
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
repo: Repository[Any] = self._repos[model]
|
|
346
|
+
|
|
347
|
+
r = spec.data_start_row
|
|
348
|
+
while r <= ws.max_row:
|
|
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):
|
|
353
|
+
break
|
|
354
|
+
|
|
355
|
+
row_parts = [row_cols[i].parse_cell(raw_parts[i]) for i in range(len(row_cols))]
|
|
356
|
+
|
|
357
|
+
for j, pivot_value in enumerate(pivot_headers):
|
|
358
|
+
c = spec.data_start_col + j
|
|
359
|
+
raw_val = ws.cell(r, c).value
|
|
360
|
+
if not spec.include_blanks and (raw_val is None or raw_val == ""):
|
|
361
|
+
continue
|
|
362
|
+
|
|
363
|
+
obj = _instantiate_model(model)
|
|
364
|
+
|
|
365
|
+
for fname, parsed in zip(spec.row_fields, row_parts, strict=False):
|
|
366
|
+
setattr(obj, fname, parsed)
|
|
367
|
+
setattr(obj, spec.pivot_field, pivot_value)
|
|
368
|
+
setattr(obj, spec.value_field, val_col.parse_cell(raw_val))
|
|
369
|
+
|
|
370
|
+
validate = getattr(obj, "validate", None)
|
|
371
|
+
if callable(validate):
|
|
372
|
+
validate()
|
|
373
|
+
|
|
374
|
+
repo.append(obj)
|
|
375
|
+
|
|
376
|
+
r += 1
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: excel-orm
|
|
3
|
+
Version: 0.1.6
|
|
4
|
+
Summary: A lightweight Excel ORM for generating templates and parsing typed row models.
|
|
5
|
+
Project-URL: Homepage, https://github.com/acdelrusso/excel-orm
|
|
6
|
+
Project-URL: Repository, https://github.com/acdelrusso/excel-orm
|
|
7
|
+
Project-URL: Issues, https://github.com/acdelrusso/excel-orm/issues
|
|
8
|
+
Author: Anthony Del Russo
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: etl,excel,openpyxl,orm
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: openpyxl>=3.1.5
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# Excel ORM
|
|
22
|
+
|
|
23
|
+
A lightweight, typed “Excel ORM” for generating Excel templates and parsing Excel workbooks into Python objects using column descriptors.
|
|
24
|
+
|
|
25
|
+
This project is designed for the common enterprise pattern where you:
|
|
26
|
+
|
|
27
|
+
1. generate a structured `.xlsx` template for users,
|
|
28
|
+
2. let users fill it in,
|
|
29
|
+
3. load the workbook back into Python, producing typed objects grouped by model.
|
|
30
|
+
|
|
31
|
+
It uses `openpyxl` for reading/writing Excel files and supports:
|
|
32
|
+
|
|
33
|
+
* multiple model “tables” laid out horizontally on a worksheet, and
|
|
34
|
+
* **pivot-style sheets** (matrix input) that expand into row objects.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
* **Typed column descriptors** (`text_column`, `int_column`, `bool_column`, `date_column`)
|
|
41
|
+
* **Template generation** for standard tables:
|
|
42
|
+
|
|
43
|
+
* merged **table title cells** (pluralized model name)
|
|
44
|
+
* bold headers
|
|
45
|
+
* sensible column widths
|
|
46
|
+
* multiple tables laid out horizontally on the same sheet with a configurable gap
|
|
47
|
+
* **Template generation** for pivot sheets:
|
|
48
|
+
|
|
49
|
+
* merged title across the **row header block + pivot header span**
|
|
50
|
+
* bold row-field headers in the left block (`row_fields`)
|
|
51
|
+
* pivot column headers rendered across the top (`pivot_values`)
|
|
52
|
+
* optional seeding of row keys down the left block (`row_values`)
|
|
53
|
+
* **Workbook parsing** into model-specific repositories:
|
|
54
|
+
|
|
55
|
+
* `excel_file.cars.all()` → `list[Car]`
|
|
56
|
+
* `excel_file.manufacturing_plants.all()` → `list[ManufacturingPlant]`
|
|
57
|
+
* `excel_file.demands.all()` → `list[Demand]` (from pivot input)
|
|
58
|
+
* **Validation hooks**
|
|
59
|
+
|
|
60
|
+
* column-level `not_null`
|
|
61
|
+
* optional row exclusion rules via `excludes`
|
|
62
|
+
* optional model-level `validate()` method
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Installation
|
|
67
|
+
|
|
68
|
+
### From PyPI
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install excel-orm
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### From source (uv)
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
git clone <your-repo-url>
|
|
78
|
+
cd excel-orm
|
|
79
|
+
uv sync
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Quick Start
|
|
85
|
+
|
|
86
|
+
### 1) Define models using `Column[...]` descriptors
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from excel_orm.column import Column, text_column, int_column
|
|
90
|
+
from excel_orm import ExcelFile, SheetSpec
|
|
91
|
+
|
|
92
|
+
class Car:
|
|
93
|
+
make: Column[str] = text_column(header="Make", not_null=True)
|
|
94
|
+
model: Column[str] = text_column(header="Model", not_null=True)
|
|
95
|
+
year: Column[int] = int_column(header="Year", not_null=True)
|
|
96
|
+
|
|
97
|
+
class ManufacturingPlant:
|
|
98
|
+
name: Column[str] = text_column(header="Factory Name", not_null=True)
|
|
99
|
+
location: Column[str] = text_column(header="Location")
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### 2) Declare a sheet containing multiple models (standard tables)
|
|
103
|
+
|
|
104
|
+
Each model becomes its own table on the same worksheet.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
sheet = SheetSpec(
|
|
108
|
+
name="Cars",
|
|
109
|
+
models=[Car, ManufacturingPlant],
|
|
110
|
+
title_row=1,
|
|
111
|
+
header_row=2,
|
|
112
|
+
data_start_row=3,
|
|
113
|
+
template_table_gap=2,
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### 3) Create an `ExcelFile`, generate a template, then load data
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
excel_file = ExcelFile(sheets=[sheet])
|
|
121
|
+
|
|
122
|
+
excel_file.generate_template("car_inventory_template.xlsx")
|
|
123
|
+
# Users fill in data in Excel...
|
|
124
|
+
excel_file.load_data("car_inventory_data.xlsx")
|
|
125
|
+
|
|
126
|
+
cars = excel_file.cars.all()
|
|
127
|
+
plants = excel_file.manufacturing_plants.all()
|
|
128
|
+
|
|
129
|
+
print(cars[0].make, cars[0].year)
|
|
130
|
+
print(plants[0].name, plants[0].location)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Pivot Sheets
|
|
136
|
+
|
|
137
|
+
Pivot sheets are useful when users prefer matrix-style input (e.g., values by multiple row keys × time), but your application wants row objects.
|
|
138
|
+
|
|
139
|
+
### Pivot Layout Summary
|
|
140
|
+
|
|
141
|
+
For a pivot sheet:
|
|
142
|
+
|
|
143
|
+
* `row_fields` define the **leftmost columns** (starting at `row_header_col`)
|
|
144
|
+
* `pivot_values` are written across the top row (`header_row`) starting at `data_start_col`
|
|
145
|
+
* `data_start_col` is **computed** as:
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
data_start_col = len(row_fields) + 1
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Parsing behavior:
|
|
152
|
+
|
|
153
|
+
* pivot headers are read from `header_row` across the top until the first blank header cell
|
|
154
|
+
* parsing stops when the **row header block** (all `row_fields` columns) is blank for a row
|
|
155
|
+
* each non-blank pivot cell emits one object (unless `include_blanks=True`)
|
|
156
|
+
|
|
157
|
+
### 1) Define a “row” model
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
from datetime import date
|
|
161
|
+
from excel_orm.column import Column, text_column, date_column, int_column
|
|
162
|
+
|
|
163
|
+
class Demand:
|
|
164
|
+
region: Column[str] = text_column(header="Region", not_null=True)
|
|
165
|
+
product: Column[str] = text_column(header="Product", not_null=True)
|
|
166
|
+
dt: Column[date] = date_column(header="Date", not_null=True)
|
|
167
|
+
value: Column[int] = int_column(header="Value", not_null=True)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### 2) Create a `PivotSheetSpec` (multi-row-fields)
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
from datetime import date
|
|
174
|
+
from excel_orm import ExcelFile, PivotSheetSpec
|
|
175
|
+
|
|
176
|
+
spec = PivotSheetSpec(
|
|
177
|
+
name="Demand",
|
|
178
|
+
model=Demand,
|
|
179
|
+
pivot_field="dt",
|
|
180
|
+
row_fields=["region", "product"],
|
|
181
|
+
value_field="value",
|
|
182
|
+
pivot_values=[date(2025, 6, 1), date(2025, 7, 1), date(2025, 8, 1)],
|
|
183
|
+
# optional: seed row keys (must match row_fields arity)
|
|
184
|
+
row_values=[
|
|
185
|
+
("NA", "SKU-1"),
|
|
186
|
+
("NA", "SKU-2"),
|
|
187
|
+
("EU", "SKU-1"),
|
|
188
|
+
],
|
|
189
|
+
)
|
|
190
|
+
xf = ExcelFile(sheets=[spec])
|
|
191
|
+
xf.generate_template("demand_template.xlsx")
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### 3) Load a filled pivot sheet
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
xf.load_data("demand_filled.xlsx")
|
|
198
|
+
rows = xf.demands.all()
|
|
199
|
+
|
|
200
|
+
# each element is a Demand row:
|
|
201
|
+
# Demand(region="NA", product="SKU-1", dt=date(2025, 6, 1), value=10)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### `row_values` seeding rules
|
|
205
|
+
|
|
206
|
+
* If `len(row_fields) == 1`, each `row_values` element may be a **single scalar** value (backward compatible).
|
|
207
|
+
* If `len(row_fields) > 1`, each `row_values` element must be a **tuple/list with length == len(row_fields)**.
|
|
208
|
+
|
|
209
|
+
* Otherwise template generation raises `ValueError`.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## How It Works
|
|
214
|
+
|
|
215
|
+
### Repositories
|
|
216
|
+
|
|
217
|
+
For each model you register, `ExcelFile` creates a repository attribute on the instance using a snake_case pluralized name:
|
|
218
|
+
|
|
219
|
+
* `Car` → `excel_file.cars`
|
|
220
|
+
* `ManufacturingPlant` → `excel_file.manufacturing_plants`
|
|
221
|
+
* `Demand` → `excel_file.demands`
|
|
222
|
+
|
|
223
|
+
Repositories are list-like containers with an `all()` helper:
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
cars = excel_file.cars.all() # list[Car]
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Multi-table Sheets (standard tables)
|
|
230
|
+
|
|
231
|
+
A single worksheet can host multiple model tables. During template generation:
|
|
232
|
+
|
|
233
|
+
* a merged title cell is written above each table (pluralized class name in title case)
|
|
234
|
+
* headers appear under the title
|
|
235
|
+
* data rows begin at `data_start_row`
|
|
236
|
+
* tables are placed horizontally with `template_table_gap` blank columns between them
|
|
237
|
+
|
|
238
|
+
During parsing:
|
|
239
|
+
|
|
240
|
+
* the library locates each model table by matching the expected header sequence
|
|
241
|
+
* it reads contiguous rows until a blank row is encountered
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
## Column Types
|
|
246
|
+
|
|
247
|
+
### Text
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
from excel_orm.column import Column, text_column
|
|
251
|
+
|
|
252
|
+
class Example:
|
|
253
|
+
name: Column[str] = text_column(header="Name", not_null=True, strip=True)
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
* `None` parses to `""` (empty string)
|
|
257
|
+
* `strip=True` trims whitespace
|
|
258
|
+
|
|
259
|
+
### Integer
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
from excel_orm.column import Column, int_column
|
|
263
|
+
|
|
264
|
+
class Example:
|
|
265
|
+
qty: Column[int] = int_column(header="Qty", not_null=True)
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
* `None` or `""` parses to `0`
|
|
269
|
+
|
|
270
|
+
### Boolean
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
from excel_orm.column import Column, bool_column
|
|
274
|
+
|
|
275
|
+
class Example:
|
|
276
|
+
active: Column[bool] = bool_column(header="Active")
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Accepted values include:
|
|
280
|
+
|
|
281
|
+
* True: `true, t, yes, y, 1` (case-insensitive)
|
|
282
|
+
* False: `false, f, no, n, 0`
|
|
283
|
+
* `None` / empty parses to `False`
|
|
284
|
+
|
|
285
|
+
Invalid values raise `ValueError`.
|
|
286
|
+
|
|
287
|
+
### Date
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
from datetime import date
|
|
291
|
+
from excel_orm.column import Column, date_column
|
|
292
|
+
|
|
293
|
+
class Example:
|
|
294
|
+
start_date: Column[date] = date_column(header="Start Date")
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
The date parser supports:
|
|
298
|
+
|
|
299
|
+
* Excel-native `datetime`/`date` values from `openpyxl`
|
|
300
|
+
* ISO strings like `2025-06-01` and `2025-06-01T13:45:00`
|
|
301
|
+
* common business formats including `01-JUN-2025`
|
|
302
|
+
|
|
303
|
+
Invalid/empty values raise `ValueError`.
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## Validation
|
|
308
|
+
|
|
309
|
+
### Column-level: `not_null`
|
|
310
|
+
|
|
311
|
+
```python
|
|
312
|
+
class Car:
|
|
313
|
+
make: Column[str] = text_column(header="Make", not_null=True)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
If a `not_null=True` column parses to `None` or `""`, a `ValueError` is raised.
|
|
317
|
+
|
|
318
|
+
### Row exclusion: `excludes`
|
|
319
|
+
|
|
320
|
+
If you set `excludes`, rows matching those raw values in that column will be skipped during parsing.
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
status: Column[str] = text_column(header="Status")
|
|
324
|
+
status.spec.excludes = {"IGNORE", "SKIP"}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Model-level: `validate()`
|
|
328
|
+
|
|
329
|
+
If your model defines a `validate(self)` method, it is called after a row is parsed.
|
|
330
|
+
|
|
331
|
+
```python
|
|
332
|
+
class Car:
|
|
333
|
+
make: Column[str] = text_column(header="Make", not_null=True)
|
|
334
|
+
year: Column[int] = int_column(header="Year", not_null=True)
|
|
335
|
+
|
|
336
|
+
def validate(self) -> None:
|
|
337
|
+
if self.year < 1886:
|
|
338
|
+
raise ValueError("Invalid car year")
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## Development
|
|
344
|
+
|
|
345
|
+
### Run tests
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
uv run pytest
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Lint/format
|
|
352
|
+
|
|
353
|
+
If you use Ruff:
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
uv run ruff check .
|
|
357
|
+
uv run ruff format .
|
|
358
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
excel_orm/__init__.py,sha256=L8BOQiL1OGb7legxP59h1iBcJc9-oF7YNu5D-Bmr_VQ,392
|
|
2
|
+
excel_orm/base.py,sha256=P1jcbO93ohqQqQHLN-9V66pCRd9FdU_v91_0S9NKZ90,503
|
|
3
|
+
excel_orm/column.py,sha256=jburc06vvCvmPV1Xa0UQ3FQHqk7Darz9yJDyJZhT1gs,5443
|
|
4
|
+
excel_orm/orm.py,sha256=8vlJCdCxt-o13hq88JQnGkVPG3nLsd27Ooe7iAy_IkQ,12504
|
|
5
|
+
excel_orm-0.1.6.dist-info/METADATA,sha256=_2pZUEcCkl8BQNWmEGDbTk4lGVBwMtFwUy6oYh3Bgro,9311
|
|
6
|
+
excel_orm-0.1.6.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
7
|
+
excel_orm-0.1.6.dist-info/RECORD,,
|