core-https 1.1.5__tar.gz → 1.1.7__tar.gz

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 (27) hide show
  1. {core_https-1.1.5 → core_https-1.1.7}/PKG-INFO +9 -6
  2. {core_https-1.1.5 → core_https-1.1.7}/core_https/exceptions.py +10 -1
  3. core_https-1.1.7/core_https/requesters/aiohttp_.py +178 -0
  4. core_https-1.1.7/core_https/requesters/base.py +161 -0
  5. core_https-1.1.7/core_https/requesters/requests_.py +116 -0
  6. core_https-1.1.7/core_https/requesters/urllib3_.py +117 -0
  7. core_https-1.1.7/core_https/tests/__init__.py +0 -0
  8. core_https-1.1.7/core_https/tests/aiohttp_.py +106 -0
  9. core_https-1.1.7/core_https/tests/base.py +12 -0
  10. core_https-1.1.7/core_https/tests/decorators.py +180 -0
  11. core_https-1.1.7/core_https/tests/requests_.py +75 -0
  12. core_https-1.1.7/core_https/tests/urllib3_.py +189 -0
  13. {core_https-1.1.5 → core_https-1.1.7}/core_https/utils.py +12 -0
  14. {core_https-1.1.5 → core_https-1.1.7}/core_https.egg-info/PKG-INFO +9 -6
  15. {core_https-1.1.5 → core_https-1.1.7}/core_https.egg-info/SOURCES.txt +9 -1
  16. {core_https-1.1.5 → core_https-1.1.7}/core_https.egg-info/requires.txt +10 -2
  17. {core_https-1.1.5 → core_https-1.1.7}/pyproject.toml +11 -8
  18. core_https-1.1.5/core_https/requesters/base.py +0 -24
  19. core_https-1.1.5/core_https/requesters/url_lib3.py +0 -34
  20. {core_https-1.1.5 → core_https-1.1.7}/LICENSE +0 -0
  21. {core_https-1.1.5 → core_https-1.1.7}/README.md +0 -0
  22. {core_https-1.1.5 → core_https-1.1.7}/core_https/__init__.py +0 -0
  23. {core_https-1.1.5 → core_https-1.1.7}/core_https/requesters/__init__.py +0 -0
  24. {core_https-1.1.5 → core_https-1.1.7}/core_https.egg-info/dependency_links.txt +0 -0
  25. {core_https-1.1.5 → core_https-1.1.7}/core_https.egg-info/top_level.txt +0 -0
  26. {core_https-1.1.5 → core_https-1.1.7}/setup.cfg +0 -0
  27. {core_https-1.1.5 → core_https-1.1.7}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: core-https
3
- Version: 1.1.5
3
+ Version: 1.1.7
4
4
  Summary: This project/library contains common elements related to HTTP & API services...
5
5
  Author-email: Alejandro Cora González <alek.cora.glez@gmail.com>
6
6
  Maintainer: Alejandro Cora González
@@ -16,22 +16,25 @@ Classifier: Development Status :: 5 - Production/Stable
16
16
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
17
  Classifier: Topic :: Utilities
18
18
  Classifier: Programming Language :: Python :: 3
19
- Classifier: Programming Language :: Python :: 3.7
20
19
  Classifier: Programming Language :: Python :: 3.8
21
20
  Classifier: Programming Language :: Python :: 3.9
22
21
  Classifier: Programming Language :: Python :: 3.10
23
22
  Classifier: Programming Language :: Python :: 3.11
24
23
  Classifier: Programming Language :: Python :: 3.12
25
24
  Classifier: Programming Language :: Python :: 3.13
26
- Requires-Python: >=3.7
25
+ Requires-Python: >=3.8
27
26
  Description-Content-Type: text/markdown
28
27
  License-File: LICENSE
29
- Requires-Dist: core-mixins>=1.1.2
28
+ Requires-Dist: aiohttp>=3.12.0; python_version >= "3.9"
29
+ Requires-Dist: aiohttp>=3.8.0; python_version >= "3.7" and python_version < "3.9"
30
+ Requires-Dist: core-mixins>=2.0.3
30
31
  Requires-Dist: core-tests>=1.1.0
31
- Requires-Dist: urllib3>=2.2.3; python_version >= "3.8"
32
- Requires-Dist: urllib3>=1.26.20; python_version >= "3.7" and python_version < "3.8"
32
+ Requires-Dist: requests>=2.32.3; python_version >= "3.8"
33
+ Requires-Dist: requests>=2.20.0; python_version >= "3.7" and python_version < "3.8"
33
34
  Requires-Dist: typing-extensions>=4.8.0; python_version >= "3.8" and python_version < "3.11"
34
35
  Requires-Dist: typing-extensions>=4.2.0; python_version >= "3.7" and python_version < "3.8"
36
+ Requires-Dist: urllib3>=2.2.3; python_version >= "3.8"
37
+ Requires-Dist: urllib3>=1.26.20; python_version >= "3.7" and python_version < "3.8"
35
38
  Provides-Extra: docs
36
39
  Requires-Dist: pydata-sphinx-theme==0.16.1; extra == "docs"
37
40
  Requires-Dist: Sphinx==8.2.3; extra == "docs"
@@ -27,7 +27,7 @@ class AuthenticationException(ServiceException):
27
27
 
28
28
  def __init__(
29
29
  self,
30
- status_code: int = 403,
30
+ status_code: int = 401,
31
31
  details: str = "Unauthorized"
32
32
  ) -> None:
33
33
  super().__init__(status_code=status_code, details=details)
@@ -56,3 +56,12 @@ class RateLimitException(ServiceException):
56
56
  details: str = "Too Many Requests"
57
57
  ) -> None:
58
58
  super().__init__(status_code=status_code, details=details)
59
+
60
+
61
+ class RetryableException(InternalServerError):
62
+ """
63
+ Special case for exceptions that are considered retryable
64
+ like: 429, 502, 503 or 504. It's useful when a retry mechanism
65
+ is used, and you want to capture one type of error to trigger
66
+ the retry.
67
+ """
@@ -0,0 +1,178 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+ from typing import Dict
8
+ from typing import Optional
9
+
10
+ try:
11
+ from typing import Self
12
+
13
+ except ImportError:
14
+ from typing_extensions import Self
15
+
16
+ try:
17
+ from http import HTTPMethod
18
+
19
+ except ImportError:
20
+ from core_https.utils import HTTPMethod
21
+
22
+ from aiohttp import (
23
+ ClientResponse,
24
+ ClientResponseError,
25
+ ClientSession,
26
+ ClientTimeout,
27
+ TCPConnector,
28
+ )
29
+
30
+ from .base import IRequester
31
+
32
+
33
+ class AioHttpRequester(IRequester):
34
+ """
35
+ It uses `aiohttp` to make the requests.
36
+
37
+ .. code-block:: python
38
+
39
+ import aiohttp
40
+ from core_https.requesters.aiohttp_ import AioHttpRequester
41
+ from core_https.utils import HTTPMethod
42
+
43
+ requester: AioHttpRequester = AioHttpRequester(raise_for_status=True)
44
+
45
+ async def get():
46
+ # This is optional as the client creates one session for you if not provided.
47
+ session = aiohttp.ClientSession()
48
+
49
+ try:
50
+ response = await requester.request(
51
+ method=HTTPMethod.GET,
52
+ session=session,
53
+ url=url,
54
+ params={
55
+ "x-api-key": "..."
56
+ })
57
+
58
+ return await response.text()
59
+
60
+ except Exception as error:
61
+ pass
62
+
63
+ finally:
64
+ await session.close()
65
+
66
+ res = asyncio.run(get())
67
+ print(res)
68
+ ..
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ session: Optional[ClientSession] = None,
74
+ retries: Optional[int] = 3,
75
+ **kwargs
76
+ ) -> None:
77
+ """
78
+ :param session: The session to use for requests.
79
+ :param retries: Retry strategy to apply. Pass zero (0) to avoid retries.
80
+ """
81
+
82
+ super().__init__(**kwargs)
83
+
84
+ self._session = session
85
+ self._session_lock = asyncio.Lock()
86
+ self._owns_session = session is None
87
+ self._timeout = ClientTimeout(total=self.timeout)
88
+ self.retries = retries
89
+
90
+ async def __aenter__(self) -> Self:
91
+ await self._ensure_session()
92
+ return self
93
+
94
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
95
+ await self.close()
96
+
97
+ @classmethod
98
+ def engine(cls) -> str:
99
+ return "aiohttp"
100
+
101
+ async def _ensure_session(self) -> ClientSession:
102
+ """ If the session doesn't exist, it creates it """
103
+
104
+ if self._session is None:
105
+ async with self._session_lock:
106
+ if self._session is None: # Double-check after acquiring lock...
107
+ self._session = ClientSession(
108
+ timeout=self._timeout,
109
+ connector=TCPConnector(
110
+ limit=self.connector_limit,
111
+ limit_per_host=self.connector_limit_per_host
112
+ ))
113
+
114
+ self._owns_session = True
115
+
116
+ return self._session
117
+
118
+ async def request(
119
+ self,
120
+ url: str,
121
+ method: HTTPMethod = HTTPMethod.GET,
122
+ session: Optional[ClientSession] = None,
123
+ headers: Optional[Dict[str, Any]] = None,
124
+ params: Optional[Dict[str, Any]] = None,
125
+ timeout: Optional[float] = None,
126
+ retries: Optional[int] = None,
127
+ backoff_factor: Optional[int] = None,
128
+ **kwargs
129
+ ) -> ClientResponse:
130
+ """
131
+ It makes the request using the session (externally provided or created
132
+ if required) and return the response...
133
+
134
+ :returns: `aiohttp.ClientResponse` object.
135
+ """
136
+
137
+ session_ = session or await self._ensure_session()
138
+ kwargs_ = kwargs.copy()
139
+
140
+ if timeout is not None:
141
+ kwargs_["timeout"] = ClientTimeout(total=timeout)
142
+
143
+ retries = retries if retries is not None else self.retries
144
+ attempts = 0
145
+
146
+ backoff_factor = (
147
+ backoff_factor
148
+ if backoff_factor is not None
149
+ else self.backoff_factor if self.backoff_factor is not None
150
+ else 0.5
151
+ )
152
+
153
+ while True:
154
+ attempts += 1
155
+
156
+ try:
157
+ response = await session_.request(
158
+ method=str(method),
159
+ url=url,
160
+ headers=headers,
161
+ params=params,
162
+ **kwargs_)
163
+
164
+ if self.raise_for_status:
165
+ response.raise_for_status()
166
+
167
+ return response
168
+
169
+ except ClientResponseError as error:
170
+ if attempts > retries:
171
+ self.raise_custom_exception(error.status, error.message)
172
+
173
+ await asyncio.sleep(backoff_factor * attempts)
174
+
175
+ async def close(self):
176
+ if self._session and self._owns_session:
177
+ await self._session.close()
178
+ self._session = None
@@ -0,0 +1,161 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ import re
4
+ from abc import ABC
5
+ from abc import abstractmethod
6
+ from typing import Any, Dict, Optional
7
+
8
+ from core_mixins.interfaces.factory import IFactory
9
+
10
+ from core_https.exceptions import (
11
+ AuthenticationException,
12
+ AuthorizationException,
13
+ InternalServerError,
14
+ RateLimitException,
15
+ RetryableException,
16
+ ServiceException,
17
+ )
18
+
19
+ try:
20
+ from http import HTTPMethod
21
+
22
+ except ImportError:
23
+ from core_https.utils import HTTPMethod
24
+
25
+
26
+ class IRequester(IFactory, ABC):
27
+ """ Base interface for all type of HTTP requesters """
28
+
29
+ def __init__(
30
+ self,
31
+ encoding: str = "utf-8",
32
+ raise_for_status: bool = False,
33
+ retries: Optional[Any] = None,
34
+ backoff_factor: Optional[int] = None,
35
+ connector_limit: int = 100,
36
+ connector_limit_per_host: int = 30,
37
+ timeout: int = 10,
38
+ ) -> None:
39
+ """
40
+ :param encoding: The encoding to use when decoding.
41
+ :param raise_for_status: If True, `raise_for_status` will be executed.
42
+ :param connector_limit: Maximum number of connections in pool.
43
+ :param connector_limit_per_host: Maximum connections per host.
44
+ :param timeout: How many seconds to wait for the server to send data.
45
+ :param retries: Retry strategy to apply. Provide it here, to avoid passing it to each request.
46
+ :param backoff_factor: Delay between successive retry attempts.
47
+ """
48
+
49
+ if timeout <= 0:
50
+ raise ValueError("`timeout` must be positive!")
51
+
52
+ if connector_limit <= 0:
53
+ raise ValueError("`connector_limit` must be positive!")
54
+
55
+ if connector_limit_per_host <= 0:
56
+ raise ValueError("`connector_limit_per_host` must be positive!")
57
+
58
+ if connector_limit_per_host > connector_limit:
59
+ raise ValueError("`connector_limit_per_host` cannot exceed `connector_limit`!")
60
+
61
+ self.backoff_factor = backoff_factor
62
+ self.retries = retries
63
+
64
+ self.encoding = encoding
65
+ self.raise_for_status = raise_for_status
66
+ self.timeout = timeout
67
+
68
+ self.connector_limit = connector_limit
69
+ self.connector_limit_per_host = connector_limit_per_host
70
+
71
+ @classmethod
72
+ def registration_key(cls) -> str:
73
+ return cls.engine()
74
+
75
+ @classmethod
76
+ @abstractmethod
77
+ def engine(cls) -> str:
78
+ """ Must return the engine name like: `requests` or `urllib3` """
79
+
80
+ @abstractmethod
81
+ def request(
82
+ self,
83
+ url: str,
84
+ method: HTTPMethod,
85
+ headers: Optional[Dict[str, str]] = None,
86
+ retries: Optional[Any] = None,
87
+ backoff_factor: Optional[int] = None,
88
+ **kwargs # Each `engine`have its own attributes...
89
+ ) -> Any:
90
+ """
91
+ Makes the request and returns the response.
92
+
93
+ :param url: The url for the request.
94
+ :param method: The method (verb) for the request.
95
+ :param headers: The headers to add.
96
+
97
+ :param retries:
98
+ It defines the retry strategy. Pass `False` if you don't want to use a
99
+ retry at all, otherwise a default one will be provided.
100
+
101
+ :param backoff_factor: Delay between successive retry attempts.
102
+
103
+ :param kwargs:
104
+ Each concrete implementation will have its own attributes. Depending on
105
+ the requester you are using, you can pass different attributes.
106
+ """
107
+
108
+ def _get_response_encoding(
109
+ self,
110
+ headers: Dict[str, str],
111
+ default="utf-8",
112
+ ) -> str:
113
+ headers_ = {
114
+ k.lower(): v
115
+ for k, v in headers.items()
116
+ }
117
+
118
+ # First trying "charset" header directly (rare)...
119
+ charset = headers_.get("charset")
120
+ if charset:
121
+ return charset.strip()
122
+
123
+ # Then checking `Content-Type` for "charset="
124
+ content_type = headers_.get("content-type", "")
125
+ match = re.search(r"charset=([^\s;]+)", content_type, re.IGNORECASE)
126
+ if match:
127
+ return match.group(1).strip()
128
+
129
+ return self.encoding or default
130
+
131
+ @staticmethod
132
+ def raise_custom_exception(status_code: int, details: str):
133
+ """
134
+ :raises: `ServiceException` for other 4XX status_code.
135
+ :raises: `AuthenticationException` for status_code == 401.
136
+ :raises: `AuthorizationException` for status_code == 403.
137
+ :raises: `RateLimitException` for status_code == 429.
138
+ :raises: `InternalServerError` for status_code >= 500.
139
+ """
140
+
141
+ error_cls = ServiceException
142
+
143
+ if status_code == 401:
144
+ error_cls = AuthenticationException
145
+
146
+ elif status_code == 403:
147
+ error_cls = AuthorizationException
148
+
149
+ elif status_code == 429:
150
+ error_cls = RateLimitException
151
+
152
+ elif status_code in (429, 502, 503, 504):
153
+ error_cls = RetryableException
154
+
155
+ elif status_code >= 500:
156
+ error_cls = InternalServerError
157
+
158
+ raise error_cls(
159
+ status_code=status_code,
160
+ details=details,
161
+ )
@@ -0,0 +1,116 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ from typing import Dict, Optional
4
+
5
+ import requests
6
+ import urllib3
7
+ from requests.adapters import HTTPAdapter
8
+
9
+ from core_https.requesters.base import IRequester
10
+ from core_https.utils import HTTPMethod
11
+
12
+
13
+ class RequestsRequester(IRequester):
14
+ """
15
+ It uses `requests` to make the requests.
16
+
17
+ .. code-block:: python
18
+
19
+ from core_https.requesters.requests_ import RequestsRequester
20
+ from core_https.utils import HTTPMethod
21
+
22
+ requester: RequestsRequester = RequestsRequester()
23
+
24
+ response = requester.request(
25
+ method=HTTPMethod.GET,
26
+ url=url,
27
+ params={
28
+ "x-api-key": "..."
29
+ })
30
+
31
+ print(response.json())
32
+ ..
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ session: Optional[requests.Session] = None,
38
+ retries: Optional[urllib3.Retry] = None,
39
+ backoff_factor: Optional[int] = None,
40
+ **kwargs
41
+ ) -> None:
42
+ """
43
+ :param session: Session to use for the requests.
44
+ :param retries: Retry strategy to apply. Pass zero (0) to avoid retries.
45
+ """
46
+
47
+ super().__init__(**kwargs)
48
+ self.retries = retries
49
+
50
+ backoff_factor = (
51
+ backoff_factor
52
+ if backoff_factor is not None
53
+ else self.backoff_factor if self.backoff_factor is not None
54
+ else 0.5
55
+ )
56
+
57
+ if self.retries is None:
58
+ self.retries = urllib3.Retry(
59
+ status_forcelist=[429, 502, 503, 504],
60
+ backoff_factor=backoff_factor,
61
+ total=3)
62
+
63
+ adapter = HTTPAdapter(
64
+ pool_connections=self.connector_limit,
65
+ pool_maxsize=self.connector_limit_per_host,
66
+ max_retries=self.retries)
67
+
68
+ if session is None:
69
+ session = requests.Session()
70
+ session.mount("https://", adapter)
71
+ session.mount("http://", adapter)
72
+
73
+ self._session = session
74
+
75
+ @classmethod
76
+ def engine(cls) -> str:
77
+ return "requests"
78
+
79
+ def request(
80
+ self,
81
+ url: str,
82
+ method: HTTPMethod = HTTPMethod.GET,
83
+ session: Optional[requests.Session] = None,
84
+ headers: Optional[Dict] = None,
85
+ params: Optional[Dict] = None,
86
+ timeout: Optional[float] = None,
87
+ **kwargs
88
+ ) -> requests.Response:
89
+ """
90
+ It makes the request using the session (externally provided or created
91
+ if required) and return the response...
92
+
93
+ :returns: `requests.Response` object.
94
+ """
95
+
96
+ session_ = session or self._session
97
+ if params is None:
98
+ params = {}
99
+
100
+ response = session_.request(
101
+ method=method.value,
102
+ url=url,
103
+ headers=headers,
104
+ params=params,
105
+ timeout=timeout,
106
+ **kwargs)
107
+
108
+ try:
109
+ if self.raise_for_status:
110
+ response.raise_for_status()
111
+
112
+ except requests.exceptions.HTTPError:
113
+ status_code, info = response.status_code, response.text
114
+ self.raise_custom_exception(status_code, info)
115
+
116
+ return response
@@ -0,0 +1,117 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ import json
4
+ from contextlib import suppress
5
+ from typing import Dict, Optional
6
+
7
+ try:
8
+ from http import HTTPMethod
9
+
10
+ except ImportError:
11
+ from core_https.utils import HTTPMethod
12
+
13
+ from urllib3 import BaseHTTPResponse
14
+ from urllib3 import PoolManager
15
+ from urllib3 import Retry
16
+
17
+ from .base import IRequester
18
+
19
+
20
+ class Urllib3Requester(IRequester):
21
+ """
22
+ It uses `urllib3` to make the requests.
23
+
24
+ .. code-block:: python
25
+
26
+ from core_https.requesters.urllib3_ import Urllib3Requester
27
+ from core_https.utils import HTTPMethod
28
+
29
+ requester: Urllib3Requester = IRequester.get_class(Urllib3Requester.engine())()
30
+ response = requester.request(method=HTTPMethod.GET, url="https://google.com")
31
+ print(response.data.decode())
32
+ ..
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ pool_manager: Optional[PoolManager] = None,
38
+ retries: Optional[Retry] = None,
39
+ **kwargs
40
+ ) -> None:
41
+ """
42
+ :param pool_manager: The pool manager to use or one will be created.
43
+ :param retries: Retry strategy to apply. Pass zero (0) to avoid retries.
44
+ """
45
+
46
+ super().__init__(**kwargs)
47
+
48
+ if not pool_manager:
49
+ pool_manager = PoolManager()
50
+
51
+ self._http: PoolManager = pool_manager
52
+ self.retries = retries
53
+
54
+ @classmethod
55
+ def engine(cls):
56
+ return "urllib3"
57
+
58
+ def request(
59
+ self,
60
+ url: str,
61
+ method: HTTPMethod = HTTPMethod.GET,
62
+ headers: Optional[Dict] = None,
63
+ fields: Optional[Dict] = None,
64
+ retries: Optional[Retry] = None,
65
+ backoff_factor: Optional[int] = None,
66
+ timeout: Optional[float] = None,
67
+ **kwargs
68
+ ) -> BaseHTTPResponse:
69
+ """
70
+ :raises: `ServiceException` for other 4XX status_code.
71
+ :raises: `AuthenticationException` for status_code == 401.
72
+ :raises: `AuthorizationException` for status_code == 403.
73
+ :raises: `InternalServerError` for status_code >= 500.
74
+
75
+ :returns: `HTTPResponse` object.
76
+ """
77
+
78
+ backoff_factor = (
79
+ backoff_factor
80
+ if backoff_factor is not None
81
+ else self.backoff_factor if self.backoff_factor is not None
82
+ else 0.5
83
+ )
84
+
85
+ retries_ = retries or self.retries
86
+ if retries_ is None:
87
+ retries_ = Retry(
88
+ status_forcelist=[429, 502, 503, 504],
89
+ backoff_factor=backoff_factor,
90
+ total=3)
91
+
92
+ response = self._http.request(
93
+ method=str(method),
94
+ url=url,
95
+ headers=headers,
96
+ fields=fields,
97
+ timeout=timeout or self.timeout,
98
+ retries=retries_,
99
+ **kwargs)
100
+
101
+ status_code = response.status
102
+
103
+ if status_code >= 400 and self.raise_for_status:
104
+ info = response.data.decode(self._get_response_encoding(response.headers))
105
+ headers_ = {k.lower(): v for k, v in response.headers.items()}
106
+
107
+ if "application/json" in headers_.get("content-type", ""):
108
+ if hasattr(response, "json"):
109
+ info = response.json()
110
+
111
+ else:
112
+ with suppress(json.JSONDecodeError):
113
+ info = json.loads(info)
114
+
115
+ self.raise_custom_exception(status_code, info)
116
+
117
+ return response
File without changes