jararaca 0.3.11a16__py3-none-any.whl → 0.3.12__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 jararaca might be problematic. Click here for more details.

@@ -1,3 +1,4 @@
1
+ import time
1
2
  from urllib.parse import urljoin
2
3
 
3
4
  import httpx
@@ -7,34 +8,63 @@ from jararaca.rpc.http.decorators import (
7
8
  HttpRPCRequest,
8
9
  HttpRPCResponse,
9
10
  RPCRequestNetworkError,
11
+ TimeoutException,
10
12
  )
11
13
 
12
14
 
13
15
  class HTTPXHttpRPCAsyncBackend(HttpRPCAsyncBackend):
14
16
 
15
- def __init__(self, prefix_url: str = ""):
17
+ def __init__(self, prefix_url: str = "", default_timeout: float = 30.0):
16
18
  self.prefix_url = prefix_url
19
+ self.default_timeout = default_timeout
17
20
 
18
21
  async def request(
19
22
  self,
20
23
  request: HttpRPCRequest,
21
24
  ) -> HttpRPCResponse:
22
25
 
23
- async with httpx.AsyncClient() as client:
26
+ start_time = time.time()
27
+
28
+ # Prepare timeout
29
+ timeout = (
30
+ request.timeout if request.timeout is not None else self.default_timeout
31
+ )
32
+
33
+ # Prepare request kwargs
34
+ request_kwargs = {
35
+ "method": request.method,
36
+ "url": urljoin(self.prefix_url, request.url),
37
+ "headers": request.headers,
38
+ "params": request.query_params,
39
+ "timeout": timeout,
40
+ }
41
+
42
+ # Handle different content types
43
+ if request.form_data and request.files:
44
+ # Multipart form data with files
45
+ request_kwargs["data"] = request.form_data
46
+ request_kwargs["files"] = request.files
47
+ elif request.form_data:
48
+ # Form data only
49
+ request_kwargs["data"] = request.form_data
50
+ elif request.body:
51
+ # Raw body content
52
+ request_kwargs["content"] = request.body
24
53
 
54
+ async with httpx.AsyncClient() as client:
25
55
  try:
26
- response = await client.request(
27
- method=request.method,
28
- url=urljoin(self.prefix_url, request.url),
29
- headers=request.headers,
30
- params=request.query_params,
31
- content=request.body,
32
- )
56
+ response = await client.request(**request_kwargs) # type: ignore[arg-type]
57
+
58
+ elapsed_time = time.time() - start_time
33
59
 
34
60
  return HttpRPCResponse(
35
61
  status_code=response.status_code,
36
62
  data=response.content,
63
+ headers=dict(response.headers),
64
+ elapsed_time=elapsed_time,
37
65
  )
66
+ except httpx.TimeoutException as err:
67
+ raise TimeoutException(f"Request timed out: {err}") from err
38
68
  except httpx.NetworkError as err:
39
69
  raise RPCRequestNetworkError(
40
70
  request=request, backend_request=err.request
@@ -1,11 +1,16 @@
1
+ import asyncio
1
2
  import inspect
3
+ import json
4
+ import time
2
5
  from dataclasses import dataclass
3
6
  from typing import (
4
7
  Any,
5
8
  Awaitable,
6
9
  Callable,
10
+ Dict,
7
11
  Iterable,
8
12
  Literal,
13
+ Optional,
9
14
  Protocol,
10
15
  Type,
11
16
  TypeVar,
@@ -15,6 +20,11 @@ from typing import (
15
20
  from pydantic import BaseModel
16
21
 
17
22
  DECORATED_FUNC = TypeVar("DECORATED_FUNC", bound=Callable[..., Awaitable[Any]])
23
+ DECORATED_CLASS = TypeVar("DECORATED_CLASS", bound=Any)
24
+
25
+
26
+ class TimeoutException(Exception):
27
+ """Exception raised when a request times out"""
18
28
 
19
29
 
20
30
  class HttpMapping:
@@ -95,7 +105,9 @@ class RequestAttribute:
95
105
  return []
96
106
 
97
107
  def __init__(
98
- self, attribute_type: Literal["query", "header", "body", "param"], name: str
108
+ self,
109
+ attribute_type: Literal["query", "header", "body", "param", "form", "file"],
110
+ name: str,
99
111
  ):
100
112
  self.attribute_type = attribute_type
101
113
  self.name = name
@@ -129,7 +141,105 @@ class PathParam(RequestAttribute):
129
141
  super().__init__("param", name)
130
142
 
131
143
 
132
- DECORATED_CLASS = TypeVar("DECORATED_CLASS", bound=Any)
144
+ class FormData(RequestAttribute):
145
+ """Decorator for form data parameters"""
146
+
147
+ def __init__(self, name: str):
148
+ super().__init__("form", name)
149
+
150
+
151
+ class File(RequestAttribute):
152
+ """Decorator for file upload parameters"""
153
+
154
+ def __init__(self, name: str):
155
+ super().__init__("file", name)
156
+
157
+
158
+ class Timeout:
159
+ """Decorator for setting request timeout"""
160
+
161
+ TIMEOUT_ATTR = "__request_timeout__"
162
+
163
+ def __init__(self, seconds: float):
164
+ self.seconds = seconds
165
+
166
+ def __call__(self, func: DECORATED_FUNC) -> DECORATED_FUNC:
167
+ setattr(func, self.TIMEOUT_ATTR, self)
168
+ return func
169
+
170
+ @staticmethod
171
+ def get(func: DECORATED_FUNC) -> Optional["Timeout"]:
172
+ return getattr(func, Timeout.TIMEOUT_ATTR, None)
173
+
174
+
175
+ class RetryConfig:
176
+ """Configuration for retry behavior"""
177
+
178
+ def __init__(
179
+ self,
180
+ max_attempts: int = 3,
181
+ backoff_factor: float = 1.0,
182
+ retry_on_status_codes: Optional[list[int]] = None,
183
+ ):
184
+ self.max_attempts = max_attempts
185
+ self.backoff_factor = backoff_factor
186
+ self.retry_on_status_codes = retry_on_status_codes or [500, 502, 503, 504]
187
+
188
+
189
+ class Retry:
190
+ """Decorator for retry configuration"""
191
+
192
+ RETRY_ATTR = "__request_retry__"
193
+
194
+ def __init__(self, config: RetryConfig):
195
+ self.config = config
196
+
197
+ def __call__(self, func: DECORATED_FUNC) -> DECORATED_FUNC:
198
+ setattr(func, self.RETRY_ATTR, self)
199
+ return func
200
+
201
+ @staticmethod
202
+ def get(func: DECORATED_FUNC) -> Optional["Retry"]:
203
+ return getattr(func, Retry.RETRY_ATTR, None)
204
+
205
+
206
+ class ContentType:
207
+ """Decorator for specifying content type"""
208
+
209
+ CONTENT_TYPE_ATTR = "__content_type__"
210
+
211
+ def __init__(self, content_type: str):
212
+ self.content_type = content_type
213
+
214
+ def __call__(self, func: DECORATED_FUNC) -> DECORATED_FUNC:
215
+ setattr(func, self.CONTENT_TYPE_ATTR, self)
216
+ return func
217
+
218
+ @staticmethod
219
+ def get(func: DECORATED_FUNC) -> Optional["ContentType"]:
220
+ return getattr(func, ContentType.CONTENT_TYPE_ATTR, None)
221
+
222
+
223
+ class ResponseMiddleware(Protocol):
224
+ """Protocol for response middleware"""
225
+
226
+ def on_response(
227
+ self, request: "HttpRPCRequest", response: "HttpRPCResponse"
228
+ ) -> "HttpRPCResponse": ...
229
+
230
+
231
+ class RequestHook(Protocol):
232
+ """Protocol for request hooks"""
233
+
234
+ def before_request(self, request: "HttpRPCRequest") -> "HttpRPCRequest": ...
235
+
236
+
237
+ class ResponseHook(Protocol):
238
+ """Protocol for response hooks"""
239
+
240
+ def after_response(
241
+ self, request: "HttpRPCRequest", response: "HttpRPCResponse"
242
+ ) -> "HttpRPCResponse": ...
133
243
 
134
244
 
135
245
  class RestClient:
@@ -159,9 +269,10 @@ class RestClient:
159
269
 
160
270
  @dataclass
161
271
  class HttpRPCResponse:
162
-
163
272
  status_code: int
164
273
  data: bytes
274
+ headers: Optional[Dict[str, str]] = None
275
+ elapsed_time: Optional[float] = None
165
276
 
166
277
 
167
278
  @dataclass
@@ -171,6 +282,9 @@ class HttpRPCRequest:
171
282
  headers: list[tuple[str, str]]
172
283
  query_params: dict[str, str]
173
284
  body: bytes | None
285
+ timeout: Optional[float] = None
286
+ form_data: Optional[Dict[str, Any]] = None
287
+ files: Optional[Dict[str, Any]] = None
174
288
 
175
289
 
176
290
  class RPCRequestNetworkError(Exception):
@@ -275,15 +389,130 @@ class RequestMiddleware(Protocol):
275
389
  def on_request(self, request: HttpRPCRequest) -> HttpRPCRequest: ...
276
390
 
277
391
 
392
+ class AuthenticationMiddleware(RequestMiddleware):
393
+ """Base class for authentication middleware"""
394
+
395
+ def on_request(self, request: HttpRPCRequest) -> HttpRPCRequest:
396
+ return self.add_auth(request)
397
+
398
+ def add_auth(self, request: HttpRPCRequest) -> HttpRPCRequest:
399
+ raise NotImplementedError
400
+
401
+
402
+ class BearerTokenAuth(AuthenticationMiddleware):
403
+ """Bearer token authentication middleware"""
404
+
405
+ def __init__(self, token: str):
406
+ self.token = token
407
+
408
+ def add_auth(self, request: HttpRPCRequest) -> HttpRPCRequest:
409
+ request.headers.append(("Authorization", f"Bearer {self.token}"))
410
+ return request
411
+
412
+
413
+ class BasicAuth(AuthenticationMiddleware):
414
+ """Basic authentication middleware"""
415
+
416
+ def __init__(self, username: str, password: str):
417
+ import base64
418
+
419
+ credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
420
+ self.credentials = credentials
421
+
422
+ def add_auth(self, request: HttpRPCRequest) -> HttpRPCRequest:
423
+ request.headers.append(("Authorization", f"Basic {self.credentials}"))
424
+ return request
425
+
426
+
427
+ class ApiKeyAuth(AuthenticationMiddleware):
428
+ """API key authentication middleware"""
429
+
430
+ def __init__(self, api_key: str, header_name: str = "X-API-Key"):
431
+ self.api_key = api_key
432
+ self.header_name = header_name
433
+
434
+ def add_auth(self, request: HttpRPCRequest) -> HttpRPCRequest:
435
+ request.headers.append((self.header_name, self.api_key))
436
+ return request
437
+
438
+
439
+ class CacheMiddleware(RequestMiddleware):
440
+ """Simple in-memory cache middleware"""
441
+
442
+ def __init__(self, ttl_seconds: int = 300):
443
+ self.cache: Dict[str, tuple[Any, float]] = {}
444
+ self.ttl_seconds = ttl_seconds
445
+
446
+ def _cache_key(self, request: HttpRPCRequest) -> str:
447
+ """Generate cache key from request"""
448
+ key_data = {
449
+ "method": request.method,
450
+ "url": request.url,
451
+ "query_params": request.query_params,
452
+ "headers": sorted(request.headers),
453
+ }
454
+ return str(hash(json.dumps(key_data, sort_keys=True)))
455
+
456
+ def on_request(self, request: HttpRPCRequest) -> HttpRPCRequest:
457
+ # Only cache GET requests
458
+ if request.method == "GET":
459
+ cache_key = self._cache_key(request)
460
+ if cache_key in self.cache:
461
+ cached_response, timestamp = self.cache[cache_key]
462
+ if time.time() - timestamp < self.ttl_seconds:
463
+ # Return cached response (this needs to be handled in the client builder)
464
+ setattr(request, "_cached_response", cached_response)
465
+ return request
466
+
467
+
278
468
  class HttpRpcClientBuilder:
279
469
 
280
470
  def __init__(
281
471
  self,
282
472
  backend: HttpRPCAsyncBackend,
283
473
  middlewares: list[RequestMiddleware] = [],
474
+ response_middlewares: list[ResponseMiddleware] = [],
475
+ request_hooks: list[RequestHook] = [],
476
+ response_hooks: list[ResponseHook] = [],
284
477
  ):
285
478
  self._backend = backend
286
479
  self._middlewares = middlewares
480
+ self._response_middlewares = response_middlewares
481
+ self._request_hooks = request_hooks
482
+ self._response_hooks = response_hooks
483
+
484
+ async def _execute_with_retry(
485
+ self, request: HttpRPCRequest, retry_config: Optional[RetryConfig]
486
+ ) -> HttpRPCResponse:
487
+ """Execute request with retry logic"""
488
+ if not retry_config:
489
+ return await self._backend.request(request)
490
+
491
+ last_exception = None
492
+ for attempt in range(retry_config.max_attempts):
493
+ try:
494
+ response = await self._backend.request(request)
495
+
496
+ # Check if we should retry based on status code
497
+ if response.status_code in retry_config.retry_on_status_codes:
498
+ if attempt < retry_config.max_attempts - 1:
499
+ wait_time = retry_config.backoff_factor * (2**attempt)
500
+ await asyncio.sleep(wait_time)
501
+ continue
502
+
503
+ return response
504
+
505
+ except Exception as e:
506
+ last_exception = e
507
+ if attempt < retry_config.max_attempts - 1:
508
+ wait_time = retry_config.backoff_factor * (2**attempt)
509
+ await asyncio.sleep(wait_time)
510
+ continue
511
+ else:
512
+ raise
513
+
514
+ # This should never be reached, but just in case
515
+ raise last_exception or Exception("Retry failed")
287
516
 
288
517
  def build(self, cls: type[T]) -> T:
289
518
  rest_client = RestClient.get(cls)
@@ -314,10 +543,17 @@ class HttpRpcClientBuilder:
314
543
  headers: list[tuple[str, str]] = []
315
544
  query_params = {}
316
545
  body: Any = None
546
+ form_data: Dict[str, Any] = {}
547
+ files: Dict[str, Any] = {}
317
548
  compiled_path = (
318
549
  rest_client.base_path.rstrip("/") + "/" + mapping.path.lstrip("/")
319
550
  )
320
551
 
552
+ # Get decorators for this method
553
+ timeout_config = Timeout.get(method_call)
554
+ retry_config = Retry.get(method_call)
555
+ content_type_config = ContentType.get(method_call)
556
+
321
557
  for attr in request_attributes:
322
558
  if attr.attribute_type == "header":
323
559
  headers.append((attr.name, compiled_kwargs[attr.name]))
@@ -329,17 +565,33 @@ class HttpRpcClientBuilder:
329
565
  compiled_path = compiled_path.replace(
330
566
  f":{attr.name}", str(compiled_kwargs[attr.name])
331
567
  )
568
+ elif attr.attribute_type == "form":
569
+ form_data[attr.name] = compiled_kwargs[attr.name]
570
+ elif attr.attribute_type == "file":
571
+ files[attr.name] = compiled_kwargs[attr.name]
332
572
 
333
573
  body_content: bytes | None = None
334
574
 
575
+ # Handle different content types
335
576
  if body is not None:
336
577
  if isinstance(body, BaseModel):
337
578
  body_content = body.model_dump_json().encode()
338
- headers.append(("Content-Type", "application/json"))
579
+ if not content_type_config:
580
+ headers.append(("Content-Type", "application/json"))
339
581
  elif isinstance(body, bytes):
340
582
  body_content = body
583
+ elif isinstance(body, str):
584
+ body_content = body.encode()
585
+ elif isinstance(body, dict):
586
+ body_content = json.dumps(body).encode()
587
+ if not content_type_config:
588
+ headers.append(("Content-Type", "application/json"))
341
589
  else:
342
- raise ValueError("Invalid body type")
590
+ raise ValueError(f"Invalid body type: {type(body)}")
591
+
592
+ # Apply custom content type if specified
593
+ if content_type_config:
594
+ headers.append(("Content-Type", content_type_config.content_type))
343
595
 
344
596
  request = HttpRPCRequest(
345
597
  url=compiled_path,
@@ -347,12 +599,41 @@ class HttpRpcClientBuilder:
347
599
  headers=headers,
348
600
  query_params=query_params,
349
601
  body=body_content,
602
+ timeout=timeout_config.seconds if timeout_config else None,
603
+ form_data=form_data if form_data else None,
604
+ files=files if files else None,
350
605
  )
351
606
 
607
+ # Apply request hooks
608
+ for hook in self._request_hooks:
609
+ request = hook.before_request(request)
610
+
352
611
  for middleware in self._middlewares:
353
612
  request = middleware.on_request(request)
354
613
 
355
- response = await self._backend.request(request)
614
+ # Check for cached response
615
+ if hasattr(request, "_cached_response"):
616
+ response = getattr(request, "_cached_response")
617
+ else:
618
+ # Execute request with retry if configured
619
+ response = await self._execute_with_retry(
620
+ request, retry_config.config if retry_config else None
621
+ )
622
+
623
+ # Apply response middleware
624
+ for response_middleware in self._response_middlewares:
625
+ response = response_middleware.on_response(request, response)
626
+
627
+ # Apply response hooks
628
+ for response_hook in self._response_hooks:
629
+ response = response_hook.after_response(request, response)
630
+
631
+ # Cache response if using cache middleware and it's a GET request
632
+ if request.method == "GET":
633
+ for middleware in self._middlewares:
634
+ if isinstance(middleware, CacheMiddleware):
635
+ cache_key = middleware._cache_key(request)
636
+ middleware.cache[cache_key] = (response, time.time())
356
637
 
357
638
  return_type = inspect.signature(method_call).return_annotation
358
639
 
@@ -407,13 +688,28 @@ __all__ = [
407
688
  "Header",
408
689
  "Body",
409
690
  "PathParam",
691
+ "FormData",
692
+ "File",
693
+ "Timeout",
694
+ "RetryConfig",
695
+ "Retry",
696
+ "ContentType",
410
697
  "RestClient",
411
698
  "HttpRPCAsyncBackend",
412
699
  "HttpRPCRequest",
413
700
  "HttpRPCResponse",
414
701
  "RPCRequestNetworkError",
702
+ "RPCUnhandleError",
415
703
  "HttpRpcClientBuilder",
416
704
  "RequestMiddleware",
705
+ "ResponseMiddleware",
706
+ "RequestHook",
707
+ "ResponseHook",
708
+ "AuthenticationMiddleware",
709
+ "BearerTokenAuth",
710
+ "BasicAuth",
711
+ "ApiKeyAuth",
712
+ "CacheMiddleware",
417
713
  "TracedRequestMiddleware",
418
714
  "GlobalHttpErrorHandler",
419
715
  "RouteHttpErrorHandler",