opportify-sdk 0.1.1__py3-none-any.whl → 0.3.1__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.

Potentially problematic release.


This version of opportify-sdk might be problematic. Click here for more details.

Files changed (113) hide show
  1. openapi_client/__init__.py +186 -0
  2. openapi_client/api/email_insights_api.py +1491 -0
  3. openapi_client/api/ip_insights_api.py +1494 -0
  4. {lib/v1/openapi_client → openapi_client}/api_client.py +14 -7
  5. {lib/v1/openapi_client → openapi_client}/configuration.py +16 -5
  6. {lib/v1/openapi_client → openapi_client}/exceptions.py +18 -1
  7. openapi_client/models/__init__.py +84 -0
  8. {lib/v1/openapi_client → openapi_client}/models/abuse_contact.py +1 -1
  9. openapi_client/models/address_signals.py +99 -0
  10. {lib/v1/openapi_client → openapi_client}/models/admin_contact.py +1 -1
  11. openapi_client/models/analyze_email200_response.py +127 -0
  12. lib/v1/openapi_client/models/analyze_email400_response_error.py → openapi_client/models/analyze_email400_response.py +9 -9
  13. openapi_client/models/analyze_email403_response.py +207 -0
  14. openapi_client/models/analyze_email500_response.py +89 -0
  15. openapi_client/models/analyze_email_request.py +94 -0
  16. {lib/v1/openapi_client → openapi_client}/models/analyze_ip200_response.py +4 -4
  17. lib/v1/openapi_client/models/analyze_ip400_response_error.py → openapi_client/models/analyze_ip400_response.py +20 -20
  18. {lib/v1/openapi_client → openapi_client}/models/analyze_ip_request.py +1 -1
  19. {lib/v1/openapi_client → openapi_client}/models/asn.py +1 -1
  20. openapi_client/models/batch_analyze_emails202_response.py +93 -0
  21. openapi_client/models/batch_analyze_emails400_response.py +137 -0
  22. openapi_client/models/batch_analyze_emails401_response.py +89 -0
  23. openapi_client/models/batch_analyze_emails402_response.py +137 -0
  24. openapi_client/models/batch_analyze_emails403_response.py +193 -0
  25. openapi_client/models/batch_analyze_emails413_response.py +89 -0
  26. openapi_client/models/batch_analyze_emails429_response.py +89 -0
  27. openapi_client/models/batch_analyze_emails_request.py +93 -0
  28. openapi_client/models/batch_analyze_ips202_response.py +93 -0
  29. openapi_client/models/batch_analyze_ips400_response.py +137 -0
  30. openapi_client/models/batch_analyze_ips_request.py +91 -0
  31. {lib/v1/openapi_client → openapi_client}/models/block_listed.py +3 -4
  32. openapi_client/models/create_email_batch_export400_response.py +89 -0
  33. openapi_client/models/create_email_batch_export403_response.py +89 -0
  34. openapi_client/models/create_email_batch_export404_response.py +89 -0
  35. openapi_client/models/create_email_batch_export409_response.py +137 -0
  36. openapi_client/models/email_dns.py +97 -0
  37. openapi_client/models/email_domain.py +113 -0
  38. openapi_client/models/export_created_response.py +91 -0
  39. openapi_client/models/export_filter.py +95 -0
  40. openapi_client/models/export_request.py +92 -0
  41. openapi_client/models/export_status_response.py +119 -0
  42. openapi_client/models/exportnotfound.py +89 -0
  43. openapi_client/models/forbidden.py +89 -0
  44. {lib/v1/openapi_client → openapi_client}/models/geo.py +1 -1
  45. openapi_client/models/get_email_batch_export_status400_response.py +89 -0
  46. openapi_client/models/get_email_batch_export_status404_response.py +137 -0
  47. openapi_client/models/get_email_batch_status200_response.py +101 -0
  48. openapi_client/models/get_email_batch_status200_response_download_urls.py +93 -0
  49. openapi_client/models/get_email_batch_status404_response.py +89 -0
  50. openapi_client/models/get_ip_batch_status200_response.py +101 -0
  51. openapi_client/models/internalerror.py +89 -0
  52. openapi_client/models/internalerror1.py +89 -0
  53. openapi_client/models/invaliddata.py +89 -0
  54. openapi_client/models/invaliddata1.py +89 -0
  55. openapi_client/models/invalidemail.py +89 -0
  56. openapi_client/models/invalidplan.py +89 -0
  57. openapi_client/models/invalidplan1.py +89 -0
  58. openapi_client/models/invalidtoken.py +89 -0
  59. openapi_client/models/ipvalidationfailed.py +89 -0
  60. openapi_client/models/jobnotfound.py +89 -0
  61. openapi_client/models/jobnotready.py +89 -0
  62. openapi_client/models/malformedrequest.py +89 -0
  63. openapi_client/models/malformedrequest1.py +89 -0
  64. openapi_client/models/malformedrequest2.py +89 -0
  65. openapi_client/models/malformedrequest3.py +89 -0
  66. openapi_client/models/manifestnotavailable.py +89 -0
  67. openapi_client/models/notfound.py +89 -0
  68. {lib/v1/openapi_client → openapi_client}/models/organization.py +1 -1
  69. openapi_client/models/quotaexceeded.py +89 -0
  70. openapi_client/models/risk_report_email.py +92 -0
  71. openapi_client/models/risk_report_ip.py +89 -0
  72. {lib/v1/openapi_client → openapi_client}/models/tech_contact.py +1 -1
  73. openapi_client/models/toomanyrequests.py +89 -0
  74. {lib/v1/openapi_client → openapi_client}/models/trusted_provider.py +1 -1
  75. {lib/v1/openapi_client → openapi_client}/models/whois.py +2 -2
  76. {lib/v1/openapi_client → openapi_client}/rest.py +2 -1
  77. opportify_sdk/email_insights.py +427 -0
  78. opportify_sdk/ip_insights.py +410 -0
  79. opportify_sdk-0.3.1.dist-info/METADATA +300 -0
  80. opportify_sdk-0.3.1.dist-info/RECORD +86 -0
  81. {opportify_sdk-0.1.1.dist-info → opportify_sdk-0.3.1.dist-info}/WHEEL +1 -1
  82. opportify_sdk-0.3.1.dist-info/top_level.txt +2 -0
  83. lib/__init__.py +0 -0
  84. lib/v1/__init__.py +0 -0
  85. lib/v1/openapi_client/__init__.py +0 -63
  86. lib/v1/openapi_client/api/email_insights_api.py +0 -317
  87. lib/v1/openapi_client/api/ip_insights_api.py +0 -322
  88. lib/v1/openapi_client/models/__init__.py +0 -45
  89. lib/v1/openapi_client/models/analyze_email200_response.py +0 -113
  90. lib/v1/openapi_client/models/analyze_email400_response.py +0 -91
  91. lib/v1/openapi_client/models/analyze_email500_response.py +0 -91
  92. lib/v1/openapi_client/models/analyze_email500_response_error.py +0 -89
  93. lib/v1/openapi_client/models/analyze_email_request.py +0 -92
  94. lib/v1/openapi_client/models/analyze_ip400_response.py +0 -91
  95. lib/v1/openapi_client/models/analyze_ip404_response.py +0 -91
  96. lib/v1/openapi_client/models/analyze_ip500_response.py +0 -91
  97. lib/v1/openapi_client/models/email_dns.py +0 -87
  98. lib/v1/openapi_client/models/internalerror.py +0 -89
  99. lib/v1/openapi_client/models/invalidemail.py +0 -89
  100. lib/v1/openapi_client/models/ipvalidationfailed.py +0 -89
  101. lib/v1/openapi_client/models/malformedrequest.py +0 -89
  102. lib/v1/openapi_client/models/malformedrequest1.py +0 -89
  103. lib/v1/openapi_client/models/notfound.py +0 -89
  104. lib/v1/openapi_client/models/risk_report.py +0 -89
  105. opportify_sdk-0.1.1.dist-info/METADATA +0 -108
  106. opportify_sdk-0.1.1.dist-info/RECORD +0 -49
  107. opportify_sdk-0.1.1.dist-info/top_level.txt +0 -2
  108. src/email_insights.py +0 -97
  109. src/ip_insights.py +0 -93
  110. {lib/v1/openapi_client → openapi_client}/api/__init__.py +0 -0
  111. {lib/v1/openapi_client → openapi_client}/api_response.py +0 -0
  112. {lib/v1/openapi_client → openapi_client}/py.typed +0 -0
  113. {src → opportify_sdk}/__init__.py +0 -0
@@ -0,0 +1,427 @@
1
+ # src/email_insights.py
2
+ import os
3
+ from typing import Optional, Dict, Any, List, Union
4
+ import openapi_client
5
+ from openapi_client.configuration import Configuration as ApiConfiguration
6
+ from openapi_client.api_client import ApiClient
7
+ from openapi_client.api.email_insights_api import EmailInsightsApi
8
+ from openapi_client.models.analyze_email_request import AnalyzeEmailRequest
9
+ from openapi_client.models.batch_analyze_emails_request import BatchAnalyzeEmailsRequest
10
+ from openapi_client.models.export_request import ExportRequest
11
+ from openapi_client.exceptions import ApiException
12
+
13
+
14
+ class EmailInsights:
15
+ def __init__(self, api_key: str, api_instance: Optional[EmailInsightsApi] = None):
16
+ """
17
+ Initialize the EmailInsights class with the provided API key.
18
+
19
+ :param api_key: The API key for authentication.
20
+ :param api_instance: Optional API instance for testing purposes.
21
+ """
22
+ self.config = ApiConfiguration()
23
+ self.config.api_key = {"opportifyToken": api_key}
24
+ self.host = "https://api.opportify.ai"
25
+ self.prefix = "insights"
26
+ self.version = "v1"
27
+ self.debug_mode = False
28
+ self.final_url = ""
29
+ self.config_changed = False
30
+
31
+ self._update_final_url()
32
+
33
+ if api_instance:
34
+ self.api_instance = api_instance
35
+ else:
36
+ self._refresh_api_instance(first_run=True)
37
+
38
+
39
+ def _refresh_api_instance(self, first_run: bool = False) -> None:
40
+ """
41
+ Ensures API instance is updated only if config has changed.
42
+
43
+ :param first_run: Whether this is the first initialization.
44
+ """
45
+ if not self.config_changed and not first_run:
46
+ return
47
+
48
+ self._update_final_url()
49
+ self.config.host = self.final_url
50
+ api_client = ApiClient(configuration=self.config)
51
+ api_client.configuration.debug = self.debug_mode
52
+ self.api_instance = EmailInsightsApi(api_client)
53
+ self.config_changed = False
54
+
55
+ def _update_final_url(self) -> None:
56
+ """
57
+ Updates the final URL used for API requests.
58
+ """
59
+ base = self.host.rstrip('/')
60
+ segments = []
61
+
62
+ prefix = self.prefix.strip('/')
63
+ if prefix:
64
+ segments.append(prefix)
65
+
66
+ version = self.version.strip('/')
67
+ if version:
68
+ segments.append(version)
69
+
70
+ self.final_url = base + ('/' + '/'.join(segments) if segments else '')
71
+
72
+ def analyze(self, params: Dict[str, Any]) -> Dict[str, Any]:
73
+ """
74
+ Analyze the email with the given parameters.
75
+
76
+ :param params: Dictionary containing parameters for email analysis.
77
+ :return: The analysis result as a dictionary.
78
+ :raises Exception: If an API exception occurs.
79
+ """
80
+ # Ensure latest config before API call
81
+ self._refresh_api_instance()
82
+
83
+ params = self._normalize_request(params)
84
+ analyze_email_request = AnalyzeEmailRequest(**params)
85
+
86
+ try:
87
+ result = self.api_instance.analyze_email(analyze_email_request)
88
+ return result.to_dict()
89
+ except ApiException as e:
90
+ raise Exception(f"API exception: {e.reason}")
91
+
92
+ def batch_analyze(self, params: Dict[str, Any], content_type: Optional[str] = None) -> Dict[str, Any]:
93
+ """
94
+ Submit a batch of emails for analysis.
95
+
96
+ :param params: Dictionary containing parameters for batch email analysis.
97
+ :param content_type: Optional content type (defaults to application/json).
98
+ Supported: 'application/json', 'multipart/form-data', 'text/plain'
99
+ :return: The batch job information as a dictionary (job_id, status, etc.).
100
+ :raises Exception: If an API exception occurs.
101
+ """
102
+ # Ensure latest config before API call
103
+ self._refresh_api_instance()
104
+
105
+ # Default to application/json if not specified
106
+ content_type = content_type or 'application/json'
107
+
108
+ try:
109
+ if content_type == 'application/json':
110
+ params = self._normalize_batch_request(params)
111
+ batch_analyze_emails_request = BatchAnalyzeEmailsRequest(**params)
112
+ result = self.api_instance.batch_analyze_emails(
113
+ batch_analyze_emails_request,
114
+ _content_type=content_type
115
+ )
116
+ elif content_type == 'multipart/form-data':
117
+ if 'file' not in params or not os.path.exists(params['file']):
118
+ raise ValueError('File parameter is required and must be a valid file path')
119
+
120
+ with open(params['file'], 'rb') as file_handle:
121
+ # Create multipart data
122
+ files = {'file': (os.path.basename(params['file']), file_handle)}
123
+ data = {}
124
+
125
+ # Add optional parameters
126
+ enable_ai = self._resolve_boolean(params, ['enable_ai', 'enableAi'])
127
+ if enable_ai is not None:
128
+ data['enable_ai'] = 'true' if enable_ai else 'false'
129
+
130
+ enable_auto_correction = self._resolve_boolean(params, ['enable_auto_correction', 'enableAutoCorrection'])
131
+ if enable_auto_correction is not None:
132
+ data['enable_auto_correction'] = 'true' if enable_auto_correction else 'false'
133
+
134
+ # Add name parameter if provided
135
+ if 'name' in params:
136
+ data['name'] = str(params['name'])
137
+
138
+ # Prepare the request body as multipart
139
+ multipart_data = self._prepare_multipart_data(files, data)
140
+ result = self.api_instance.batch_analyze_emails(
141
+ multipart_data,
142
+ _content_type=content_type
143
+ )
144
+ elif content_type == 'text/plain':
145
+ if 'text' not in params:
146
+ raise ValueError('Text parameter is required for text/plain content type')
147
+
148
+ result = self.api_instance.batch_analyze_emails(
149
+ params['text'],
150
+ _content_type=content_type
151
+ )
152
+ else:
153
+ raise ValueError(f'Unsupported content type: {content_type}')
154
+
155
+ return result.to_dict()
156
+ except ApiException as e:
157
+ raise Exception(f"API exception: {e.reason}")
158
+
159
+ def batch_analyze_file(self, file_path: str, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
160
+ """
161
+ Submit a batch of emails for analysis using a file.
162
+
163
+ :param file_path: Path to the file containing emails (CSV or text).
164
+ :param options: Additional options like enableAi, enableAutoCorrection, name.
165
+ :return: The batch job information as a dictionary.
166
+ :raises Exception: If an API exception occurs.
167
+ """
168
+ options = options or {}
169
+ params = {'file': file_path, **options}
170
+ return self.batch_analyze(params, 'multipart/form-data')
171
+
172
+ def get_batch_status(self, job_id: str) -> Dict[str, Any]:
173
+ """
174
+ Get the status of a batch email analysis job.
175
+
176
+ :param job_id: The unique identifier of the batch job.
177
+ :return: The batch job status as a dictionary.
178
+ :raises Exception: If an API exception occurs.
179
+ """
180
+ # Ensure latest config before API call
181
+ self._refresh_api_instance()
182
+
183
+ try:
184
+ result = self.api_instance.get_email_batch_status(job_id)
185
+ return result.to_dict()
186
+ except ApiException as e:
187
+ raise Exception(f"API exception: {e.reason}")
188
+
189
+ def create_batch_export(self, job_id: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
190
+ """
191
+ Request a custom export for a completed email batch job.
192
+
193
+ :param job_id: The unique identifier of the batch job.
194
+ :param payload: Optional export configuration (export_type, filters, columns).
195
+ :return: The export creation response as a dictionary.
196
+ :raises Exception: If an API exception occurs.
197
+ """
198
+ self._refresh_api_instance()
199
+
200
+ job_id = job_id.strip()
201
+ if not job_id:
202
+ raise ValueError('Job ID cannot be empty when creating an export.')
203
+
204
+ payload = payload or {}
205
+ normalized_payload = self._normalize_export_request(payload)
206
+ export_request = ExportRequest(**normalized_payload) if normalized_payload else None
207
+
208
+ try:
209
+ result = self.api_instance.create_email_batch_export(job_id, export_request)
210
+ return result.to_dict()
211
+ except ApiException as e:
212
+ raise Exception(f"API exception: {e.reason}")
213
+
214
+ def get_batch_export_status(self, job_id: str, export_id: str) -> Dict[str, Any]:
215
+ """
216
+ Retrieve the status of a previously requested email batch export.
217
+
218
+ :param job_id: The unique identifier of the batch job.
219
+ :param export_id: The unique identifier of the export.
220
+ :return: The export status as a dictionary.
221
+ :raises Exception: If an API exception occurs.
222
+ """
223
+ self._refresh_api_instance()
224
+
225
+ job_id = job_id.strip()
226
+ export_id = export_id.strip()
227
+
228
+ if not job_id or not export_id:
229
+ raise ValueError('Job ID and export ID are required to fetch export status.')
230
+
231
+ try:
232
+ result = self.api_instance.get_email_batch_export_status(job_id, export_id)
233
+ return result.to_dict()
234
+ except ApiException as e:
235
+ raise Exception(f"API exception: {e.reason}")
236
+
237
+ def set_host(self, host: str) -> "EmailInsights":
238
+ """
239
+ Set the host.
240
+
241
+ :param host: The host URL.
242
+ :return: The current instance for chaining.
243
+ """
244
+ if self.host != host:
245
+ self.host = host
246
+ self.config_changed = True
247
+ return self
248
+
249
+ def set_version(self, version: str) -> "EmailInsights":
250
+ """
251
+ Set the version.
252
+
253
+ :param version: The API version.
254
+ :return: The current instance for chaining.
255
+ """
256
+ if self.version != version:
257
+ self.version = version
258
+ self.config_changed = True
259
+ return self
260
+
261
+ def set_prefix(self, prefix: str) -> "EmailInsights":
262
+ """
263
+ Set the prefix.
264
+
265
+ :param prefix: The URL prefix.
266
+ :return: The current instance for chaining.
267
+ """
268
+ prefix = prefix.strip('/')
269
+ if self.prefix != prefix:
270
+ self.prefix = prefix
271
+ self.config_changed = True
272
+ return self
273
+
274
+ def set_debug_mode(self, debug_mode: bool) -> "EmailInsights":
275
+ """
276
+ Set the debug mode.
277
+
278
+ :param debug_mode: Enable or disable debug mode.
279
+ :return: The current instance for chaining.
280
+ """
281
+ if self.debug_mode != debug_mode:
282
+ self.debug_mode = debug_mode
283
+ self.config_changed = True
284
+ return self
285
+
286
+ def _normalize_request(self, params: Dict[str, Any]) -> Dict[str, Any]:
287
+ """
288
+ Normalize the request parameters.
289
+
290
+ :param params: The raw parameters.
291
+ :return: Normalized parameters.
292
+ """
293
+ if 'email' not in params:
294
+ raise ValueError('The email parameter is required for analysis.')
295
+
296
+ normalized = {}
297
+ normalized["email"] = str(params["email"])
298
+
299
+ # Only include optional parameters if explicitly provided by user
300
+ enable_ai = self._resolve_boolean(params, ['enable_ai', 'enableAi'])
301
+ if enable_ai is not None:
302
+ normalized["enable_ai"] = enable_ai
303
+
304
+ enable_auto_correction = self._resolve_boolean(params, ['enable_auto_correction', 'enableAutoCorrection'])
305
+ if enable_auto_correction is not None:
306
+ normalized["enable_auto_correction"] = enable_auto_correction
307
+
308
+ enable_domain_enrichment = self._resolve_boolean(params, ['enable_domain_enrichment', 'enableDomainEnrichment'])
309
+ if enable_domain_enrichment is not None:
310
+ normalized["enable_domain_enrichment"] = enable_domain_enrichment
311
+
312
+ return normalized
313
+
314
+ def _normalize_batch_request(self, params: Dict[str, Any]) -> Dict[str, Any]:
315
+ """
316
+ Normalize the batch request parameters.
317
+
318
+ :param params: The raw parameters.
319
+ :return: Normalized parameters.
320
+ """
321
+ if 'emails' not in params:
322
+ raise ValueError("'emails' parameter is required for batch analysis")
323
+
324
+ emails = params['emails']
325
+ if not isinstance(emails, list):
326
+ raise ValueError("'emails' parameter must be a list")
327
+
328
+ normalized = {}
329
+ normalized["emails"] = [str(email) for email in emails]
330
+
331
+ enable_ai = self._resolve_boolean(params, ['enable_ai', 'enableAi'])
332
+ if enable_ai is not None:
333
+ normalized['enable_ai'] = enable_ai
334
+
335
+ enable_auto_correction = self._resolve_boolean(params, ['enable_auto_correction', 'enableAutoCorrection'])
336
+ if enable_auto_correction is not None:
337
+ normalized['enable_auto_correction'] = enable_auto_correction
338
+
339
+ # Add name parameter if provided
340
+ if 'name' in params:
341
+ normalized['name'] = str(params['name'])
342
+
343
+ return normalized
344
+
345
+ def _normalize_export_request(self, params: Dict[str, Any]) -> Dict[str, Any]:
346
+ """
347
+ Normalize export payload for batch exports.
348
+
349
+ :param params: The raw parameters.
350
+ :return: Normalized parameters.
351
+ """
352
+ normalized = {}
353
+
354
+ if self._has_any_key(params, ['export_type', 'exportType']):
355
+ value = params.get('export_type') or params.get('exportType')
356
+ normalized['export_type'] = str(value).lower()
357
+
358
+ if 'filters' in params and params['filters'] is not None:
359
+ if not isinstance(params['filters'], dict):
360
+ raise ValueError('Filters must be provided as a dictionary.')
361
+ normalized['filters'] = params['filters']
362
+
363
+ if 'columns' in params and params['columns'] is not None:
364
+ if not isinstance(params['columns'], list):
365
+ raise ValueError('Columns must be provided as a list.')
366
+ normalized['columns'] = [str(column) for column in params['columns']]
367
+
368
+ return normalized
369
+
370
+ def _has_any_key(self, params: Dict[str, Any], keys: List[str]) -> bool:
371
+ """
372
+ Check if params has any of the specified keys.
373
+
374
+ :param params: The parameters dictionary.
375
+ :param keys: List of keys to check.
376
+ :return: True if any key exists.
377
+ """
378
+ return any(key in params for key in keys)
379
+
380
+ def _resolve_boolean(self, params: Dict[str, Any], keys: List[str], default: Optional[bool] = None) -> Optional[bool]:
381
+ """
382
+ Resolve boolean value from params using multiple possible keys.
383
+
384
+ :param params: The parameters dictionary.
385
+ :param keys: List of possible keys.
386
+ :param default: Default value if none found.
387
+ :return: Boolean value or default.
388
+ """
389
+ for key in keys:
390
+ if key in params:
391
+ return self._to_boolean(params[key], key)
392
+ return default
393
+
394
+ def _to_boolean(self, value: Any, parameter_name: str) -> bool:
395
+ """
396
+ Convert a value to boolean.
397
+
398
+ :param value: The value to convert.
399
+ :param parameter_name: Name for error messages.
400
+ :return: Boolean value.
401
+ """
402
+ if isinstance(value, bool):
403
+ return value
404
+
405
+ if value in (1, 0, '1', '0'):
406
+ return bool(int(value))
407
+
408
+ if isinstance(value, str):
409
+ value_lower = value.lower()
410
+ if value_lower in ('true', 'yes', '1'):
411
+ return True
412
+ if value_lower in ('false', 'no', '0'):
413
+ return False
414
+
415
+ raise ValueError(f'Invalid boolean value provided for {parameter_name}')
416
+
417
+ def _prepare_multipart_data(self, files: Dict[str, Any], data: Dict[str, Any]) -> Any:
418
+ """
419
+ Prepare multipart form data for file upload.
420
+
421
+ :param files: Files dictionary.
422
+ :param data: Additional form data.
423
+ :return: Prepared multipart data.
424
+ """
425
+ # For Python SDK, we'll pass the file path directly and let the OpenAPI client handle it
426
+ # This is a placeholder that may need adjustment based on how the openapi_client handles multipart
427
+ return {**files, **data}