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/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