amazon-ads-mcp 0.2.7__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 (82) hide show
  1. amazon_ads_mcp/__init__.py +11 -0
  2. amazon_ads_mcp/auth/__init__.py +33 -0
  3. amazon_ads_mcp/auth/base.py +211 -0
  4. amazon_ads_mcp/auth/hooks.py +172 -0
  5. amazon_ads_mcp/auth/manager.py +791 -0
  6. amazon_ads_mcp/auth/oauth_state_store.py +277 -0
  7. amazon_ads_mcp/auth/providers/__init__.py +14 -0
  8. amazon_ads_mcp/auth/providers/direct.py +393 -0
  9. amazon_ads_mcp/auth/providers/example_auth0.py.example +216 -0
  10. amazon_ads_mcp/auth/providers/openbridge.py +512 -0
  11. amazon_ads_mcp/auth/registry.py +146 -0
  12. amazon_ads_mcp/auth/secure_token_store.py +297 -0
  13. amazon_ads_mcp/auth/token_store.py +723 -0
  14. amazon_ads_mcp/config/__init__.py +5 -0
  15. amazon_ads_mcp/config/sampling.py +111 -0
  16. amazon_ads_mcp/config/settings.py +366 -0
  17. amazon_ads_mcp/exceptions.py +314 -0
  18. amazon_ads_mcp/middleware/__init__.py +11 -0
  19. amazon_ads_mcp/middleware/authentication.py +1474 -0
  20. amazon_ads_mcp/middleware/caching.py +177 -0
  21. amazon_ads_mcp/middleware/oauth.py +175 -0
  22. amazon_ads_mcp/middleware/sampling.py +112 -0
  23. amazon_ads_mcp/models/__init__.py +320 -0
  24. amazon_ads_mcp/models/amc_models.py +837 -0
  25. amazon_ads_mcp/models/api_responses.py +847 -0
  26. amazon_ads_mcp/models/base_models.py +215 -0
  27. amazon_ads_mcp/models/builtin_responses.py +496 -0
  28. amazon_ads_mcp/models/dsp_models.py +556 -0
  29. amazon_ads_mcp/models/stores_brands.py +610 -0
  30. amazon_ads_mcp/server/__init__.py +6 -0
  31. amazon_ads_mcp/server/__main__.py +6 -0
  32. amazon_ads_mcp/server/builtin_prompts.py +269 -0
  33. amazon_ads_mcp/server/builtin_tools.py +962 -0
  34. amazon_ads_mcp/server/file_routes.py +547 -0
  35. amazon_ads_mcp/server/html_templates.py +149 -0
  36. amazon_ads_mcp/server/mcp_server.py +327 -0
  37. amazon_ads_mcp/server/openapi_utils.py +158 -0
  38. amazon_ads_mcp/server/sampling_handler.py +251 -0
  39. amazon_ads_mcp/server/server_builder.py +751 -0
  40. amazon_ads_mcp/server/sidecar_loader.py +178 -0
  41. amazon_ads_mcp/server/transform_executor.py +827 -0
  42. amazon_ads_mcp/tools/__init__.py +22 -0
  43. amazon_ads_mcp/tools/cache_management.py +105 -0
  44. amazon_ads_mcp/tools/download_tools.py +267 -0
  45. amazon_ads_mcp/tools/identity.py +236 -0
  46. amazon_ads_mcp/tools/oauth.py +598 -0
  47. amazon_ads_mcp/tools/profile.py +150 -0
  48. amazon_ads_mcp/tools/profile_listing.py +285 -0
  49. amazon_ads_mcp/tools/region.py +320 -0
  50. amazon_ads_mcp/tools/region_identity.py +175 -0
  51. amazon_ads_mcp/utils/__init__.py +6 -0
  52. amazon_ads_mcp/utils/async_compat.py +215 -0
  53. amazon_ads_mcp/utils/errors.py +452 -0
  54. amazon_ads_mcp/utils/export_content_type_resolver.py +249 -0
  55. amazon_ads_mcp/utils/export_download_handler.py +579 -0
  56. amazon_ads_mcp/utils/header_resolver.py +81 -0
  57. amazon_ads_mcp/utils/http/__init__.py +56 -0
  58. amazon_ads_mcp/utils/http/circuit_breaker.py +127 -0
  59. amazon_ads_mcp/utils/http/client_manager.py +329 -0
  60. amazon_ads_mcp/utils/http/request.py +207 -0
  61. amazon_ads_mcp/utils/http/resilience.py +512 -0
  62. amazon_ads_mcp/utils/http/resilient_client.py +195 -0
  63. amazon_ads_mcp/utils/http/retry.py +76 -0
  64. amazon_ads_mcp/utils/http_client.py +873 -0
  65. amazon_ads_mcp/utils/media/__init__.py +21 -0
  66. amazon_ads_mcp/utils/media/negotiator.py +243 -0
  67. amazon_ads_mcp/utils/media/types.py +199 -0
  68. amazon_ads_mcp/utils/openapi/__init__.py +16 -0
  69. amazon_ads_mcp/utils/openapi/json.py +55 -0
  70. amazon_ads_mcp/utils/openapi/loader.py +263 -0
  71. amazon_ads_mcp/utils/openapi/refs.py +46 -0
  72. amazon_ads_mcp/utils/region_config.py +200 -0
  73. amazon_ads_mcp/utils/response_wrapper.py +171 -0
  74. amazon_ads_mcp/utils/sampling_helpers.py +156 -0
  75. amazon_ads_mcp/utils/sampling_wrapper.py +173 -0
  76. amazon_ads_mcp/utils/security.py +630 -0
  77. amazon_ads_mcp/utils/tool_naming.py +137 -0
  78. amazon_ads_mcp-0.2.7.dist-info/METADATA +664 -0
  79. amazon_ads_mcp-0.2.7.dist-info/RECORD +82 -0
  80. amazon_ads_mcp-0.2.7.dist-info/WHEEL +4 -0
  81. amazon_ads_mcp-0.2.7.dist-info/entry_points.txt +3 -0
  82. amazon_ads_mcp-0.2.7.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,263 @@
1
+ """Dynamic OpenAPI specification loader for Amazon Ads MCP server.
2
+
3
+ This module provides functionality for loading, merging, and managing
4
+ OpenAPI specifications for the Amazon Ads API, enabling dynamic
5
+ API integration.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class OpenAPISpecLoader:
17
+ """Load and merge OpenAPI specifications dynamically.
18
+
19
+ Handles loading of multiple OpenAPI specifications from a manifest
20
+ file and merging them into a single comprehensive specification
21
+ for the Amazon Ads API.
22
+
23
+ :param base_path: Base path for OpenAPI specifications
24
+ :type base_path: Path
25
+ :param manifest_path: Path to the manifest file
26
+ :type manifest_path: Path
27
+ :param specs: Dictionary of loaded specifications
28
+ :type specs: Dict[str, Any]
29
+ :param merged_spec: Cached merged specification
30
+ :type merged_spec: Optional[Dict[str, Any]]
31
+ """
32
+
33
+ def __init__(self, base_path: Path = Path("openapi/amazon_ads_apis")):
34
+ self.base_path = base_path
35
+ self.manifest_path = base_path / "manifest.json"
36
+ self.specs = {}
37
+ self.merged_spec = None
38
+
39
+ def load_all_specs(self) -> Dict[str, Any]:
40
+ """Load all OpenAPI specifications from the manifest.
41
+
42
+ Loads all OpenAPI specifications listed in the manifest file.
43
+ Falls back to legacy specifications if manifest is not found.
44
+
45
+ :return: Dictionary of loaded specifications
46
+ :rtype: Dict[str, Any]
47
+ """
48
+ if not self.manifest_path.exists():
49
+ logger.warning(f"Manifest not found at {self.manifest_path}")
50
+ return self._load_legacy_specs()
51
+
52
+ with open(self.manifest_path) as f:
53
+ manifest = json.load(f)
54
+
55
+ logger.info(f"Loading {manifest['successful']} OpenAPI specifications")
56
+
57
+ # Load each successful spec
58
+ for spec_info in manifest["specs"]:
59
+ if spec_info["status"] == "success":
60
+ # Fix path - the manifest stores paths relative to openapi/
61
+ spec_path = self.base_path.parent / spec_info["file"]
62
+ if spec_path.exists():
63
+ try:
64
+ with open(spec_path) as f:
65
+ spec = json.load(f)
66
+
67
+ category = spec_info["category"]
68
+ resource = spec_info["resource"]
69
+ key = f"{category}/{resource}"
70
+
71
+ self.specs[key] = {"spec": spec, "info": spec_info}
72
+ logger.debug(f"Loaded {key}")
73
+ except Exception as e:
74
+ logger.error(f"Failed to load {spec_path}: {e}")
75
+
76
+ logger.info(f"Successfully loaded {len(self.specs)} specifications")
77
+ return self.specs
78
+
79
+ def _load_legacy_specs(self) -> Dict[str, Any]:
80
+ """Load legacy manually downloaded specs as fallback.
81
+
82
+ Loads legacy OpenAPI specifications as a fallback when
83
+ the manifest file is not available.
84
+
85
+ :return: Dictionary of loaded legacy specifications
86
+ :rtype: Dict[str, Any]
87
+ """
88
+ legacy_specs = {
89
+ "test_accounts": "openapi/test_account.json",
90
+ "profiles": "openapi/profiles.json",
91
+ "exports": "openapi/exports.json",
92
+ "ads_api": "openapi/amazon_ads_all.json",
93
+ }
94
+
95
+ for name, path in legacy_specs.items():
96
+ spec_path = Path(path)
97
+ if spec_path.exists():
98
+ try:
99
+ with open(spec_path) as f:
100
+ spec = json.load(f)
101
+ self.specs[name] = {
102
+ "spec": spec,
103
+ "info": {"resource": name, "category": "legacy"},
104
+ }
105
+ logger.info(f"Loaded legacy spec: {name}")
106
+ except Exception as e:
107
+ logger.error(f"Failed to load legacy spec {path}: {e}")
108
+
109
+ return self.specs
110
+
111
+ def merge_specs(self) -> Dict[str, Any]:
112
+ """Merge all loaded specs into a single OpenAPI specification.
113
+
114
+ Combines all loaded OpenAPI specifications into a single
115
+ comprehensive specification for the Amazon Ads API.
116
+
117
+ :return: Merged OpenAPI specification
118
+ :rtype: Dict[str, Any]
119
+ """
120
+ if self.merged_spec:
121
+ return self.merged_spec
122
+
123
+ # Base structure
124
+ merged = {
125
+ "openapi": "3.0.1",
126
+ "info": {
127
+ "title": "Amazon Ads API - Complete",
128
+ "version": "1.0",
129
+ "description": "Comprehensive Amazon Ads API including all services",
130
+ },
131
+ "servers": [
132
+ {
133
+ "url": "https://advertising-api.amazon.com",
134
+ "description": "North America",
135
+ },
136
+ {
137
+ "url": "https://advertising-api-eu.amazon.com",
138
+ "description": "Europe",
139
+ },
140
+ {
141
+ "url": "https://advertising-api-fe.amazon.com",
142
+ "description": "Far East",
143
+ },
144
+ ],
145
+ "paths": {},
146
+ "components": {
147
+ "schemas": {},
148
+ "securitySchemes": {
149
+ "bearerAuth": {
150
+ "type": "http",
151
+ "scheme": "bearer",
152
+ }
153
+ },
154
+ "parameters": {},
155
+ "responses": {},
156
+ },
157
+ }
158
+
159
+ # Merge paths and components from each spec
160
+ for key, spec_data in self.specs.items():
161
+ spec = spec_data["spec"]
162
+
163
+ # Merge paths
164
+ if "paths" in spec:
165
+ for path, path_item in spec["paths"].items():
166
+ # Process each path item to remove auth headers
167
+ processed_path_item = self._remove_auth_headers(path_item)
168
+
169
+ if path not in merged["paths"]:
170
+ merged["paths"][path] = processed_path_item
171
+ else:
172
+ # Merge operations if path already exists
173
+ for method, operation in processed_path_item.items():
174
+ if method not in merged["paths"][path]:
175
+ merged["paths"][path][method] = operation
176
+
177
+ # Merge components
178
+ if "components" in spec:
179
+ for component_type in [
180
+ "schemas",
181
+ "parameters",
182
+ "responses",
183
+ "examples",
184
+ "requestBodies",
185
+ "headers",
186
+ ]:
187
+ if component_type in spec["components"]:
188
+ if component_type not in merged["components"]:
189
+ merged["components"][component_type] = {}
190
+
191
+ # Add prefix to avoid conflicts
192
+ prefix = key.replace("/", "_").replace(" ", "_")
193
+ for name, component in spec["components"][
194
+ component_type
195
+ ].items():
196
+ # Use original name if no conflict, otherwise prefix it
197
+ final_name = name
198
+ if name in merged["components"][component_type]:
199
+ final_name = f"{prefix}_{name}"
200
+ merged["components"][component_type][
201
+ final_name
202
+ ] = component
203
+
204
+ self.merged_spec = merged
205
+ logger.info(
206
+ f"Merged {len(merged['paths'])} paths from {len(self.specs)} specifications"
207
+ )
208
+
209
+ return merged
210
+
211
+ def get_categories(self) -> Dict[str, List[str]]:
212
+ """Get all categories and their resources."""
213
+ categories = {}
214
+ for key, spec_data in self.specs.items():
215
+ info = spec_data["info"]
216
+ category = info.get("category", "unknown")
217
+ resource = info.get("resource", key)
218
+
219
+ if category not in categories:
220
+ categories[category] = []
221
+ categories[category].append(resource)
222
+
223
+ return categories
224
+
225
+ def _remove_auth_headers(
226
+ self, path_item: Dict[str, Any]
227
+ ) -> Dict[str, Any]:
228
+ """Remove authentication headers from path item parameters."""
229
+ auth_headers = {
230
+ "Amazon-Advertising-API-ClientId",
231
+ "Amazon-Advertising-API-Scope",
232
+ "Authorization",
233
+ }
234
+
235
+ processed = {}
236
+ for method, operation in path_item.items():
237
+ if method in ["get", "post", "put", "delete", "patch"]:
238
+ processed[method] = operation.copy()
239
+
240
+ # Remove auth headers from parameters
241
+ if "parameters" in operation:
242
+ processed[method]["parameters"] = [
243
+ param
244
+ for param in operation["parameters"]
245
+ if not (
246
+ param.get("in") == "header"
247
+ and param.get("name") in auth_headers
248
+ )
249
+ ]
250
+
251
+ return processed
252
+
253
+ def save_merged_spec(self, output_path: Path):
254
+ """Save the merged specification to a file."""
255
+ merged = self.merge_specs()
256
+ with open(output_path, "w") as f:
257
+ json.dump(merged, f, indent=2)
258
+ logger.info(f"Saved merged spec to {output_path}")
259
+
260
+ def load_and_merge_specs(self) -> Dict[str, Any]:
261
+ """Load all specs and return the merged specification."""
262
+ self.load_all_specs()
263
+ return self.merge_specs()
@@ -0,0 +1,46 @@
1
+ """Reference utilities for OpenAPI specs.
2
+
3
+ This module provides utilities for handling OpenAPI specification references
4
+ ($ref pointers). It includes functionality to dereference JSON Schema
5
+ references within OpenAPI documents, allowing access to the actual schema
6
+ definitions rather than just reference pointers.
7
+ """
8
+
9
+ from typing import Any, Optional
10
+
11
+
12
+ def deref(spec: dict, obj: Optional[dict]) -> Optional[dict]:
13
+ """Dereference a $ref pointer in an OpenAPI spec.
14
+
15
+ This function resolves JSON Schema references ($ref) within OpenAPI
16
+ specifications. It follows the reference path from the root of the
17
+ specification to retrieve the actual schema object that the reference
18
+ points to.
19
+
20
+ The function handles relative references that start with "#/" and
21
+ traverses the specification structure using the path components.
22
+ If the reference cannot be resolved (invalid path, missing keys,
23
+ or non-dict target), it returns the original object unchanged.
24
+
25
+ :param spec: The complete OpenAPI specification dictionary
26
+ :type spec: dict
27
+ :param obj: Object that may contain a $ref pointer to dereference
28
+ :type obj: Optional[dict]
29
+ :return: Dereferenced object if successful, original object if
30
+ dereferencing fails or is not needed
31
+ :rtype: Optional[dict]
32
+ """
33
+ if not isinstance(obj, dict):
34
+ return obj
35
+ ref = obj.get("$ref")
36
+ if not isinstance(ref, str) or not ref.startswith("#/"):
37
+ return obj
38
+ cur: Any = spec
39
+ for part in ref.lstrip("#/").split("/"):
40
+ if not isinstance(cur, dict) or part not in cur:
41
+ return obj
42
+ cur = cur[part]
43
+ return cur if isinstance(cur, dict) else obj
44
+
45
+
46
+ __all__ = ["deref"]
@@ -0,0 +1,200 @@
1
+ """Centralized region configuration for Amazon Ads API.
2
+
3
+ This module provides a single source of truth for all region-specific
4
+ configurations including API endpoints, OAuth endpoints, and region metadata.
5
+ All region mappings should be defined here to avoid duplication.
6
+ """
7
+
8
+ from typing import Dict, Literal, Optional
9
+ from urllib.parse import urlparse
10
+
11
+ # Type alias for region codes
12
+ RegionCode = Literal["na", "eu", "fe"]
13
+
14
+
15
+ class RegionConfig:
16
+ """Centralized configuration for Amazon Ads API regions.
17
+
18
+ This class provides a single source of truth for all region-specific
19
+ endpoints and configurations. All components should use this class
20
+ instead of defining their own region mappings.
21
+ """
22
+
23
+ # Single source of truth for API endpoints
24
+ API_ENDPOINTS: Dict[RegionCode, str] = {
25
+ "na": "https://advertising-api.amazon.com",
26
+ "eu": "https://advertising-api-eu.amazon.com",
27
+ "fe": "https://advertising-api-fe.amazon.com",
28
+ }
29
+
30
+ # Single source of truth for OAuth token endpoints
31
+ OAUTH_ENDPOINTS: Dict[RegionCode, str] = {
32
+ "na": "https://api.amazon.com/auth/o2/token",
33
+ "eu": "https://api.amazon.co.uk/auth/o2/token",
34
+ "fe": "https://api.amazon.co.jp/auth/o2/token",
35
+ }
36
+
37
+ # Host mappings for URL parsing (without https://)
38
+ API_HOSTS: Dict[RegionCode, str] = {
39
+ "na": "advertising-api.amazon.com",
40
+ "eu": "advertising-api-eu.amazon.com",
41
+ "fe": "advertising-api-fe.amazon.com",
42
+ }
43
+
44
+ # Region display names for UI/logging
45
+ REGION_NAMES: Dict[RegionCode, str] = {
46
+ "na": "North America",
47
+ "eu": "Europe",
48
+ "fe": "Far East",
49
+ }
50
+
51
+ # Default region if none specified
52
+ DEFAULT_REGION: RegionCode = "na"
53
+
54
+ @classmethod
55
+ def get_api_endpoint(cls, region: Optional[str] = None) -> str:
56
+ """Get API endpoint URL for the specified region.
57
+
58
+ :param region: Region code (na, eu, fe) or None for default
59
+ :type region: Optional[str]
60
+ :return: Full API endpoint URL
61
+ :rtype: str
62
+
63
+ Example:
64
+ >>> RegionConfig.get_api_endpoint("eu")
65
+ 'https://advertising-api-eu.amazon.com'
66
+ """
67
+ if region is None:
68
+ region = cls.DEFAULT_REGION
69
+ region = region.lower()
70
+ return cls.API_ENDPOINTS.get(region, cls.API_ENDPOINTS[cls.DEFAULT_REGION])
71
+
72
+ @classmethod
73
+ def get_oauth_endpoint(cls, region: Optional[str] = None) -> str:
74
+ """Get OAuth token endpoint URL for the specified region.
75
+
76
+ :param region: Region code (na, eu, fe) or None for default
77
+ :type region: Optional[str]
78
+ :return: Full OAuth token endpoint URL
79
+ :rtype: str
80
+
81
+ Example:
82
+ >>> RegionConfig.get_oauth_endpoint("fe")
83
+ 'https://api.amazon.co.jp/auth/o2/token'
84
+ """
85
+ if region is None:
86
+ region = cls.DEFAULT_REGION
87
+ region = region.lower()
88
+ return cls.OAUTH_ENDPOINTS.get(region, cls.OAUTH_ENDPOINTS[cls.DEFAULT_REGION])
89
+
90
+ @classmethod
91
+ def get_api_host(cls, region: Optional[str] = None) -> str:
92
+ """Get API host (without protocol) for the specified region.
93
+
94
+ :param region: Region code (na, eu, fe) or None for default
95
+ :type region: Optional[str]
96
+ :return: API host without https://
97
+ :rtype: str
98
+
99
+ Example:
100
+ >>> RegionConfig.get_api_host("eu")
101
+ 'advertising-api-eu.amazon.com'
102
+ """
103
+ if region is None:
104
+ region = cls.DEFAULT_REGION
105
+ region = region.lower()
106
+ return cls.API_HOSTS.get(region, cls.API_HOSTS[cls.DEFAULT_REGION])
107
+
108
+ @classmethod
109
+ def get_region_from_url(cls, url: str) -> str:
110
+ """Extract region code from an API or OAuth URL.
111
+
112
+ :param url: URL to parse for region
113
+ :type url: str
114
+ :return: Region code (na, eu, fe)
115
+ :rtype: str
116
+
117
+ Example:
118
+ >>> RegionConfig.get_region_from_url("https://advertising-api-eu.amazon.com/v2/profiles")
119
+ 'eu'
120
+ """
121
+ if not url:
122
+ return cls.DEFAULT_REGION
123
+
124
+ # Parse URL and extract hostname only
125
+ parsed = urlparse(url)
126
+ hostname = (parsed.hostname or parsed.netloc or "").lower()
127
+
128
+ if not hostname:
129
+ return cls.DEFAULT_REGION
130
+
131
+ # Check for EU indicators in hostname only
132
+ if "-eu." in hostname or hostname.endswith(".co.uk") or "api-eu" in hostname:
133
+ return "eu"
134
+
135
+ # Check for FE indicators in hostname only
136
+ if "-fe." in hostname or hostname.endswith(".co.jp") or "api-fe" in hostname:
137
+ return "fe"
138
+
139
+ # Default to NA
140
+ return "na"
141
+
142
+ @classmethod
143
+ def is_valid_region(cls, region: str) -> bool:
144
+ """Check if a region code is valid.
145
+
146
+ :param region: Region code to validate
147
+ :type region: str
148
+ :return: True if valid, False otherwise
149
+ :rtype: bool
150
+
151
+ Example:
152
+ >>> RegionConfig.is_valid_region("eu")
153
+ True
154
+ >>> RegionConfig.is_valid_region("invalid")
155
+ False
156
+ """
157
+ if not region:
158
+ return False
159
+ return region.lower() in cls.API_ENDPOINTS
160
+
161
+ @classmethod
162
+ def get_region_name(cls, region: Optional[str] = None) -> str:
163
+ """Get display name for a region.
164
+
165
+ :param region: Region code (na, eu, fe) or None for default
166
+ :type region: Optional[str]
167
+ :return: Human-readable region name
168
+ :rtype: str
169
+
170
+ Example:
171
+ >>> RegionConfig.get_region_name("na")
172
+ 'North America'
173
+ """
174
+ if region is None:
175
+ region = cls.DEFAULT_REGION
176
+ region = region.lower()
177
+ return cls.REGION_NAMES.get(region, cls.REGION_NAMES[cls.DEFAULT_REGION])
178
+
179
+ @classmethod
180
+ def get_all_regions(cls) -> Dict[str, Dict[str, str]]:
181
+ """Get all region configurations.
182
+
183
+ :return: Dictionary of all regions with their configurations
184
+ :rtype: Dict[str, Dict[str, str]]
185
+
186
+ Example:
187
+ >>> regions = RegionConfig.get_all_regions()
188
+ >>> regions["na"]["api_endpoint"]
189
+ 'https://advertising-api.amazon.com'
190
+ """
191
+ result = {}
192
+ for region in cls.API_ENDPOINTS.keys():
193
+ result[region] = {
194
+ "code": region,
195
+ "name": cls.get_region_name(region),
196
+ "api_endpoint": cls.get_api_endpoint(region),
197
+ "oauth_endpoint": cls.get_oauth_endpoint(region),
198
+ "api_host": cls.get_api_host(region),
199
+ }
200
+ return result
@@ -0,0 +1,171 @@
1
+ """Response wrapper to avoid manipulating private httpx attributes."""
2
+
3
+ import json
4
+ import logging
5
+ from typing import Any, Optional
6
+
7
+ import httpx
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class ResponseWrapper:
13
+ """
14
+ Wrapper for HTTP responses that avoids accessing private attributes.
15
+
16
+ This wrapper provides a clean interface for response manipulation
17
+ without relying on httpx internals.
18
+ """
19
+
20
+ def __init__(self, response: httpx.Response):
21
+ """
22
+ Initialize the response wrapper.
23
+
24
+ Args:
25
+ response: The original httpx response
26
+ """
27
+ self.original_response = response
28
+ self._modified_content: Optional[bytes] = None
29
+ self._modified_json: Optional[Any] = None
30
+
31
+ @property
32
+ def status_code(self) -> int:
33
+ """Get the status code."""
34
+ return self.original_response.status_code
35
+
36
+ @property
37
+ def headers(self) -> httpx.Headers:
38
+ """Get the response headers."""
39
+ return self.original_response.headers
40
+
41
+ @property
42
+ def content(self) -> bytes:
43
+ """Get the response content, either modified or original."""
44
+ if self._modified_content is not None:
45
+ return self._modified_content
46
+ return self.original_response.content
47
+
48
+ def json(self) -> Any:
49
+ """Get the JSON content, either modified or original."""
50
+ if self._modified_json is not None:
51
+ return self._modified_json
52
+ if self._modified_content is not None:
53
+ return json.loads(self._modified_content)
54
+ return self.original_response.json()
55
+
56
+ def set_content(self, content: bytes):
57
+ """
58
+ Set modified content.
59
+
60
+ Args:
61
+ content: The new content bytes
62
+ """
63
+ self._modified_content = content
64
+ self._modified_json = None # Clear JSON cache
65
+
66
+ def set_json(self, data: Any):
67
+ """
68
+ Set modified JSON content.
69
+
70
+ Args:
71
+ data: The new JSON data
72
+ """
73
+ self._modified_json = data
74
+ self._modified_content = json.dumps(data).encode("utf-8")
75
+
76
+ def modify_json(self, modifier: callable) -> "ResponseWrapper":
77
+ """
78
+ Modify JSON content with a function.
79
+
80
+ Args:
81
+ modifier: Function that takes JSON data and returns modified data
82
+
83
+ Returns:
84
+ Self for chaining
85
+ """
86
+ try:
87
+ current_json = self.json()
88
+ modified_json = modifier(current_json)
89
+ self.set_json(modified_json)
90
+ except Exception as e:
91
+ logger.warning(f"Failed to modify JSON response: {e}")
92
+ return self
93
+
94
+
95
+ def shape_amc_response(response: httpx.Response) -> httpx.Response:
96
+ """
97
+ Shape AMC responses without accessing private attributes.
98
+
99
+ Args:
100
+ response: The original response
101
+
102
+ Returns:
103
+ The response (potentially modified)
104
+ """
105
+ try:
106
+ # Check if this is an AMC response that needs shaping
107
+ if response.status_code != 200:
108
+ return response
109
+
110
+ content_type = response.headers.get("content-type", "")
111
+ if "application/json" not in content_type:
112
+ return response
113
+
114
+ # Parse response
115
+ try:
116
+ data = response.json()
117
+ except Exception:
118
+ return response
119
+
120
+ # Check if shaping is needed
121
+ modified = False
122
+
123
+ # Handle data wrapper
124
+ if isinstance(data, dict) and "data" in data:
125
+ # Unwrap single data element
126
+ if isinstance(data["data"], list) and len(data["data"]) == 1:
127
+ data = data["data"][0]
128
+ modified = True
129
+
130
+ # Handle ISO date conversion
131
+ if isinstance(data, dict):
132
+ for key, value in data.items():
133
+ if isinstance(value, str) and "T" in value and "Z" in value:
134
+ # Looks like ISO date, leave as-is (don't convert to epoch)
135
+ pass
136
+
137
+ if modified:
138
+ # Create a new response with modified content
139
+ # Instead of modifying private _content attribute
140
+ content_bytes = json.dumps(data).encode("utf-8")
141
+
142
+ # Create new response object
143
+ new_response = httpx.Response(
144
+ status_code=response.status_code,
145
+ headers=dict(response.headers),
146
+ content=content_bytes,
147
+ request=response.request,
148
+ )
149
+
150
+ # Update content-length header
151
+ new_response.headers["content-length"] = str(len(content_bytes))
152
+
153
+ return new_response
154
+
155
+ except Exception as e:
156
+ logger.debug(f"AMC response shaping failed: {e}")
157
+
158
+ return response
159
+
160
+
161
+ def wrap_response(response: httpx.Response) -> ResponseWrapper:
162
+ """
163
+ Wrap an httpx response for safe manipulation.
164
+
165
+ Args:
166
+ response: The original response
167
+
168
+ Returns:
169
+ Wrapped response
170
+ """
171
+ return ResponseWrapper(response)