apytizer 0.0.1a0__py3-none-any.whl → 0.0.1b2__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 (75) hide show
  1. apytizer/__init__.py +2 -12
  2. apytizer/adapters/__init__.py +2 -3
  3. apytizer/adapters/transport_adapter.py +91 -0
  4. apytizer/apis/__init__.py +6 -0
  5. apytizer/apis/abstract_api.py +36 -0
  6. apytizer/apis/web_api.py +460 -0
  7. apytizer/connections/__init__.py +6 -0
  8. apytizer/connections/abstract_connection.py +28 -0
  9. apytizer/connections/http_connection.py +431 -0
  10. apytizer/decorators/__init__.py +5 -5
  11. apytizer/decorators/caching.py +60 -9
  12. apytizer/decorators/chunking.py +105 -0
  13. apytizer/decorators/connection.py +55 -20
  14. apytizer/decorators/json.py +70 -12
  15. apytizer/decorators/pagination.py +50 -32
  16. apytizer/endpoints/__init__.py +6 -0
  17. apytizer/endpoints/abstract_endpoint.py +38 -0
  18. apytizer/endpoints/web_endpoint.py +519 -0
  19. apytizer/engines/__init__.py +6 -0
  20. apytizer/engines/abstract_engine.py +45 -0
  21. apytizer/engines/http_engine.py +171 -0
  22. apytizer/errors.py +34 -0
  23. apytizer/factories/__init__.py +5 -0
  24. apytizer/factories/abstract_factory.py +17 -0
  25. apytizer/http_methods.py +34 -0
  26. apytizer/managers/__init__.py +12 -0
  27. apytizer/managers/abstract_manager.py +80 -0
  28. apytizer/managers/base_manager.py +116 -0
  29. apytizer/mappers/__init__.py +6 -0
  30. apytizer/mappers/abstract_mapper.py +48 -0
  31. apytizer/mappers/base_mapper.py +78 -0
  32. apytizer/media_types.py +118 -0
  33. apytizer/models/__init__.py +6 -0
  34. apytizer/models/abstract_model.py +119 -0
  35. apytizer/models/base_model.py +85 -0
  36. apytizer/protocols.py +38 -0
  37. apytizer/repositories/__init__.py +6 -0
  38. apytizer/repositories/abstract_repository.py +81 -0
  39. apytizer/repositories/managed_repository.py +92 -0
  40. apytizer/routes/__init__.py +6 -0
  41. apytizer/routes/abstract_route.py +32 -0
  42. apytizer/routes/base_route.py +138 -0
  43. apytizer/sessions/__init__.py +33 -0
  44. apytizer/sessions/abstract_session.py +63 -0
  45. apytizer/sessions/requests_session.py +125 -0
  46. apytizer/states/__init__.py +6 -0
  47. apytizer/states/abstract_state.py +71 -0
  48. apytizer/states/local_state.py +99 -0
  49. apytizer/utils/__init__.py +9 -4
  50. apytizer/utils/caching.py +39 -0
  51. apytizer/utils/dictionaries.py +376 -0
  52. apytizer/utils/errors.py +104 -0
  53. apytizer/utils/iterables.py +91 -0
  54. apytizer/utils/objects.py +145 -0
  55. apytizer/utils/strings.py +69 -0
  56. apytizer/utils/typing.py +29 -0
  57. apytizer-0.0.1b2.dist-info/METADATA +41 -0
  58. apytizer-0.0.1b2.dist-info/RECORD +60 -0
  59. {apytizer-0.0.1a0.dist-info → apytizer-0.0.1b2.dist-info}/WHEEL +1 -2
  60. apytizer/abstracts/__init__.py +0 -8
  61. apytizer/abstracts/api.py +0 -147
  62. apytizer/abstracts/endpoint.py +0 -177
  63. apytizer/abstracts/model.py +0 -50
  64. apytizer/abstracts/session.py +0 -39
  65. apytizer/adapters/transport.py +0 -40
  66. apytizer/base/__init__.py +0 -8
  67. apytizer/base/api.py +0 -510
  68. apytizer/base/endpoint.py +0 -443
  69. apytizer/base/model.py +0 -119
  70. apytizer/utils/generate_key.py +0 -18
  71. apytizer/utils/merge.py +0 -19
  72. apytizer-0.0.1a0.dist-info/METADATA +0 -27
  73. apytizer-0.0.1a0.dist-info/RECORD +0 -25
  74. apytizer-0.0.1a0.dist-info/top_level.txt +0 -1
  75. {apytizer-0.0.1a0.dist-info → apytizer-0.0.1b2.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,431 @@
1
+ # -*- coding: utf-8 -*-
2
+ # src/apytizer/connection/base_connection.py
3
+ """HTTP Connection Class.
4
+
5
+ This module defines the base HTTP connection class implementation.
6
+
7
+ """
8
+
9
+ # Standard Library Imports
10
+ from collections import ChainMap
11
+ import logging
12
+ from typing import Any
13
+ from typing import MutableMapping
14
+ from typing import Optional
15
+ from typing import Tuple
16
+ from typing import Union
17
+ from typing import final
18
+ from typing import TYPE_CHECKING
19
+ from urllib.parse import urljoin
20
+
21
+ # Third-Party Imports
22
+ from requests import Request
23
+ from requests import Response
24
+
25
+ # Local Imports
26
+ from .abstract_connection import AbstractConnection
27
+ from ..decorators import confirm_connection
28
+ from ..http_methods import HTTPMethod
29
+ from ..sessions import AbstractSession
30
+ from ..sessions import sessionmaker
31
+ from .. import errors
32
+
33
+ if TYPE_CHECKING:
34
+ from ..engines import HTTPEngine
35
+
36
+ __all__ = ["HttpConnection"]
37
+
38
+
39
+ log = logging.getLogger("apytizer")
40
+
41
+ # Constants
42
+ DEFAULT_SESSION_FACTORY = sessionmaker()
43
+
44
+
45
+ class HttpConnection(AbstractConnection):
46
+ """Implements an HTTP connection.
47
+
48
+ The connection class provides an interface for interacting with an API.
49
+ It implements the standard HTTP methods (HEAD, GET, POST, PUT, PATCH,
50
+ DELETE, OPTIONS and TRACE) as well as a `request` method for sending
51
+ custom HTTP requests.
52
+
53
+ Args:
54
+ engine: Engine.
55
+ session_factory (optional): Function for creating sessions.
56
+
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ engine: "HTTPEngine",
62
+ *,
63
+ session_factory: sessionmaker = DEFAULT_SESSION_FACTORY,
64
+ ) -> None:
65
+ self._engine = engine
66
+ self._session_factory = session_factory
67
+
68
+ @property
69
+ def headers(self) -> ChainMap[str, str]:
70
+ """Connection headers."""
71
+ return self._engine.headers
72
+
73
+ @property
74
+ def params(self) -> ChainMap[str, Any]:
75
+ """Connection parameters."""
76
+ return self._engine.params
77
+
78
+ @property
79
+ def session(self) -> Optional[AbstractSession]:
80
+ """Session."""
81
+ return getattr(self, "_session", None)
82
+
83
+ @property
84
+ def timeout(self) -> Optional[Union[float, Tuple[float, float]]]:
85
+ """Connection timeout."""
86
+ return self._engine.timeout
87
+
88
+ @property
89
+ def url(self) -> str:
90
+ """Connection URL."""
91
+ return self._engine.url
92
+
93
+ @final
94
+ def __enter__(self) -> AbstractConnection:
95
+ """Starts connection as context manager."""
96
+ self.start()
97
+ return self
98
+
99
+ @final
100
+ def __exit__(self, *_) -> None:
101
+ """Ends connection as context manager."""
102
+ self.close()
103
+
104
+ def start(self) -> None:
105
+ """Start connection."""
106
+ session = self._session_factory(self._engine)
107
+ setattr(self, "_session", session)
108
+ session.start()
109
+
110
+ def close(self) -> None:
111
+ """Close connection."""
112
+ if self.session is not None:
113
+ self.session.close()
114
+
115
+ def head(
116
+ self,
117
+ route: Optional[str] = None,
118
+ *,
119
+ headers: Optional[MutableMapping[str, str]] = None,
120
+ params: Optional[MutableMapping[str, Any]] = None,
121
+ **kwargs: Any,
122
+ ) -> Optional[Response]:
123
+ """Sends an HTTP HEAD request.
124
+
125
+ Args:
126
+ route (optional): Route to which to send request. Default ``None``.
127
+ headers (optional): Request headers (overrides global headers).
128
+ params (optional): Request parameters (overrides global parameters).
129
+ **kwargs: Additional arguments to pass to request.
130
+
131
+ Returns:
132
+ Response object.
133
+
134
+ .. _HTTP HEAD Method:
135
+ https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD
136
+
137
+ """
138
+ response = self.request(
139
+ HTTPMethod.HEAD,
140
+ route,
141
+ headers=headers,
142
+ params=params,
143
+ **kwargs,
144
+ )
145
+ return response
146
+
147
+ def get(
148
+ self,
149
+ route: Optional[str] = None,
150
+ *,
151
+ headers: Optional[MutableMapping[str, str]] = None,
152
+ params: Optional[MutableMapping[str, Any]] = None,
153
+ **kwargs: Any,
154
+ ) -> Optional[Response]:
155
+ """Sends an HTTP GET request.
156
+
157
+ Args:
158
+ route (optional): Route to which to send request. Default ``None``.
159
+ headers (optional): Request headers (overrides global headers).
160
+ params (optional): Request parameters (overrides global parameters).
161
+ **kwargs: Additional arguments to pass to request.
162
+
163
+ Returns:
164
+ Response object.
165
+
166
+ .. _HTTP GET Method:
167
+ https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET
168
+
169
+ """
170
+ response = self.request(
171
+ HTTPMethod.GET,
172
+ route,
173
+ headers=headers,
174
+ params=params,
175
+ **kwargs,
176
+ )
177
+ return response
178
+
179
+ def post(
180
+ self,
181
+ route: Optional[str] = None,
182
+ *,
183
+ headers: Optional[MutableMapping[str, str]] = None,
184
+ params: Optional[MutableMapping[str, Any]] = None,
185
+ **kwargs: Any,
186
+ ) -> Optional[Response]:
187
+ """Sends an HTTP POST request.
188
+
189
+ Args:
190
+ route (optional): Route to which to send request. Default ``None``.
191
+ headers (optional): Request headers (overrides global headers).
192
+ params (optional): Request parameters (overrides global parameters).
193
+ **kwargs: Additional arguments to pass to request.
194
+
195
+ Returns:
196
+ Response object.
197
+
198
+ .. _HTTP POST Method:
199
+ https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST
200
+
201
+ """
202
+ response = self.request(
203
+ HTTPMethod.POST,
204
+ route,
205
+ headers=headers,
206
+ params=params,
207
+ **kwargs,
208
+ )
209
+ return response
210
+
211
+ def put(
212
+ self,
213
+ route: Optional[str] = None,
214
+ *,
215
+ headers: Optional[MutableMapping[str, str]] = None,
216
+ params: Optional[MutableMapping[str, Any]] = None,
217
+ **kwargs: Any,
218
+ ) -> Optional[Response]:
219
+ """Sends an HTTP PUT request.
220
+
221
+ Args:
222
+ route (optional): Route to which to send request. Default ``None``.
223
+ headers (optional): Request headers (overrides global headers).
224
+ params (optional): Request parameters (overrides global parameters).
225
+ **kwargs: Additional arguments to pass to request.
226
+
227
+ Returns:
228
+ Response object.
229
+
230
+ .. _HTTP PUT Method:
231
+ https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT
232
+
233
+ """
234
+ response = self.request(
235
+ HTTPMethod.PUT,
236
+ route,
237
+ headers=headers,
238
+ params=params,
239
+ **kwargs,
240
+ )
241
+ return response
242
+
243
+ def patch(
244
+ self,
245
+ route: Optional[str] = None,
246
+ *,
247
+ headers: Optional[MutableMapping[str, str]] = None,
248
+ params: Optional[MutableMapping[str, Any]] = None,
249
+ **kwargs: Any,
250
+ ) -> Optional[Response]:
251
+ """Sends an HTTP PATCH request.
252
+
253
+ Args:
254
+ route (optional): Route to which to send request. Default ``None``.
255
+ headers (optional): Request headers (overrides global headers).
256
+ params (optional): Request parameters (overrides global parameters).
257
+ **kwargs: Additional arguments to pass to request.
258
+
259
+ Returns:
260
+ Response object.
261
+
262
+ .. _HTTP PATCH Method:
263
+ https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PATCH
264
+
265
+ """
266
+ response = self.request(
267
+ HTTPMethod.PATCH,
268
+ route,
269
+ headers=headers,
270
+ params=params,
271
+ **kwargs,
272
+ )
273
+ return response
274
+
275
+ def delete(
276
+ self,
277
+ route: Optional[str] = None,
278
+ *,
279
+ headers: Optional[MutableMapping[str, str]] = None,
280
+ params: Optional[MutableMapping[str, Any]] = None,
281
+ **kwargs: Any,
282
+ ) -> Optional[Response]:
283
+ """Sends an HTTP DELETE request.
284
+
285
+ Args:
286
+ route (optional): Route to which to send request. Default ``None``.
287
+ headers (optional): Request headers (overrides global headers).
288
+ params (optional): Request parameters (overrides global parameters).
289
+ **kwargs: Additional arguments to pass to request.
290
+
291
+ Returns:
292
+ Response object.
293
+
294
+ .. _HTTP DELETE Method:
295
+ https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/DELETE
296
+
297
+ """
298
+ response = self.request(
299
+ HTTPMethod.DELETE,
300
+ route,
301
+ headers=headers,
302
+ params=params,
303
+ **kwargs,
304
+ )
305
+ return response
306
+
307
+ def options(
308
+ self,
309
+ route: Optional[str] = None,
310
+ *,
311
+ headers: Optional[MutableMapping[str, str]] = None,
312
+ params: Optional[MutableMapping[str, Any]] = None,
313
+ **kwargs: Any,
314
+ ) -> Optional[Response]:
315
+ """Sends an HTTP OPTIONS request.
316
+
317
+ Args:
318
+ route (optional): Route to which to send request. Default ``None``.
319
+ headers (optional): Request headers (overrides global headers).
320
+ params (optional): Request parameters (overrides global parameters).
321
+ **kwargs: Additional arguments to pass to request.
322
+
323
+ Returns:
324
+ Response object.
325
+
326
+ .. _HTTP OPTIONS Method:
327
+ https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS
328
+
329
+ """
330
+ response = self.request(
331
+ HTTPMethod.OPTIONS,
332
+ route,
333
+ headers=headers,
334
+ params=params,
335
+ **kwargs,
336
+ )
337
+ return response
338
+
339
+ def trace(
340
+ self,
341
+ route: Optional[str] = None,
342
+ *,
343
+ headers: Optional[MutableMapping[str, str]] = None,
344
+ params: Optional[MutableMapping[str, Any]] = None,
345
+ **kwargs: Any,
346
+ ) -> Optional[Response]:
347
+ """Sends an HTTP TRACE request.
348
+
349
+ Args:
350
+ route (optional): Route to which to send request. Default ``None``.
351
+ headers (optional): Request headers (overrides global headers).
352
+ params (optional): Request parameters (overrides global parameters).
353
+ **kwargs: Additional arguments to pass to request.
354
+
355
+ Returns:
356
+ Response object.
357
+
358
+ .. _HTTP TRACE Method:
359
+ https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/TRACE
360
+
361
+ """
362
+ response = self.request(
363
+ HTTPMethod.TRACE,
364
+ route,
365
+ headers=headers,
366
+ params=params,
367
+ **kwargs,
368
+ )
369
+ return response
370
+
371
+ def request(
372
+ self,
373
+ method: HTTPMethod,
374
+ /,
375
+ route: Optional[str] = None,
376
+ *,
377
+ headers: Optional[MutableMapping[str, str]] = None,
378
+ params: Optional[MutableMapping[str, Any]] = None,
379
+ **kwargs: Any,
380
+ ) -> Optional[Response]:
381
+ """Sends an HTTP request.
382
+
383
+ Args:
384
+ method: HTTP request method to use.
385
+ route (optional): Route to which to send request. Default ``None``.
386
+ headers (optional): Request headers (overrides global headers).
387
+ params (optional): Request parameters (overrides global parameters).
388
+ **kwargs: Additional arguments to pass to request.
389
+
390
+ Returns:
391
+ Response object.
392
+
393
+ .. _Requests Documentation:
394
+ https://docs.python-requests.org/en/latest/api/
395
+
396
+ """
397
+ request = Request(
398
+ method.name,
399
+ urljoin(self.url, route),
400
+ headers=self.headers.new_child(headers),
401
+ params=self.params.new_child(params),
402
+ **kwargs,
403
+ )
404
+ response = self.send(request)
405
+ return response
406
+
407
+ @confirm_connection
408
+ def send(self, request: Request) -> Optional[Response]:
409
+ """Sends an HTTP request.
410
+
411
+ Args:
412
+ request: Request to send.
413
+
414
+ Returns:
415
+ Response object.
416
+
417
+ """
418
+ if self.session is None:
419
+ message = "session not started before sending request"
420
+ raise errors.SessionNotStarted(message)
421
+
422
+ log.debug(
423
+ "Sending HTTP %(method)s request to %(url)s",
424
+ {"method": request.method, "url": request.url},
425
+ )
426
+ response = self.session.send(request, timeout=self.timeout)
427
+ log.debug(
428
+ "Received response with status code %(status)s",
429
+ {"status": response.status_code},
430
+ )
431
+ return response
@@ -1,7 +1,7 @@
1
1
  # -*- coding: utf-8 -*-
2
+ # src/apytizer/decorators/__init__.py
2
3
 
3
- # pylint: skip-file
4
-
5
- from .connection import confirm_connection
6
- from .json import json_response
7
- from .pagination import pagination
4
+ from .caching import *
5
+ from .connection import *
6
+ from .json import *
7
+ from .pagination import *
@@ -1,24 +1,28 @@
1
1
  # -*- coding: utf-8 -*-
2
+ # src/apytizer/decorators/caching.py
2
3
 
3
4
  # Standard Library Imports
4
5
  import functools
5
- import logging
6
6
  import operator
7
+ from typing import Any
7
8
  from typing import Callable
9
+ from typing import TypeVar
8
10
 
9
11
  # Third-Party Imports
10
12
  from cachetools import cachedmethod
11
13
 
12
14
  # Local Imports
13
- from ..utils import generate_key
15
+ from ..utils.caching import generate_key
14
16
 
17
+ __all__ = ["cache_response"]
15
18
 
16
- log = logging.getLogger(__name__)
17
19
 
20
+ # Custom types
21
+ T = TypeVar("T")
18
22
 
19
- def cache_response(func: Callable) -> Callable:
20
- """
21
- Decorator function for handling caching.
23
+
24
+ def cache_response(func: Callable[..., T]) -> Callable[..., T]:
25
+ """Decorator function for handling caching.
22
26
 
23
27
  Args:
24
28
  func: Decorated function.
@@ -27,10 +31,57 @@ def cache_response(func: Callable) -> Callable:
27
31
  Wrapped function.
28
32
 
29
33
  """
34
+ cached_func = cachedmethod(
35
+ operator.attrgetter("cache"),
36
+ key=generate_key(func.__name__.upper()),
37
+ )(func)
38
+
30
39
  @functools.wraps(func)
31
- @cachedmethod(operator.attrgetter('cache'), key=generate_key(func.__name__.upper()))
32
- def wrapper(*args, **kwargs):
40
+ def wrapper(*args: Any, **kwargs: Any) -> T:
33
41
  """Wrapper applied to decorated function."""
34
- return func(*args, **kwargs)
42
+ try:
43
+ result = cached_func(*args, **kwargs)
44
+
45
+ except TypeError as error: # raised when cache is 'None'
46
+ if is_missing_cache(error):
47
+ result = func(*args, **kwargs)
48
+
49
+ else:
50
+ raise error
35
51
 
52
+ return result
53
+
54
+ functools.update_wrapper(wrapper, func)
36
55
  return wrapper
56
+
57
+
58
+ # ----------------------------------------------------------------------------
59
+ # Validators
60
+ # ----------------------------------------------------------------------------
61
+ def is_missing_cache(e: Exception) -> bool:
62
+ """Check whether exception was raised because of missing cache.
63
+
64
+ Args:
65
+ e: Exception.
66
+
67
+ Returns:
68
+ Whether exception was raised because of missing cache.
69
+
70
+ """
71
+ expected = "'NoneType' object is not subscriptable"
72
+ result = is_nonetype_error(e) and expected in str(e)
73
+ return result
74
+
75
+
76
+ def is_nonetype_error(e: Exception) -> bool:
77
+ """Check whether exception is 'NoneType' error.
78
+
79
+ Args:
80
+ e: Exception.
81
+
82
+ Returns:
83
+ Whether exception wis 'NoneType' error.
84
+
85
+ """
86
+ result = isinstance(e, TypeError) and "NoneType" in str(e)
87
+ return result
@@ -0,0 +1,105 @@
1
+ # -*- coding: utf-8 -*-
2
+ # src/apytizer/decorators/chunking.py
3
+
4
+ # Standard Library Imports
5
+ import functools
6
+ from typing import Any
7
+ from typing import Callable
8
+ from typing import Dict
9
+ from typing import Iterable
10
+ from typing import List
11
+ from typing import Union
12
+
13
+ # Third-Party Imports
14
+ from requests import Response
15
+
16
+ # Local Imports
17
+ from .. import utils
18
+
19
+ __all__ = ["chunked_request"]
20
+
21
+
22
+ def chunked_request(
23
+ max_size: int,
24
+ ) -> Callable[..., Callable[..., Union[Dict[str, Any], Response]]]:
25
+ """Split request payload based on maximum request size.
26
+
27
+ Args:
28
+ max_size: Maximum size of request.
29
+
30
+ Returns:
31
+ Wrapped function.
32
+
33
+ """
34
+
35
+ def decorator(
36
+ func: Callable[[Any, List[Any]], Union[Dict[str, Any], Response]],
37
+ ) -> Callable[..., Union[Dict[str, Any], Response]]:
38
+ """Decorator function for handling chunking.
39
+
40
+ Args:
41
+ func: Decorated function.
42
+
43
+ Returns:
44
+ Wrapped function.
45
+
46
+ """
47
+
48
+ @functools.wraps(func)
49
+ def wrapper(
50
+ self: Any, data: List[Any], *args: Any, **kwargs: Any
51
+ ) -> Union[Dict[str, Any], Response]:
52
+ """Wrapper applied to decorated function.
53
+
54
+ Args:
55
+ data: Data to chunk into multiple requests.
56
+ *args: Positional arguments to pass to wrapped function.
57
+ **kwargs: Keyword arguments to pass to wrapped function.
58
+
59
+ Returns:
60
+ Results.
61
+
62
+ """
63
+ if not isinstance(data, Iterable): # type: ignore
64
+ message = f"expected iterable object, got {type(data)} instead"
65
+ raise TypeError(message)
66
+
67
+ results = {}
68
+ for group in utils.split_list(data, max_size):
69
+ response = func(self, group, *args, **kwargs)
70
+ results = update_results(results, response)
71
+
72
+ return results
73
+
74
+ functools.update_wrapper(wrapper, func)
75
+ return wrapper
76
+
77
+ return decorator
78
+
79
+
80
+ def update_results(
81
+ results: Dict[str, List[Any]],
82
+ response: Union[Dict[str, List[Any]], Response],
83
+ ) -> Dict[str, Any]:
84
+ """Update results with response data.
85
+
86
+ Args:
87
+ results: Results to update.
88
+ response: Response with which to update results.
89
+
90
+ Returns:
91
+ Updated results.
92
+
93
+ """
94
+ if isinstance(response, Response) and response.ok:
95
+ return results
96
+
97
+ if not isinstance(response, dict): # type: ignore
98
+ message = f"expected type `dict`, got {type(response)} instead"
99
+ raise TypeError(message)
100
+
101
+ for key, value in response.items():
102
+ results.setdefault(key, [])
103
+ results[key].extend(value)
104
+
105
+ return results