http-dynamix 1.0.0__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.
@@ -0,0 +1,38 @@
1
+ """Copyright (c) 2024, Aydin A.
2
+
3
+ This module is the entry point for the http_dynamix package. It imports the
4
+ version number and the logger from the log module. It also imports the
5
+ HttpClientFactory class from the client_factory module.
6
+ """
7
+ from http_dynamix.factory import ClientFactory
8
+ from http_dynamix.enums import SegmentFormat, ClientType
9
+ from http_dynamix._version import version
10
+ from http_dynamix.auth import BearerAuth, ApiKeyAuth
11
+ from http_dynamix.log import log as logger, LogMaster
12
+ from http_dynamix.core import SegmentFormatter
13
+ from http_dynamix.clients import SyncClient, AsyncClient, AsyncDynamicClient, SyncDynamicClient
14
+
15
+
16
+ __all__ = [
17
+ "ClientFactory",
18
+ "ClientType",
19
+ "SegmentFormat",
20
+ "logger",
21
+ "LogMaster",
22
+ "version",
23
+ "BearerAuth",
24
+ "ApiKeyAuth"
25
+ "SegmentFormatter",
26
+ "BaseClient",
27
+ "BaseDynamicClient",
28
+ "SyncClient",
29
+ "AsyncClient",
30
+ "AsyncDynamicClient",
31
+ "SyncDynamicClient"
32
+ ]
33
+
34
+
35
+ __author__ = "Aydin Abdi"
36
+ __version__ = version
37
+ __license__ = "MIT"
38
+
@@ -0,0 +1,21 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
5
+
6
+ TYPE_CHECKING = False
7
+ if TYPE_CHECKING:
8
+ from typing import Tuple
9
+ from typing import Union
10
+
11
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
12
+ else:
13
+ VERSION_TUPLE = object
14
+
15
+ version: str
16
+ __version__: str
17
+ __version_tuple__: VERSION_TUPLE
18
+ version_tuple: VERSION_TUPLE
19
+
20
+ __version__ = version = '1.0.0'
21
+ __version_tuple__ = version_tuple = (1, 0, 0)
http_dynamix/auth.py ADDED
@@ -0,0 +1,194 @@
1
+ """Authentication classes for dynamic HTTP client.
2
+
3
+ This module provides various authentication methods compatible with HTTPX client.
4
+ All authentication classes inherit from httpx.Auth and implement the required auth_flow
5
+ method for proper request authentication.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Generator
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from http_dynamix.log import log as logger
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class BearerAuth(httpx.Auth):
21
+ """Bearer token authentication method.
22
+
23
+ Implements Bearer token authentication as defined in RFC 6750.
24
+
25
+ Args:
26
+ token: The bearer token for authentication.
27
+ auth_header: Custom authorization header name (default: "Authorization").
28
+ """
29
+
30
+ token: str
31
+ auth_header: str = "Authorization"
32
+
33
+ def auth_flow(
34
+ self, request: httpx.Request
35
+ ) -> Generator[httpx.Request, httpx.Response, None]:
36
+ """Yields authenticated requests using Bearer auth scheme.
37
+
38
+ Args:
39
+ request: The HTTP request object to authenticate.
40
+
41
+ Yields:
42
+ httpx.Request: The authenticated HTTP request.
43
+ """
44
+ request.headers[self.auth_header] = self._build_auth_header()
45
+ logger.debug("Applied Bearer token auth")
46
+ yield request
47
+
48
+ def _build_auth_header(self) -> str:
49
+ """Builds the Authorization header with Bearer token.
50
+
51
+ Returns:
52
+ str: The formatted Authorization header.
53
+ """
54
+ return f"Bearer {self.token}"
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class ApiKeyAuth(httpx.Auth):
59
+ """API key authentication method.
60
+
61
+ Implements API key authentication using custom header.
62
+
63
+ Args:
64
+ api_key: The API key for authentication.
65
+ header_name: The header name for the API key (default: "X-API-Key").
66
+ """
67
+
68
+ api_key: str
69
+ header_name: str = "X-API-Key"
70
+
71
+ def auth_flow(
72
+ self, request: httpx.Request
73
+ ) -> Generator[httpx.Request, httpx.Response, None]:
74
+ """Yields authenticated requests using API key.
75
+
76
+ Args:
77
+ request: The HTTP request object to authenticate.
78
+
79
+ Yields:
80
+ httpx.Request: The authenticated HTTP request.
81
+ """
82
+ request.headers[self.header_name] = self.api_key
83
+ logger.debug(f"Applied API key auth with header: {self.header_name}")
84
+ yield request
85
+
86
+
87
+ @dataclass(slots=True)
88
+ class MultiAuth(httpx.Auth):
89
+ """Multi-authentication method that combines multiple auth schemes.
90
+
91
+ Allows chaining multiple authentication methods to be applied in sequence.
92
+
93
+ Args:
94
+ auth_methods: List of authentication methods implementing httpx.Auth.
95
+ """
96
+
97
+ auth_methods: list[httpx.Auth] = field(default_factory=list)
98
+
99
+ def auth_flow(
100
+ self, request: httpx.Request
101
+ ) -> Generator[httpx.Request, httpx.Response, None]:
102
+ """Yields authenticated requests applying multiple auth methods.
103
+
104
+ Applies each authentication method in sequence to the request.
105
+
106
+ Args:
107
+ request: The HTTP request object to authenticate.
108
+
109
+ Yields:
110
+ httpx.Request: The authenticated HTTP request.
111
+ """
112
+ current_request = request
113
+ for auth in self.auth_methods:
114
+ auth_flow = auth.auth_flow(current_request)
115
+ try:
116
+ while True:
117
+ current_request = next(auth_flow)
118
+ response = yield current_request
119
+ auth_flow.send(response)
120
+ except StopIteration:
121
+ pass
122
+ logger.debug(f"Applied {len(self.auth_methods)} authentication methods")
123
+
124
+
125
+ # Auth-related configuration keys
126
+ AUTH_KEYS = frozenset(
127
+ ["token", "api_key", "username", "password", "auth_header", "api_key_header"]
128
+ )
129
+
130
+
131
+ def filter_auth_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
132
+ """Filter kwargs to only include auth-related arguments.
133
+
134
+ Args:
135
+ kwargs: Original kwargs dictionary
136
+
137
+ Returns:
138
+ Dictionary containing only auth-related arguments
139
+ """
140
+ return {k: v for k, v in kwargs.items() if k in AUTH_KEYS}
141
+
142
+
143
+ def create_auth(
144
+ token: str | None = None,
145
+ api_key: str | None = None,
146
+ username: str | bytes | None = None,
147
+ password: str | bytes | None = None,
148
+ auth_header: str = "Authorization",
149
+ api_key_header: str = "X-API-Key",
150
+ ) -> MultiAuth | None:
151
+ """Factory function to create authentication method based on provided credentials.
152
+
153
+ Args:
154
+ token: Bearer token for authentication.
155
+ api_key: API key for authentication.
156
+ username: Username for basic authentication.
157
+ password: Password for basic authentication.
158
+ auth_header: Custom authorization header name (default: "Authorization").
159
+ api_key_header: Custom API key header name (default: "X-API-Key").
160
+
161
+ Returns:
162
+ An instance of appropriate Auth class or None if no credentials provided.
163
+ """
164
+ auth_methods: list[httpx.Auth] = []
165
+
166
+ if not any([token, api_key, username, password]):
167
+ return None
168
+
169
+ if token:
170
+ auth_methods.append(BearerAuth(token=token, auth_header=auth_header))
171
+
172
+ if api_key:
173
+ auth_methods.append(ApiKeyAuth(api_key=api_key, header_name=api_key_header))
174
+
175
+ if username and password:
176
+ auth_methods.append(httpx.BasicAuth(username=username, password=password))
177
+
178
+ return MultiAuth(auth_methods) if auth_methods else None
179
+
180
+
181
+ def prepare_auth(**kwargs: Any) -> httpx.Auth | None:
182
+ """Prepares the authentication method for use in HTTPX client.
183
+
184
+ Args:
185
+ **kwargs: Keyword arguments containing authentication credentials.
186
+
187
+ Returns:
188
+ An instance of appropriate Auth class or None if no credentials provided.
189
+ """
190
+ auth_kwargs = filter_auth_kwargs(kwargs)
191
+ auth = create_auth(**auth_kwargs)
192
+ if auth:
193
+ logger.debug(f"Prepared authentication: {type(auth).__name__}")
194
+ return auth
@@ -0,0 +1,9 @@
1
+ from http_dynamix.clients.async_client import AsyncClient, AsyncDynamicClient
2
+ from http_dynamix.clients.sync_client import SyncClient, SyncDynamicClient
3
+
4
+ __all__ = [
5
+ "SyncClient",
6
+ "SyncDynamicClient",
7
+ "AsyncClient",
8
+ "AsyncDynamicClient",
9
+ ]
@@ -0,0 +1,313 @@
1
+ """Async HTTP client with dynamic path creation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Coroutine
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, cast
8
+
9
+ import httpx
10
+ from httpx import Response
11
+
12
+ from http_dynamix.core import PathSegment, SegmentFormatter
13
+ from http_dynamix.enums import HTTPMethod, SegmentFormat
14
+ from http_dynamix.httpx_logger import loggix
15
+ from http_dynamix.log import log as logger
16
+ from http_dynamix.protocols import AsyncClientProtocol
17
+
18
+
19
+ @dataclass
20
+ class AsyncDynamicClient(AsyncClientProtocol):
21
+ """Asynchronous dynamic HTTP client.
22
+
23
+ This class allows for dynamic path creation and HTTP requests.
24
+
25
+ Example:
26
+ .. code-block:: python
27
+
28
+ async with AsyncClient("https://api.example.com") as client:
29
+ response = await client.users.john.get()
30
+ print(response.json())
31
+ """
32
+
33
+ client: AsyncClient
34
+ segments: list[PathSegment] = field(default_factory=list)
35
+ segment_format: SegmentFormat = SegmentFormat.DEFAULT
36
+ known_paths: dict[str, str] = field(default_factory=dict)
37
+
38
+ def _transform_path(self) -> str:
39
+ transformed_segments = []
40
+
41
+ for segment in self.segments:
42
+ if segment.value is not None:
43
+ transformed_segments.append(str(segment.value))
44
+ else:
45
+ segment_name = segment.name
46
+ transformed = SegmentFormatter(
47
+ self.segment_format, self.known_paths
48
+ ).transform(segment_name)
49
+ transformed_segments.append(transformed)
50
+
51
+ return "/".join(transformed_segments)
52
+
53
+ def __getattr__(self, name: str) -> AsyncDynamicClient:
54
+ """Handle attribute access for dynamic path creation.
55
+
56
+ Args:
57
+ name: The name of the attribute.
58
+
59
+ Returns:
60
+ The dynamic client.
61
+ """
62
+ new_segments = self.segments.copy()
63
+ new_segments.append(PathSegment(name=name, format=self.segment_format))
64
+
65
+ return self.__class__(
66
+ client=self.client,
67
+ segments=new_segments,
68
+ segment_format=self.segment_format,
69
+ known_paths=self.known_paths,
70
+ )
71
+
72
+ def __getitem__(self, key: str | int | SegmentFormat) -> AsyncDynamicClient:
73
+ """Handle item access for dynamic path creation.
74
+
75
+ Args:
76
+ key: The key to access.
77
+
78
+ Returns:
79
+ The dynamic client.
80
+ """
81
+ if not self.segments:
82
+ raise ValueError("Cannot use [] operator without a path segment")
83
+
84
+ new_segments = self.segments.copy()
85
+
86
+ # If the key is a SegmentFormat, update the last segment with the format
87
+ if isinstance(key, SegmentFormat):
88
+ last_segment = new_segments.pop()
89
+ new_segments.append(last_segment.with_format(key))
90
+ else:
91
+ # Otherwise treat the key as a parameter value
92
+ last_segment = new_segments.pop()
93
+ new_segments.append(
94
+ PathSegment(
95
+ name=last_segment.name, format=last_segment.format, value=key
96
+ )
97
+ )
98
+
99
+ return self.__class__(
100
+ client=self.client,
101
+ segments=new_segments,
102
+ segment_format=self.segment_format,
103
+ known_paths=self.known_paths,
104
+ )
105
+
106
+ def with_format(self, format: SegmentFormat) -> AsyncDynamicClient:
107
+ """Set the segment format.
108
+
109
+ Args:
110
+ format: The segment format.
111
+
112
+ Returns:
113
+ The dynamic client.
114
+ """
115
+ return self.__class__(
116
+ client=self.client,
117
+ segments=self.segments,
118
+ segment_format=format,
119
+ known_paths=self.known_paths,
120
+ )
121
+
122
+ def _get_url(self) -> str:
123
+ url = f"{self.client.base_url}/{self._transform_path()}".strip("/")
124
+ logger.debug(f"Constructed URL: {url}")
125
+ return str(httpx.URL(url))
126
+
127
+ async def request(
128
+ self,
129
+ method: HTTPMethod,
130
+ **kwargs: Any,
131
+ ) -> Coroutine[Any, Any, Response]:
132
+ """Make an HTTP request.
133
+
134
+ Args:
135
+ method: The HTTP method to use.
136
+ **kwargs: Additional keyword arguments to pass to the request.
137
+
138
+ Returns:
139
+ The response from the request.
140
+ """
141
+ url = self._get_url()
142
+
143
+ # Remove None values
144
+ request_kwargs = {k: v for k, v in kwargs.items() if v is not None}
145
+
146
+ logger.debug(f"{method.value} request to {url} with {request_kwargs!r}")
147
+ response = await self.client._client.request(
148
+ method.value, url, **request_kwargs
149
+ )
150
+ response.raise_for_status()
151
+ loggix(response)
152
+ return cast(Coroutine[Any, Any, Response], response)
153
+
154
+ async def aclose(self) -> None:
155
+ """Close the client."""
156
+ await self.client.aclose() # pragma: no cover
157
+
158
+ async def get(self, **kwargs: Any) -> Any:
159
+ """Make a GET request.
160
+
161
+ Args:
162
+ **kwargs: Additional keyword arguments to pass to the request.
163
+
164
+ Returns:
165
+ The response from the request.
166
+ """
167
+ return await self.request(HTTPMethod.GET, **kwargs)
168
+
169
+ async def post(self, **kwargs: Any) -> Any:
170
+ """Make a POST request.
171
+
172
+ Args:
173
+ **kwargs: Additional keyword arguments to pass to the request.
174
+
175
+ Returns:
176
+ The response from the request.
177
+ """
178
+ return await self.request(HTTPMethod.POST, **kwargs)
179
+
180
+ async def put(self, **kwargs: Any) -> Any:
181
+ """Make a PUT request.
182
+
183
+ Args:
184
+ **kwargs: Additional keyword arguments to pass to the request.
185
+
186
+ Returns:
187
+ The response from the request.
188
+ """
189
+ return await self.request(HTTPMethod.PUT, **kwargs)
190
+
191
+ async def delete(self, **kwargs: Any) -> Any:
192
+ """Make a DELETE request.
193
+
194
+ Args:
195
+ **kwargs: Additional keyword arguments to pass to the request.
196
+
197
+ Returns:
198
+ The response from the request.
199
+ """
200
+ return await self.request(HTTPMethod.DELETE, **kwargs)
201
+
202
+ async def patch(self, **kwargs: Any) -> Any:
203
+ """Make a PATCH request.
204
+
205
+ Args:
206
+ **kwargs: Additional keyword arguments to pass to the request.
207
+
208
+ Returns:
209
+ The response from the request.
210
+ """
211
+ return await self.request(HTTPMethod.PATCH, **kwargs)
212
+
213
+ async def head(self, **kwargs: Any) -> Any:
214
+ """Make a HEAD request.
215
+
216
+ Args:
217
+ **kwargs: Additional keyword arguments to pass to the request.
218
+
219
+ Returns:
220
+ The response from the request.
221
+ """
222
+ return await self.request(HTTPMethod.HEAD, **kwargs)
223
+
224
+ async def options(self, **kwargs: Any) -> Any:
225
+ """Make an OPTIONS request.
226
+
227
+ Args:
228
+ **kwargs: Additional keyword arguments to pass to the request.
229
+
230
+ Returns:
231
+ The response from the request.
232
+ """
233
+ return await self.request(HTTPMethod.OPTIONS, **kwargs)
234
+
235
+ async def trace(self, **kwargs: Any) -> Any:
236
+ """Make a TRACE request.
237
+
238
+ Args:
239
+ **kwargs: Additional keyword arguments to pass to the request.
240
+
241
+ Returns:
242
+ The response from the request.
243
+ """
244
+ return await self.request(HTTPMethod.TRACE, **kwargs)
245
+
246
+ async def connect(self, **kwargs: Any) -> Any:
247
+ """Make a CONNECT request.
248
+
249
+ Args:
250
+ **kwargs: Additional keyword arguments to pass to the request.
251
+
252
+ Returns:
253
+ The response from the request.
254
+ """
255
+ return await self.request(HTTPMethod.CONNECT, **kwargs)
256
+
257
+
258
+ @dataclass
259
+ class AsyncClient:
260
+ """Asynchronous HTTP client.
261
+
262
+ This class allows for making HTTP requests.
263
+
264
+ Args:
265
+ base_url: The base URL for the client.
266
+ segment_format: The segment format to use.
267
+ known_paths: A dictionary of known paths.
268
+ client_kwargs: Additional keyword arguments to pass to the HTTP client.
269
+ """
270
+
271
+ base_url: str
272
+ segment_format: SegmentFormat = SegmentFormat.DEFAULT
273
+ known_paths: dict[str, str] = field(default_factory=dict)
274
+ client_kwargs: dict[str, Any] = field(default_factory=dict)
275
+
276
+ _client: httpx.AsyncClient = field(init=False)
277
+
278
+ def __post_init__(self) -> None:
279
+ """Initialize the HTTP client."""
280
+ self.client_kwargs["base_url"] = self.base_url
281
+ self._client = httpx.AsyncClient(**self.client_kwargs)
282
+
283
+ async def aclose(self) -> None:
284
+ """Close the client."""
285
+ await self._client.aclose()
286
+
287
+ async def __aenter__(self) -> AsyncClient:
288
+ """Enter the client.
289
+
290
+ Returns:
291
+ The client.
292
+ """
293
+ return self
294
+
295
+ async def __aexit__(self, *args: Any) -> None:
296
+ """Exit the client."""
297
+ await self.aclose()
298
+
299
+ def __getattr__(self, name: str) -> AsyncDynamicClient:
300
+ """Get an attribute.
301
+
302
+ Args:
303
+ name: The name of the attribute.
304
+
305
+ Returns:
306
+ The dynamic client.
307
+ """
308
+ return AsyncDynamicClient(
309
+ client=self,
310
+ segments=[PathSegment(name=name, format=self.segment_format)],
311
+ segment_format=self.segment_format,
312
+ known_paths=self.known_paths,
313
+ )