amplify-excel-migrator 1.1.5__py3-none-any.whl → 1.2.15__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.
Files changed (45) hide show
  1. amplify_excel_migrator/__init__.py +17 -0
  2. amplify_excel_migrator/auth/__init__.py +6 -0
  3. amplify_excel_migrator/auth/cognito_auth.py +306 -0
  4. amplify_excel_migrator/auth/provider.py +42 -0
  5. amplify_excel_migrator/cli/__init__.py +5 -0
  6. amplify_excel_migrator/cli/commands.py +165 -0
  7. amplify_excel_migrator/client.py +47 -0
  8. amplify_excel_migrator/core/__init__.py +5 -0
  9. amplify_excel_migrator/core/config.py +98 -0
  10. amplify_excel_migrator/data/__init__.py +7 -0
  11. amplify_excel_migrator/data/excel_reader.py +23 -0
  12. amplify_excel_migrator/data/transformer.py +119 -0
  13. amplify_excel_migrator/data/validator.py +48 -0
  14. amplify_excel_migrator/graphql/__init__.py +8 -0
  15. amplify_excel_migrator/graphql/client.py +137 -0
  16. amplify_excel_migrator/graphql/executor.py +405 -0
  17. amplify_excel_migrator/graphql/mutation_builder.py +80 -0
  18. amplify_excel_migrator/graphql/query_builder.py +194 -0
  19. amplify_excel_migrator/migration/__init__.py +8 -0
  20. amplify_excel_migrator/migration/batch_uploader.py +23 -0
  21. amplify_excel_migrator/migration/failure_tracker.py +92 -0
  22. amplify_excel_migrator/migration/orchestrator.py +143 -0
  23. amplify_excel_migrator/migration/progress_reporter.py +57 -0
  24. amplify_excel_migrator/schema/__init__.py +6 -0
  25. model_field_parser.py → amplify_excel_migrator/schema/field_parser.py +100 -22
  26. amplify_excel_migrator/schema/introspector.py +95 -0
  27. {amplify_excel_migrator-1.1.5.dist-info → amplify_excel_migrator-1.2.15.dist-info}/METADATA +121 -26
  28. amplify_excel_migrator-1.2.15.dist-info/RECORD +40 -0
  29. amplify_excel_migrator-1.2.15.dist-info/entry_points.txt +2 -0
  30. amplify_excel_migrator-1.2.15.dist-info/top_level.txt +2 -0
  31. tests/__init__.py +1 -0
  32. tests/test_cli_commands.py +292 -0
  33. tests/test_client.py +187 -0
  34. tests/test_cognito_auth.py +363 -0
  35. tests/test_config_manager.py +347 -0
  36. tests/test_field_parser.py +615 -0
  37. tests/test_mutation_builder.py +391 -0
  38. tests/test_query_builder.py +384 -0
  39. amplify_client.py +0 -941
  40. amplify_excel_migrator-1.1.5.dist-info/RECORD +0 -9
  41. amplify_excel_migrator-1.1.5.dist-info/entry_points.txt +0 -2
  42. amplify_excel_migrator-1.1.5.dist-info/top_level.txt +0 -3
  43. migrator.py +0 -437
  44. {amplify_excel_migrator-1.1.5.dist-info → amplify_excel_migrator-1.2.15.dist-info}/WHEEL +0 -0
  45. {amplify_excel_migrator-1.1.5.dist-info → amplify_excel_migrator-1.2.15.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,98 @@
1
+ """Configuration management for Amplify Excel Migrator."""
2
+
3
+ import json
4
+ import logging
5
+ from getpass import getpass
6
+ from pathlib import Path
7
+ from typing import Dict, Any, Optional
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class ConfigManager:
13
+ """Manages configuration loading, saving, and user prompts."""
14
+
15
+ DEFAULT_CONFIG_DIR = Path.home() / ".amplify-migrator"
16
+ DEFAULT_CONFIG_FILE = "config.json"
17
+
18
+ SENSITIVE_KEYS = {"password", "ADMIN_PASSWORD"}
19
+
20
+ def __init__(self, config_path: Optional[str] = None):
21
+ if config_path:
22
+ self.config_path = Path(config_path)
23
+ else:
24
+ self.config_path = self.DEFAULT_CONFIG_DIR / self.DEFAULT_CONFIG_FILE
25
+
26
+ self._config: Dict[str, Any] = {}
27
+
28
+ def load(self) -> Dict[str, Any]:
29
+ if not self.config_path.exists():
30
+ logger.debug(f"Config file not found at {self.config_path}")
31
+ return {}
32
+
33
+ try:
34
+ with open(self.config_path, "r") as f:
35
+ self._config = json.load(f)
36
+ logger.debug(f"Loaded configuration from {self.config_path}")
37
+ return self._config
38
+ except Exception as e:
39
+ logger.warning(f"Failed to load cached config: {e}")
40
+ return {}
41
+
42
+ def save(self, config: Dict[str, Any]) -> None:
43
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
44
+
45
+ sanitized_config = {k: v for k, v in config.items() if k not in self.SENSITIVE_KEYS}
46
+
47
+ with open(self.config_path, "w") as f:
48
+ json.dump(sanitized_config, f, indent=2)
49
+
50
+ self._config = sanitized_config
51
+ logger.info(f"✅ Configuration saved to {self.config_path}")
52
+
53
+ def get(self, key: str, default: Any = None) -> Any:
54
+ if not self._config:
55
+ self.load()
56
+ return self._config.get(key, default)
57
+
58
+ def set(self, key: str, value: Any) -> None:
59
+ if not self._config:
60
+ self.load()
61
+ self._config[key] = value
62
+
63
+ def update(self, updates: Dict[str, Any]) -> None:
64
+ if not self._config:
65
+ self.load()
66
+ self._config.update(updates)
67
+ self.save(self._config)
68
+
69
+ def prompt_for_value(self, prompt_text: str, default: str = "", secret: bool = False) -> str:
70
+ if default:
71
+ display_prompt = f"{prompt_text} [{default}]: "
72
+ else:
73
+ display_prompt = f"{prompt_text}: "
74
+
75
+ if secret:
76
+ value = getpass(display_prompt)
77
+ else:
78
+ value = input(display_prompt)
79
+
80
+ return value.strip() if value.strip() else default
81
+
82
+ def get_or_prompt(self, key: str, prompt_text: str, default: str = "", secret: bool = False) -> str:
83
+ if not self._config:
84
+ self.load()
85
+
86
+ if key in self._config:
87
+ return self._config[key]
88
+
89
+ return self.prompt_for_value(prompt_text, default, secret)
90
+
91
+ def exists(self) -> bool:
92
+ return self.config_path.exists()
93
+
94
+ def clear(self) -> None:
95
+ self._config = {}
96
+ if self.config_path.exists():
97
+ self.config_path.unlink()
98
+ logger.info(f"Configuration cleared from {self.config_path}")
@@ -0,0 +1,7 @@
1
+ """Data processing components for Excel migration."""
2
+
3
+ from .excel_reader import ExcelReader
4
+ from .transformer import DataTransformer
5
+ from .validator import RecordValidator
6
+
7
+ __all__ = ["ExcelReader", "DataTransformer", "RecordValidator"]
@@ -0,0 +1,23 @@
1
+ """Excel file reading functionality."""
2
+
3
+ import logging
4
+ from typing import Dict, Any
5
+
6
+ import pandas as pd
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class ExcelReader:
12
+ def __init__(self, file_path: str):
13
+ self.file_path = file_path
14
+
15
+ def read_all_sheets(self) -> Dict[str, pd.DataFrame]:
16
+ logger.info(f"Reading Excel file: {self.file_path}")
17
+ all_sheets = pd.read_excel(self.file_path, sheet_name=None)
18
+ logger.info(f"Loaded {len(all_sheets)} sheets from Excel")
19
+ return all_sheets
20
+
21
+ def read_sheet(self, sheet_name: str) -> pd.DataFrame:
22
+ logger.info(f"Reading sheet '{sheet_name}' from Excel file: {self.file_path}")
23
+ return pd.read_excel(self.file_path, sheet_name=sheet_name)
@@ -0,0 +1,119 @@
1
+ """Data transformation from Excel rows to Amplify records."""
2
+
3
+ import logging
4
+ import re
5
+ from typing import Dict, Any, Optional, List, Tuple
6
+
7
+ import pandas as pd
8
+
9
+ from amplify_excel_migrator.schema import FieldParser
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class DataTransformer:
15
+ def __init__(self, field_parser: FieldParser):
16
+ self.field_parser = field_parser
17
+
18
+ def transform_rows_to_records(
19
+ self,
20
+ df: pd.DataFrame,
21
+ parsed_model_structure: Dict[str, Any],
22
+ primary_field: str,
23
+ fk_lookup_cache: Dict[str, Dict[str, str]],
24
+ ) -> Tuple[List[Dict], Dict[str, Dict], List[Dict]]:
25
+ records = []
26
+ row_dict_by_primary = {}
27
+ failed_rows = []
28
+ row_count = 0
29
+
30
+ for row_tuple in df.itertuples(index=False, name="Row"):
31
+ row_count += 1
32
+ row_dict = {col: getattr(row_tuple, col) for col in df.columns}
33
+ primary_field_value = row_dict.get(primary_field, f"Row {row_count}")
34
+
35
+ row_dict_by_primary[str(primary_field_value)] = row_dict.copy()
36
+
37
+ try:
38
+ record = self.transform_row_to_record(row_dict, parsed_model_structure, fk_lookup_cache)
39
+ if record:
40
+ records.append(record)
41
+ except Exception as e:
42
+ error_msg = str(e)
43
+ logger.error(f"Error transforming row {row_count} ({primary_field}={primary_field_value}): {error_msg}")
44
+ failed_rows.append(
45
+ {
46
+ "primary_field": primary_field,
47
+ "primary_field_value": primary_field_value,
48
+ "error": f"Parsing error: {error_msg}",
49
+ "original_row": row_dict,
50
+ }
51
+ )
52
+
53
+ logger.info(f"Prepared {len(records)} records for upload")
54
+
55
+ return records, row_dict_by_primary, failed_rows
56
+
57
+ def transform_row_to_record(
58
+ self, row_dict: Dict, parsed_model_structure: Dict[str, Any], fk_lookup_cache: Dict[str, Dict[str, str]]
59
+ ) -> Optional[Dict]:
60
+ model_record = {}
61
+
62
+ for field in parsed_model_structure["fields"]:
63
+ input_value = self.parse_input(row_dict, field, fk_lookup_cache)
64
+ if input_value is not None:
65
+ model_record[field["name"]] = input_value
66
+
67
+ return model_record
68
+
69
+ def parse_input(
70
+ self,
71
+ row_dict: Dict,
72
+ field: Dict[str, Any],
73
+ fk_lookup_cache: Dict[str, Dict[str, str]],
74
+ ) -> Any:
75
+ field_name = field["name"][:-2] if field["is_id"] else field["name"]
76
+
77
+ if field_name not in row_dict or pd.isna(row_dict[field_name]):
78
+ if field["is_required"]:
79
+ raise ValueError(f"Required field '{field_name}' is missing")
80
+ return None
81
+
82
+ value = self.field_parser.clean_input(row_dict[field_name])
83
+
84
+ if field["is_id"]:
85
+ return self._resolve_foreign_key(field, value, fk_lookup_cache)
86
+ elif field["is_list"] and field["is_scalar"]:
87
+ return self.field_parser.parse_scalar_array(field, field_name, row_dict[field_name])
88
+ else:
89
+ return self.field_parser.parse_field_input(field, field_name, value)
90
+
91
+ @staticmethod
92
+ def _resolve_foreign_key(
93
+ field: Dict[str, Any], value: Any, fk_lookup_cache: Dict[str, Dict[str, str]]
94
+ ) -> Optional[str]:
95
+ if "related_model" in field:
96
+ related_model = field["related_model"]
97
+ else:
98
+ related_model = (temp := field["name"][:-2])[0].upper() + temp[1:]
99
+
100
+ if related_model in fk_lookup_cache:
101
+ lookup_dict = fk_lookup_cache[related_model]["lookup"]
102
+ record_id = lookup_dict.get(str(value))
103
+
104
+ if record_id:
105
+ return record_id
106
+ elif field["is_required"]:
107
+ raise ValueError(f"{related_model}: {value} does not exist")
108
+ return None
109
+ else:
110
+ if field["is_required"]:
111
+ raise ValueError(f"No pre-fetched data for {related_model}, cannot resolve FK for required field")
112
+ logger.warning(f"No pre-fetched data for {related_model}, skipping optional FK field")
113
+ return None
114
+
115
+ @staticmethod
116
+ def to_camel_case(s: str) -> str:
117
+ s_with_spaces = re.sub(r"(?<!^)(?=[A-Z])", " ", s)
118
+ parts = re.split(r"[\s_\-]+", s_with_spaces.strip())
119
+ return parts[0].lower() + "".join(word.capitalize() for word in parts[1:])
@@ -0,0 +1,48 @@
1
+ """Record validation functionality."""
2
+
3
+ import logging
4
+ from typing import Dict, Any, List
5
+
6
+ import pandas as pd
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class RecordValidator:
12
+ @staticmethod
13
+ def validate_required_fields(row_dict: Dict, parsed_model_structure: Dict[str, Any]) -> List[str]:
14
+ errors = []
15
+
16
+ for field in parsed_model_structure["fields"]:
17
+ if not field["is_required"]:
18
+ continue
19
+
20
+ field_name = field["name"][:-2] if field["is_id"] else field["name"]
21
+
22
+ if field_name not in row_dict or pd.isna(row_dict[field_name]):
23
+ errors.append(f"Required field '{field_name}' is missing")
24
+
25
+ return errors
26
+
27
+ @staticmethod
28
+ def validate_foreign_key(
29
+ field: Dict[str, Any], value: Any, fk_lookup_cache: Dict[str, Dict[str, str]]
30
+ ) -> List[str]:
31
+ errors = []
32
+
33
+ if "related_model" in field:
34
+ related_model = field["related_model"]
35
+ else:
36
+ related_model = (temp := field["name"][:-2])[0].upper() + temp[1:]
37
+
38
+ if related_model not in fk_lookup_cache:
39
+ if field["is_required"]:
40
+ errors.append(f"No pre-fetched data for required foreign key {related_model}")
41
+ return errors
42
+
43
+ lookup_dict = fk_lookup_cache[related_model]["lookup"]
44
+ if str(value) not in lookup_dict:
45
+ if field["is_required"]:
46
+ errors.append(f"{related_model}: {value} does not exist")
47
+
48
+ return errors
@@ -0,0 +1,8 @@
1
+ """GraphQL module for query and mutation building."""
2
+
3
+ from .query_builder import QueryBuilder
4
+ from .mutation_builder import MutationBuilder
5
+ from .client import GraphQLClient, AuthenticationError, GraphQLError
6
+ from .executor import QueryExecutor
7
+
8
+ __all__ = ["QueryBuilder", "MutationBuilder", "GraphQLClient", "AuthenticationError", "GraphQLError", "QueryExecutor"]
@@ -0,0 +1,137 @@
1
+ """GraphQL HTTP client for making requests to GraphQL APIs."""
2
+
3
+ import logging
4
+ import sys
5
+ from typing import Dict, Any, Optional
6
+
7
+ import aiohttp
8
+ import requests
9
+
10
+ from amplify_excel_migrator.auth import AuthenticationProvider
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class AuthenticationError(Exception):
16
+ """Raised when authentication is required but not completed"""
17
+
18
+ pass
19
+
20
+
21
+ class GraphQLError(Exception):
22
+ """Raised when GraphQL query returns errors"""
23
+
24
+ pass
25
+
26
+
27
+ class GraphQLClient:
28
+ def __init__(self, api_endpoint: str, auth_provider: Optional[AuthenticationProvider] = None):
29
+ self.api_endpoint = api_endpoint
30
+ self.auth_provider = auth_provider
31
+
32
+ def request(
33
+ self, query: str, variables: Optional[Dict[str, Any]] = None, context: Optional[str] = None
34
+ ) -> Optional[Dict[str, Any]]:
35
+ if not self.auth_provider or not self.auth_provider.is_authenticated():
36
+ raise AuthenticationError("Not authenticated. Call authenticate() on the auth provider first.")
37
+
38
+ id_token = self.auth_provider.get_id_token()
39
+ headers = {"Authorization": id_token, "Content-Type": "application/json"}
40
+
41
+ payload = {"query": query, "variables": variables or {}}
42
+
43
+ context_msg = f" [{context}]" if context else ""
44
+
45
+ try:
46
+ response = requests.post(self.api_endpoint, headers=headers, json=payload)
47
+
48
+ if response.status_code == 200:
49
+ result = response.json()
50
+
51
+ if "errors" in result:
52
+ raise GraphQLError(f"GraphQL errors{context_msg}: {result['errors']}")
53
+
54
+ return result
55
+ else:
56
+ logger.error(f"HTTP Error {response.status_code}{context_msg}: {response.text}")
57
+ return None
58
+
59
+ except requests.exceptions.ConnectionError:
60
+ logger.error(
61
+ f"Connection error{context_msg}: Unable to connect to API endpoint. Check your internet connection or the API endpoint URL."
62
+ )
63
+ sys.exit(1)
64
+
65
+ except requests.exceptions.Timeout as e:
66
+ logger.error(f"Request timeout{context_msg}: {e}")
67
+ return None
68
+
69
+ except requests.exceptions.HTTPError as e:
70
+ logger.error(f"HTTP error{context_msg}: {e}")
71
+ return None
72
+
73
+ except GraphQLError as e:
74
+ logger.error(str(e))
75
+ return None
76
+
77
+ except requests.exceptions.RequestException as e:
78
+ logger.error(f"Request error{context_msg}: {e}")
79
+ return None
80
+
81
+ async def request_async(
82
+ self,
83
+ session: aiohttp.ClientSession,
84
+ query: str,
85
+ variables: Optional[Dict[str, Any]] = None,
86
+ context: Optional[str] = None,
87
+ ) -> Optional[Dict[str, Any]]:
88
+ if not self.auth_provider or not self.auth_provider.is_authenticated():
89
+ raise AuthenticationError("Not authenticated. Call authenticate() on the auth provider first.")
90
+
91
+ id_token = self.auth_provider.get_id_token()
92
+ headers = {"Authorization": id_token, "Content-Type": "application/json"}
93
+
94
+ payload = {"query": query, "variables": variables or {}}
95
+
96
+ context_msg = f" [{context}]" if context else ""
97
+
98
+ try:
99
+ async with session.post(self.api_endpoint, headers=headers, json=payload) as response:
100
+ if response.status == 200:
101
+ result = await response.json()
102
+
103
+ if "errors" in result:
104
+ raise GraphQLError(f"GraphQL errors{context_msg}: {result['errors']}")
105
+
106
+ return result
107
+ else:
108
+ text = await response.text()
109
+ error_msg = f"HTTP Error {response.status}{context_msg}: {text}"
110
+ logger.error(error_msg)
111
+ raise aiohttp.ClientError(error_msg)
112
+
113
+ except aiohttp.ServerTimeoutError as e:
114
+ error_msg = f"Request timeout{context_msg}: {e}"
115
+ logger.error(error_msg)
116
+ raise aiohttp.ServerTimeoutError(error_msg)
117
+
118
+ except aiohttp.ClientConnectionError as e:
119
+ error_msg = f"Connection error{context_msg}: Unable to connect to API endpoint. {e}"
120
+ logger.error(error_msg)
121
+ raise aiohttp.ClientConnectionError(error_msg)
122
+
123
+ except aiohttp.ClientResponseError as e:
124
+ error_msg = f"HTTP response error{context_msg}: {e}"
125
+ logger.error(error_msg)
126
+ raise aiohttp.ClientResponseError(
127
+ request_info=e.request_info, history=e.history, status=e.status, message=error_msg
128
+ )
129
+
130
+ except GraphQLError as e:
131
+ logger.error(str(e))
132
+ raise
133
+
134
+ except aiohttp.ClientError as e:
135
+ error_msg = f"Client error{context_msg}: {e}"
136
+ logger.error(error_msg)
137
+ raise aiohttp.ClientError(error_msg)