excel2api 1.0.0__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.
- excel2api/__init__.py +1 -0
- excel2api/__main__.py +4 -0
- excel2api/api.py +98 -0
- excel2api/cli.py +255 -0
- excel2api/config.py +30 -0
- excel2api/converter.py +18 -0
- excel2api/dependencies.py +120 -0
- excel2api/doctor.py +79 -0
- excel2api/errors.py +17 -0
- excel2api/reader.py +36 -0
- excel2api/report.py +45 -0
- excel2api/schema.py +69 -0
- excel2api/sync.py +154 -0
- excel2api/validator.py +160 -0
- excel2api-1.0.0.dist-info/METADATA +398 -0
- excel2api-1.0.0.dist-info/RECORD +19 -0
- excel2api-1.0.0.dist-info/WHEEL +4 -0
- excel2api-1.0.0.dist-info/entry_points.txt +2 -0
- excel2api-1.0.0.dist-info/licenses/LICENSE +16 -0
excel2api/report.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from openpyxl import Workbook
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def extract_path(data: Any, path: str, default=None):
|
|
8
|
+
"""Extract a dotted path from a JSON-like response."""
|
|
9
|
+
if not path:
|
|
10
|
+
return default
|
|
11
|
+
current = data
|
|
12
|
+
for part in path.split('.'):
|
|
13
|
+
if isinstance(current, dict) and part in current:
|
|
14
|
+
current = current[part]
|
|
15
|
+
else:
|
|
16
|
+
return default
|
|
17
|
+
return current
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def write_error_report(path: str | Path, errors: list[Any]):
|
|
21
|
+
workbook = Workbook()
|
|
22
|
+
sheet = workbook.active
|
|
23
|
+
sheet.title = "Errors"
|
|
24
|
+
sheet.append(["Sheet", "Row", "Field", "Message"])
|
|
25
|
+
for error in errors:
|
|
26
|
+
sheet.append([getattr(error, "sheet", ""), error.row, error.field, error.message])
|
|
27
|
+
workbook.save(path)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def write_sync_report(path: str | Path, results: list[Any], response_mapping: dict[str, str] | None = None):
|
|
31
|
+
workbook = Workbook()
|
|
32
|
+
sheet = workbook.active
|
|
33
|
+
sheet.title = "Sync Results"
|
|
34
|
+
|
|
35
|
+
mapping = response_mapping or {}
|
|
36
|
+
headers = ["Sheet", "Row", "Operation", "Success", "HTTP Status", "Error"] + list(mapping.keys())
|
|
37
|
+
sheet.append(headers)
|
|
38
|
+
|
|
39
|
+
for result in results:
|
|
40
|
+
values = [getattr(result, "sheet", ""), result.row, result.operation, result.success, result.status_code, result.error]
|
|
41
|
+
for output_name, response_path in mapping.items():
|
|
42
|
+
values.append(extract_path(result.response, response_path, ""))
|
|
43
|
+
sheet.append(values)
|
|
44
|
+
|
|
45
|
+
workbook.save(path)
|
excel2api/schema.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import yaml
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
SUPPORTED_TYPES = {
|
|
7
|
+
"string", "integer", "float", "decimal",
|
|
8
|
+
"boolean", "date", "datetime", "email", "phone"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Field:
|
|
14
|
+
name: str
|
|
15
|
+
column: str
|
|
16
|
+
type: str = "string"
|
|
17
|
+
required: bool = False
|
|
18
|
+
nullable: bool = True
|
|
19
|
+
default: object = None
|
|
20
|
+
options: list[object] = field(default_factory=list)
|
|
21
|
+
min_length: int | None = None
|
|
22
|
+
max_length: int | None = None
|
|
23
|
+
minimum: float | None = None
|
|
24
|
+
maximum: float | None = None
|
|
25
|
+
regex: str | None = None
|
|
26
|
+
transform: list[str] = field(default_factory=list)
|
|
27
|
+
include: bool = True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_config(path: str | Path) -> dict:
|
|
31
|
+
with open(path, "r", encoding="utf-8") as file:
|
|
32
|
+
data = yaml.safe_load(file) or {}
|
|
33
|
+
|
|
34
|
+
fields = []
|
|
35
|
+
for name, config in data.get("fields", {}).items():
|
|
36
|
+
config = config or {}
|
|
37
|
+
field_type = config.get("type", "string")
|
|
38
|
+
|
|
39
|
+
if field_type not in SUPPORTED_TYPES:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"Unsupported type '{field_type}' for field '{name}'. "
|
|
42
|
+
f"Supported types: {', '.join(sorted(SUPPORTED_TYPES))}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
fields.append(Field(
|
|
46
|
+
name=name,
|
|
47
|
+
column=config.get("column", name),
|
|
48
|
+
type=field_type,
|
|
49
|
+
required=config.get("required", False),
|
|
50
|
+
nullable=config.get("nullable", True),
|
|
51
|
+
default=config.get("default"),
|
|
52
|
+
options=config.get("options", []),
|
|
53
|
+
min_length=config.get("min_length"),
|
|
54
|
+
max_length=config.get("max_length"),
|
|
55
|
+
minimum=config.get("min"),
|
|
56
|
+
maximum=config.get("max"),
|
|
57
|
+
regex=config.get("regex"),
|
|
58
|
+
transform=config.get("transform", []),
|
|
59
|
+
include=config.get("include", True),
|
|
60
|
+
))
|
|
61
|
+
|
|
62
|
+
if not fields:
|
|
63
|
+
raise ValueError("Schema must contain at least one field")
|
|
64
|
+
|
|
65
|
+
return {"fields": fields, "response": data.get("response", {}) or {}}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_schema(path: str | Path) -> list[Field]:
|
|
69
|
+
return load_config(path)["fields"]
|
excel2api/sync.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from .api import APIClient, APIResult
|
|
6
|
+
from .converter import convert_file
|
|
7
|
+
from .reader import read_file
|
|
8
|
+
from .schema import load_config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SUPPORTED_OPERATIONS = {"CREATE", "UPDATE", "PATCH", "DELETE", "UPSERT"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _save_checkpoint(path: str | Path, completed_rows: set[int]):
|
|
15
|
+
path = Path(path)
|
|
16
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
17
|
+
tmp.write_text(json.dumps({"completed_rows": sorted(completed_rows)}, indent=2), encoding="utf-8")
|
|
18
|
+
tmp.replace(path)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_checkpoint(path: str | Path) -> set[int]:
|
|
22
|
+
path = Path(path)
|
|
23
|
+
if not path.exists():
|
|
24
|
+
return set()
|
|
25
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
26
|
+
return {int(row) for row in data.get("completed_rows", [])}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sync_file(
|
|
30
|
+
input_path: str,
|
|
31
|
+
schema_path: str,
|
|
32
|
+
api_url: str,
|
|
33
|
+
operation_field: str = "operation",
|
|
34
|
+
identifier_field: str = "id",
|
|
35
|
+
token: str | None = None,
|
|
36
|
+
dry_run: bool = False,
|
|
37
|
+
timeout: int = 30,
|
|
38
|
+
retries: int = 0,
|
|
39
|
+
retry_create: bool = False,
|
|
40
|
+
headers: dict[str, str] | None = None,
|
|
41
|
+
rate_limit: float = 0.0,
|
|
42
|
+
batch_size: int = 0,
|
|
43
|
+
checkpoint: str | None = None,
|
|
44
|
+
resume: bool = False,
|
|
45
|
+
stop_on_error: bool = False,
|
|
46
|
+
response_mapping: dict[str, str] | None = None,
|
|
47
|
+
endpoints: dict[str, object] | None = None,
|
|
48
|
+
sheet_name: str | int | None = None,
|
|
49
|
+
record_overrides: dict[int, dict] | None = None,
|
|
50
|
+
):
|
|
51
|
+
records, errors = convert_file(input_path, schema_path, sheet_name=sheet_name)
|
|
52
|
+
if errors:
|
|
53
|
+
return [], errors
|
|
54
|
+
|
|
55
|
+
rows = read_file(input_path, sheet_name=sheet_name)
|
|
56
|
+
record_overrides = record_overrides or {}
|
|
57
|
+
if record_overrides:
|
|
58
|
+
# Re-validate with dependency-provided values before API execution.
|
|
59
|
+
from .schema import load_schema
|
|
60
|
+
from .validator import validate_and_convert
|
|
61
|
+
fields = load_schema(schema_path)
|
|
62
|
+
records = []
|
|
63
|
+
errors = []
|
|
64
|
+
for row_number, raw_row in enumerate(rows, start=2):
|
|
65
|
+
merged = dict(raw_row)
|
|
66
|
+
for field_name, value in record_overrides.get(row_number, {}).items():
|
|
67
|
+
for field in fields:
|
|
68
|
+
if field.name == field_name:
|
|
69
|
+
merged[field.column] = value
|
|
70
|
+
break
|
|
71
|
+
record, row_errors = validate_and_convert(merged, fields, row_number)
|
|
72
|
+
if row_errors:
|
|
73
|
+
errors.extend(row_errors)
|
|
74
|
+
else:
|
|
75
|
+
records.append(record)
|
|
76
|
+
if errors:
|
|
77
|
+
return [], errors
|
|
78
|
+
|
|
79
|
+
client = APIClient(
|
|
80
|
+
api_url,
|
|
81
|
+
token=token,
|
|
82
|
+
timeout=timeout,
|
|
83
|
+
retries=retries,
|
|
84
|
+
retry_create=retry_create,
|
|
85
|
+
headers=headers,
|
|
86
|
+
)
|
|
87
|
+
results = []
|
|
88
|
+
completed_rows = _load_checkpoint(checkpoint) if (resume and checkpoint) else set()
|
|
89
|
+
processed_since_checkpoint = 0
|
|
90
|
+
|
|
91
|
+
for index, (row, record) in enumerate(zip(rows, records), start=2):
|
|
92
|
+
if index in completed_rows:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
operation = str(row.get(operation_field) or "CREATE").upper()
|
|
96
|
+
identifier = row.get(identifier_field)
|
|
97
|
+
|
|
98
|
+
if operation not in SUPPORTED_OPERATIONS:
|
|
99
|
+
result = APIResult(row=index, operation=operation, success=False,
|
|
100
|
+
error=f"unsupported operation: {operation}", record=record)
|
|
101
|
+
results.append(result)
|
|
102
|
+
if stop_on_error:
|
|
103
|
+
break
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
if dry_run:
|
|
107
|
+
result = APIResult(row=index, operation=operation, success=True,
|
|
108
|
+
response={"dry_run": True, "record": record, "id": identifier}, record=record)
|
|
109
|
+
else:
|
|
110
|
+
try:
|
|
111
|
+
if operation == "UPSERT":
|
|
112
|
+
if identifier is None:
|
|
113
|
+
raise ValueError("UPSERT requires an identifier")
|
|
114
|
+
response = client.execute("PATCH", record, identifier, endpoints=endpoints)
|
|
115
|
+
else:
|
|
116
|
+
response = client.execute(operation, record, identifier, endpoints=endpoints)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
response_body = response.json()
|
|
120
|
+
except ValueError:
|
|
121
|
+
response_body = response.text
|
|
122
|
+
|
|
123
|
+
result = APIResult(
|
|
124
|
+
row=index,
|
|
125
|
+
operation=operation,
|
|
126
|
+
success=response.ok,
|
|
127
|
+
status_code=response.status_code,
|
|
128
|
+
response=response_body,
|
|
129
|
+
error=None if response.ok else f"HTTP {response.status_code}",
|
|
130
|
+
record=record,
|
|
131
|
+
)
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
result = APIResult(row=index, operation=operation, success=False, error=str(exc), record=record)
|
|
134
|
+
|
|
135
|
+
results.append(result)
|
|
136
|
+
processed_since_checkpoint += 1
|
|
137
|
+
|
|
138
|
+
if result.success:
|
|
139
|
+
completed_rows.add(index)
|
|
140
|
+
|
|
141
|
+
if checkpoint and (processed_since_checkpoint >= batch_size if batch_size > 0 else True):
|
|
142
|
+
_save_checkpoint(checkpoint, completed_rows)
|
|
143
|
+
processed_since_checkpoint = 0
|
|
144
|
+
|
|
145
|
+
if stop_on_error and not result.success:
|
|
146
|
+
break
|
|
147
|
+
|
|
148
|
+
if rate_limit > 0 and index < len(rows) + 1:
|
|
149
|
+
time.sleep(rate_limit)
|
|
150
|
+
|
|
151
|
+
if checkpoint:
|
|
152
|
+
_save_checkpoint(checkpoint, completed_rows)
|
|
153
|
+
|
|
154
|
+
return results, []
|
excel2api/validator.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from datetime import date, datetime
|
|
3
|
+
from decimal import Decimal, InvalidOperation
|
|
4
|
+
from .errors import ValidationError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
8
|
+
PHONE_RE = re.compile(r"^\+?[0-9][0-9\s-]{6,19}$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def is_empty(value):
|
|
12
|
+
return value is None or (isinstance(value, str) and not value.strip())
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def convert(value, field_type):
|
|
16
|
+
if value is None:
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
if field_type == "string":
|
|
20
|
+
return str(value).strip()
|
|
21
|
+
|
|
22
|
+
if field_type == "integer":
|
|
23
|
+
return int(value)
|
|
24
|
+
|
|
25
|
+
if field_type == "float":
|
|
26
|
+
return float(value)
|
|
27
|
+
|
|
28
|
+
if field_type == "decimal":
|
|
29
|
+
return float(Decimal(str(value)))
|
|
30
|
+
|
|
31
|
+
if field_type == "boolean":
|
|
32
|
+
if isinstance(value, bool):
|
|
33
|
+
return value
|
|
34
|
+
normalized = str(value).strip().lower()
|
|
35
|
+
if normalized in {"true", "yes", "1"}:
|
|
36
|
+
return True
|
|
37
|
+
if normalized in {"false", "no", "0"}:
|
|
38
|
+
return False
|
|
39
|
+
raise ValueError("must be a boolean")
|
|
40
|
+
|
|
41
|
+
if field_type == "date":
|
|
42
|
+
if isinstance(value, datetime):
|
|
43
|
+
return value.strftime("%Y-%m-%d")
|
|
44
|
+
if isinstance(value, date):
|
|
45
|
+
return value.strftime("%Y-%m-%d")
|
|
46
|
+
return datetime.fromisoformat(str(value)).date().isoformat()
|
|
47
|
+
|
|
48
|
+
if field_type == "datetime":
|
|
49
|
+
if isinstance(value, datetime):
|
|
50
|
+
return value.isoformat()
|
|
51
|
+
return datetime.fromisoformat(str(value)).isoformat()
|
|
52
|
+
|
|
53
|
+
if field_type in {"email", "phone"}:
|
|
54
|
+
return str(value).strip()
|
|
55
|
+
|
|
56
|
+
raise ValueError(f"unsupported type: {field_type}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def apply_transforms(value, transforms):
|
|
60
|
+
for transform in transforms:
|
|
61
|
+
if value is None:
|
|
62
|
+
break
|
|
63
|
+
if transform == "strip":
|
|
64
|
+
value = str(value).strip()
|
|
65
|
+
elif transform == "uppercase":
|
|
66
|
+
value = str(value).upper()
|
|
67
|
+
elif transform == "lowercase":
|
|
68
|
+
value = str(value).lower()
|
|
69
|
+
else:
|
|
70
|
+
raise ValueError(f"unknown transform: {transform}")
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate_and_convert(row: dict, fields: list[object], row_number: int):
|
|
75
|
+
output = {}
|
|
76
|
+
errors = []
|
|
77
|
+
|
|
78
|
+
for field in fields:
|
|
79
|
+
value = row.get(field.column)
|
|
80
|
+
|
|
81
|
+
if is_empty(value):
|
|
82
|
+
if field.required:
|
|
83
|
+
errors.append(ValidationError(
|
|
84
|
+
row_number, field.name, "required field is missing"
|
|
85
|
+
))
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
if field.default is not None:
|
|
89
|
+
value = field.default
|
|
90
|
+
elif field.nullable:
|
|
91
|
+
output[field.name] = None
|
|
92
|
+
continue
|
|
93
|
+
else:
|
|
94
|
+
errors.append(ValidationError(
|
|
95
|
+
row_number, field.name, "null value is not allowed"
|
|
96
|
+
))
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
value = apply_transforms(value, field.transform)
|
|
101
|
+
value = convert(value, field.type)
|
|
102
|
+
except (ValueError, TypeError, InvalidOperation, OverflowError) as exc:
|
|
103
|
+
errors.append(ValidationError(row_number, field.name, str(exc)))
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
if field.type == "email" and not EMAIL_RE.match(value):
|
|
107
|
+
errors.append(ValidationError(
|
|
108
|
+
row_number, field.name, "invalid email address"
|
|
109
|
+
))
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if field.type == "phone" and not PHONE_RE.match(value):
|
|
113
|
+
errors.append(ValidationError(
|
|
114
|
+
row_number, field.name, "invalid phone number"
|
|
115
|
+
))
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
if field.min_length is not None and len(value) < field.min_length:
|
|
119
|
+
errors.append(ValidationError(
|
|
120
|
+
row_number, field.name,
|
|
121
|
+
f"length must be at least {field.min_length}"
|
|
122
|
+
))
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
if field.max_length is not None and len(value) > field.max_length:
|
|
126
|
+
errors.append(ValidationError(
|
|
127
|
+
row_number, field.name,
|
|
128
|
+
f"length must be at most {field.max_length}"
|
|
129
|
+
))
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
if field.minimum is not None and value < field.minimum:
|
|
133
|
+
errors.append(ValidationError(
|
|
134
|
+
row_number, field.name, f"value must be >= {field.minimum}"
|
|
135
|
+
))
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
if field.maximum is not None and value > field.maximum:
|
|
139
|
+
errors.append(ValidationError(
|
|
140
|
+
row_number, field.name, f"value must be <= {field.maximum}"
|
|
141
|
+
))
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
if field.options and value not in field.options:
|
|
145
|
+
errors.append(ValidationError(
|
|
146
|
+
row_number, field.name,
|
|
147
|
+
f"must be one of: {', '.join(map(str, field.options))}"
|
|
148
|
+
))
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
if field.regex and not re.fullmatch(field.regex, str(value)):
|
|
152
|
+
errors.append(ValidationError(
|
|
153
|
+
row_number, field.name, "does not match required pattern"
|
|
154
|
+
))
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
if field.include:
|
|
158
|
+
output[field.name] = value
|
|
159
|
+
|
|
160
|
+
return output, errors
|