pyrestkit 0.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.
Files changed (115) hide show
  1. pyrestkit/__init__.py +35 -0
  2. pyrestkit/ai/__init__.py +17 -0
  3. pyrestkit/ai/analyzer.py +137 -0
  4. pyrestkit/ai/client.py +101 -0
  5. pyrestkit/ai/config/__init__.py +5 -0
  6. pyrestkit/ai/config/ai_config.py +200 -0
  7. pyrestkit/ai/exceptions.py +22 -0
  8. pyrestkit/ai/generators/__init__.py +0 -0
  9. pyrestkit/ai/models.py +44 -0
  10. pyrestkit/ai/parsers/__init__.py +0 -0
  11. pyrestkit/ai/prompts/failure_analysis.md +21 -0
  12. pyrestkit/ai/provider.py +58 -0
  13. pyrestkit/ai/providers/__init__.py +21 -0
  14. pyrestkit/ai/providers/anthropic.py +85 -0
  15. pyrestkit/ai/providers/azure_openai.py +84 -0
  16. pyrestkit/ai/providers/base.py +39 -0
  17. pyrestkit/ai/providers/bedrock.py +70 -0
  18. pyrestkit/ai/providers/cohere.py +82 -0
  19. pyrestkit/ai/providers/gemini.py +113 -0
  20. pyrestkit/ai/providers/groq.py +81 -0
  21. pyrestkit/ai/providers/mistral.py +88 -0
  22. pyrestkit/ai/providers/ollama.py +82 -0
  23. pyrestkit/ai/providers/openai.py +124 -0
  24. pyrestkit/ai/utils/__init__.py +0 -0
  25. pyrestkit/ai/utils/prompt_loader.py +52 -0
  26. pyrestkit/assertions/__init__.py +7 -0
  27. pyrestkit/assertions/assertion_exception.py +4 -0
  28. pyrestkit/assertions/response_assertions.py +181 -0
  29. pyrestkit/auth/__init__.py +11 -0
  30. pyrestkit/auth/auth_strategy.py +14 -0
  31. pyrestkit/auth/authentication_manager.py +35 -0
  32. pyrestkit/auth/strategies/__init__.py +5 -0
  33. pyrestkit/auth/strategies/api_key_auth.py +18 -0
  34. pyrestkit/auth/strategies/basic_auth.py +24 -0
  35. pyrestkit/auth/strategies/bearer_auth.py +17 -0
  36. pyrestkit/auth/token_cache.py +44 -0
  37. pyrestkit/auth/token_manager.py +32 -0
  38. pyrestkit/auth/token_provider.py +12 -0
  39. pyrestkit/auth/token_response.py +13 -0
  40. pyrestkit/builder/__init__.py +5 -0
  41. pyrestkit/builder/fluent_request_builder.py +167 -0
  42. pyrestkit/clients/__init__.py +7 -0
  43. pyrestkit/clients/base_client.py +68 -0
  44. pyrestkit/clients/user_client.py +66 -0
  45. pyrestkit/config/__init__.py +5 -0
  46. pyrestkit/config/config.py +97 -0
  47. pyrestkit/constants/__init__.py +0 -0
  48. pyrestkit/constants/content_types.py +0 -0
  49. pyrestkit/constants/headers.py +0 -0
  50. pyrestkit/constants/status_codes.py +0 -0
  51. pyrestkit/core/__init__.py +13 -0
  52. pyrestkit/core/api_client.py +129 -0
  53. pyrestkit/core/logger.py +41 -0
  54. pyrestkit/core/request_builder.py +45 -0
  55. pyrestkit/core/request_executor.py +64 -0
  56. pyrestkit/core/request_logger.py +0 -0
  57. pyrestkit/core/response_logger.py +0 -0
  58. pyrestkit/core/session_manager.py +19 -0
  59. pyrestkit/database/__init__.py +0 -0
  60. pyrestkit/endpoints/__init__.py +5 -0
  61. pyrestkit/endpoints/base_endpoints.py +32 -0
  62. pyrestkit/endpoints/order_endpoints.py +9 -0
  63. pyrestkit/endpoints/payment_endpoints.py +5 -0
  64. pyrestkit/endpoints/user_endpoints.py +48 -0
  65. pyrestkit/exceptions/__init__.py +21 -0
  66. pyrestkit/exceptions/api_exception.py +8 -0
  67. pyrestkit/exceptions/authentication_exception.py +10 -0
  68. pyrestkit/exceptions/configuration_exception.py +10 -0
  69. pyrestkit/exceptions/exception_mapper.py +32 -0
  70. pyrestkit/exceptions/network_exception.py +10 -0
  71. pyrestkit/exceptions/response_exception.py +10 -0
  72. pyrestkit/exceptions/serialization_exception.py +10 -0
  73. pyrestkit/exceptions/validation_exception.py +10 -0
  74. pyrestkit/factories/__init__.py +5 -0
  75. pyrestkit/factories/base_factory.py +25 -0
  76. pyrestkit/factories/user_factory.py +37 -0
  77. pyrestkit/hooks/__init__.py +5 -0
  78. pyrestkit/hooks/hook.py +27 -0
  79. pyrestkit/hooks/hook_manager.py +39 -0
  80. pyrestkit/hooks/request_hook.py +18 -0
  81. pyrestkit/hooks/response_hook.py +17 -0
  82. pyrestkit/hooks/timing_hook.py +32 -0
  83. pyrestkit/models/__init__.py +8 -0
  84. pyrestkit/models/base_response.py +11 -0
  85. pyrestkit/models/request/__init__.py +7 -0
  86. pyrestkit/models/request/create_user_request.py +11 -0
  87. pyrestkit/models/request/update_user_request.py +10 -0
  88. pyrestkit/models/response/__init__.py +5 -0
  89. pyrestkit/models/response/create_user_response.py +26 -0
  90. pyrestkit/models/response/get_user_response.py +28 -0
  91. pyrestkit/models/response/user_response.py +12 -0
  92. pyrestkit/pipeline/__init__.py +5 -0
  93. pyrestkit/pipeline/middleware.py +18 -0
  94. pyrestkit/pipeline/middleware_chain.py +11 -0
  95. pyrestkit/pipeline/pipeline.py +27 -0
  96. pyrestkit/pipeline/request_context.py +26 -0
  97. pyrestkit/response/__init__.py +8 -0
  98. pyrestkit/response/framework_response.py +271 -0
  99. pyrestkit/response/response_body.py +124 -0
  100. pyrestkit/retry/__init__.py +5 -0
  101. pyrestkit/retry/backoff.py +32 -0
  102. pyrestkit/retry/retry_handler.py +52 -0
  103. pyrestkit/retry/retry_policy.py +33 -0
  104. pyrestkit/serializers/__init__.py +0 -0
  105. pyrestkit/serializers/response_mapper.py +25 -0
  106. pyrestkit/types/__init__.py +0 -0
  107. pyrestkit/types/model_protocol.py +17 -0
  108. pyrestkit/utils/__init__.py +0 -0
  109. pyrestkit/validators/__init__.py +7 -0
  110. pyrestkit/validators/response_validator.py +57 -0
  111. pyrestkit/validators/schema_validator.py +33 -0
  112. pyrestkit-0.0.0.dist-info/METADATA +741 -0
  113. pyrestkit-0.0.0.dist-info/RECORD +115 -0
  114. pyrestkit-0.0.0.dist-info/WHEEL +5 -0
  115. pyrestkit-0.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pyrestkit.ai.config import AIConfig
6
+ from pyrestkit.ai.exceptions import AIProviderError
7
+ from pyrestkit.ai.providers.base import BaseAIProvider
8
+
9
+
10
+ class OllamaProvider(BaseAIProvider):
11
+ """
12
+ Local Ollama provider using the generate endpoint.
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ session: Any | None = None,
18
+ ) -> None:
19
+ super().__init__(session=session)
20
+
21
+ def complete(
22
+ self,
23
+ prompt: str,
24
+ *,
25
+ config: AIConfig,
26
+ system_prompt: str | None = None,
27
+ ) -> str:
28
+ options: dict[str, Any] = {
29
+ "temperature": config.temperature,
30
+ }
31
+
32
+ if config.max_tokens is not None:
33
+ options["num_predict"] = config.max_tokens
34
+
35
+ payload: dict[str, Any] = {
36
+ "model": config.model,
37
+ "prompt": prompt,
38
+ "stream": False,
39
+ "options": options,
40
+ }
41
+
42
+ if system_prompt:
43
+ payload["system"] = system_prompt
44
+
45
+ response = self._session.post(
46
+ _generate_url(config.base_url),
47
+ json=payload,
48
+ headers=dict(config.headers),
49
+ timeout=config.timeout,
50
+ )
51
+
52
+ if response.status_code >= 400:
53
+ raise AIProviderError(
54
+ f"Ollama request failed with status {response.status_code}: "
55
+ f"{response.text}"
56
+ )
57
+
58
+ try:
59
+ data = response.json()
60
+ except ValueError as exc:
61
+ raise AIProviderError("Ollama response was not valid JSON.") from exc
62
+
63
+ if not isinstance(data, dict):
64
+ raise AIProviderError("Ollama response was not a JSON object.")
65
+
66
+ text = data.get("response")
67
+
68
+ if not isinstance(text, str):
69
+ raise AIProviderError("Ollama response did not include response text.")
70
+
71
+ return text
72
+
73
+
74
+ def _generate_url(
75
+ base_url: str | None,
76
+ ) -> str:
77
+ base = (base_url or "http://localhost:11434").rstrip("/")
78
+
79
+ if base.endswith("/api/generate"):
80
+ return base
81
+
82
+ return f"{base}/api/generate"
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pyrestkit.ai.config import AIConfig
6
+ from pyrestkit.ai.exceptions import AIConfigurationError, AIProviderError
7
+ from pyrestkit.ai.providers.base import BaseAIProvider
8
+
9
+
10
+ class OpenAIResponsesProvider(BaseAIProvider):
11
+ """
12
+ OpenAI provider backed by the Responses API.
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ session: Any | None = None,
18
+ ) -> None:
19
+ super().__init__(session=session)
20
+
21
+ def complete(
22
+ self,
23
+ prompt: str,
24
+ *,
25
+ config: AIConfig,
26
+ system_prompt: str | None = None,
27
+ ) -> str:
28
+ if not config.api_key:
29
+ raise AIConfigurationError("OpenAI API key is required.")
30
+
31
+ payload: dict[str, Any] = {
32
+ "model": config.model,
33
+ "input": prompt,
34
+ "temperature": config.temperature,
35
+ }
36
+
37
+ if system_prompt:
38
+ payload["instructions"] = system_prompt
39
+
40
+ if config.max_tokens is not None:
41
+ payload["max_output_tokens"] = config.max_tokens
42
+
43
+ response = self._session.post(
44
+ _responses_url(config.base_url),
45
+ json=payload,
46
+ headers=_headers(config),
47
+ timeout=config.timeout,
48
+ )
49
+
50
+ if response.status_code >= 400:
51
+ raise AIProviderError(
52
+ f"OpenAI request failed with status {response.status_code}: "
53
+ f"{response.text}"
54
+ )
55
+
56
+ try:
57
+ data = response.json()
58
+ except ValueError as exc:
59
+ raise AIProviderError("OpenAI response was not valid JSON.") from exc
60
+
61
+ return _extract_text(data)
62
+
63
+
64
+ def _responses_url(
65
+ base_url: str | None,
66
+ ) -> str:
67
+ base = (base_url or "https://api.openai.com/v1").rstrip("/")
68
+
69
+ if base.endswith("/responses"):
70
+ return base
71
+
72
+ return f"{base}/responses"
73
+
74
+
75
+ def _headers(config: AIConfig) -> dict[str, str]:
76
+ headers = BaseAIProvider.json_headers(config)
77
+
78
+ if config.api_key is not None:
79
+ headers["Authorization"] = f"Bearer {config.api_key}"
80
+
81
+ if config.organization is not None:
82
+ headers["OpenAI-Organization"] = config.organization
83
+
84
+ return headers
85
+
86
+
87
+ def _extract_text(
88
+ data: MappingLike,
89
+ ) -> str:
90
+ output_text = data.get("output_text")
91
+
92
+ if isinstance(output_text, str) and output_text.strip():
93
+ return output_text
94
+
95
+ output = data.get("output", [])
96
+
97
+ if isinstance(output, list):
98
+ chunks: list[str] = []
99
+
100
+ for item in output:
101
+ if not isinstance(item, dict):
102
+ continue
103
+
104
+ content = item.get("content", [])
105
+
106
+ if not isinstance(content, list):
107
+ continue
108
+
109
+ for part in content:
110
+ if not isinstance(part, dict):
111
+ continue
112
+
113
+ text = part.get("text")
114
+
115
+ if isinstance(text, str):
116
+ chunks.append(text)
117
+
118
+ if chunks:
119
+ return "\n".join(chunks)
120
+
121
+ raise AIProviderError("OpenAI response did not include output text.")
122
+
123
+
124
+ MappingLike = dict[str, Any]
File without changes
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from string import Template
5
+
6
+
7
+ class PromptLoader:
8
+ """
9
+ Loads and renders prompt templates from a directory.
10
+ """
11
+
12
+ def __init__(
13
+ self,
14
+ base_path: str | Path,
15
+ ) -> None:
16
+ self._base_path = Path(base_path).resolve()
17
+
18
+ @classmethod
19
+ def default(cls) -> PromptLoader:
20
+ return cls(Path(__file__).resolve().parents[1] / "prompts")
21
+
22
+ def load(
23
+ self,
24
+ name: str,
25
+ ) -> str:
26
+ path = self._resolve(name)
27
+
28
+ if not path.exists():
29
+ raise FileNotFoundError(f"Prompt template '{name}' was not found.")
30
+
31
+ return path.read_text(encoding="utf-8")
32
+
33
+ def render(
34
+ self,
35
+ template_name: str,
36
+ **values: object,
37
+ ) -> str:
38
+ template = Template(self.load(template_name))
39
+ return template.substitute(values)
40
+
41
+ def _resolve(
42
+ self,
43
+ name: str,
44
+ ) -> Path:
45
+ path = (self._base_path / name).resolve()
46
+
47
+ try:
48
+ path.relative_to(self._base_path)
49
+ except ValueError as exc:
50
+ raise ValueError("Prompt template path cannot escape base path.") from exc
51
+
52
+ return path
@@ -0,0 +1,7 @@
1
+ from .assertion_exception import AssertionException
2
+ from .response_assertions import ResponseAssertions
3
+
4
+ __all__ = [
5
+ "AssertionException",
6
+ "ResponseAssertions",
7
+ ]
@@ -0,0 +1,4 @@
1
+ class AssertionException(AssertionError):
2
+ """
3
+ Raised when a framework assertion fails.
4
+ """
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from pyrestkit.assertions.assertion_exception import AssertionException
6
+ from pyrestkit.validators.schema_validator import SchemaValidator
7
+
8
+ if TYPE_CHECKING:
9
+ from pyrestkit.response.framework_response import FrameworkResponse
10
+
11
+
12
+ class ResponseAssertions:
13
+ """
14
+ Fluent assertions for FrameworkResponse.
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ response: FrameworkResponse,
20
+ ) -> None:
21
+ self._response = response
22
+
23
+ def have_status(
24
+ self,
25
+ expected: int,
26
+ ) -> ResponseAssertions:
27
+ actual = self._response.status_code
28
+
29
+ if actual != expected:
30
+ raise AssertionException(
31
+ f"Expected status code {expected}, but received {actual}."
32
+ )
33
+
34
+ return self
35
+
36
+ def be_successful(
37
+ self,
38
+ ) -> ResponseAssertions:
39
+ if not self._response.ok:
40
+ raise AssertionException(
41
+ f"Expected successful response "
42
+ f"but received {self._response.status_code}."
43
+ )
44
+
45
+ return self
46
+
47
+ def have_header(
48
+ self,
49
+ name: str,
50
+ expected: str | None = None,
51
+ ) -> ResponseAssertions:
52
+ headers = self._response.headers
53
+
54
+ if name not in headers:
55
+ raise AssertionException(f"Header '{name}' was not found.")
56
+
57
+ if expected is not None:
58
+ actual = headers[name]
59
+
60
+ if actual != expected:
61
+ raise AssertionException(
62
+ f"Expected header '{name}' to be '{expected}', but was '{actual}'."
63
+ )
64
+
65
+ return self
66
+
67
+ def match_schema(
68
+ self,
69
+ schema_path: str,
70
+ ) -> ResponseAssertions:
71
+ SchemaValidator.validate(
72
+ self._response,
73
+ schema_path,
74
+ )
75
+
76
+ return self
77
+
78
+ def _get_json_value(
79
+ self,
80
+ path: str,
81
+ ) -> object:
82
+ """
83
+ Returns a nested JSON value using dot notation.
84
+
85
+ Example:
86
+
87
+ data.email
88
+ support.url
89
+ """
90
+
91
+ value: object = self._response.json()
92
+
93
+ for part in path.split("."):
94
+ if not isinstance(value, dict):
95
+ raise AssertionException(f"'{path}' does not exist.")
96
+
97
+ if part not in value:
98
+ raise AssertionException(f"Key '{part}' not found in '{path}'.")
99
+
100
+ value = value[part]
101
+
102
+ return value
103
+
104
+ def have_json(
105
+ self,
106
+ path: str,
107
+ expected: object,
108
+ ) -> ResponseAssertions:
109
+ actual = self._get_json_value(path)
110
+
111
+ if actual != expected:
112
+ raise AssertionException(
113
+ f"Expected '{path}' to be {expected!r}, but was {actual!r}."
114
+ )
115
+
116
+ return self
117
+
118
+ def respond_within(
119
+ self,
120
+ milliseconds: int,
121
+ ) -> ResponseAssertions:
122
+ """
123
+ Verifies that the response completed within the
124
+ specified number of milliseconds.
125
+ """
126
+
127
+ actual = self._response.elapsed.total_seconds() * 1000
128
+
129
+ if actual > milliseconds:
130
+ raise AssertionException(
131
+ f"Expected response within {milliseconds} ms, but took {actual:.2f} ms."
132
+ )
133
+
134
+ return self
135
+
136
+ def have_json_count(
137
+ self,
138
+ path: str,
139
+ expected: int,
140
+ ) -> ResponseAssertions:
141
+ """
142
+ Verifies the number of items
143
+ in a JSON array.
144
+ """
145
+
146
+ value = self._response.body.get(path)
147
+
148
+ if not isinstance(
149
+ value,
150
+ list,
151
+ ):
152
+ raise AssertionException(f"'{path}' is not a JSON array.")
153
+
154
+ actual = len(value)
155
+
156
+ if actual != expected:
157
+ raise AssertionException(
158
+ f"Expected {expected} items at '{path}', but found {actual}."
159
+ )
160
+
161
+ return self
162
+
163
+ def have_json_contains(
164
+ self,
165
+ path: str,
166
+ expected: object,
167
+ ) -> ResponseAssertions:
168
+ """
169
+ Verifies that a JSON array contains
170
+ the expected value.
171
+ """
172
+
173
+ value = self._response.body.get(path)
174
+
175
+ if not isinstance(value, list):
176
+ raise AssertionException(f"'{path}' is not a JSON array.")
177
+
178
+ if expected not in value:
179
+ raise AssertionException(f"{expected!r} not found in '{path}'.")
180
+
181
+ return self
@@ -0,0 +1,11 @@
1
+ from .auth_strategy import AuthenticationStrategy
2
+ from .authentication_manager import AuthenticationManager
3
+ from .token_cache import TokenCache
4
+ from .token_manager import TokenManager
5
+
6
+ __all__ = [
7
+ "AuthenticationManager",
8
+ "AuthenticationStrategy",
9
+ "TokenCache",
10
+ "TokenManager",
11
+ ]
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class AuthenticationStrategy(ABC):
7
+ """Base interface for all authentication strategies.
8
+ This is the interface every authentication implementation must follow.
9
+ """
10
+
11
+ @abstractmethod
12
+ def get_headers(self) -> dict[str, str]:
13
+ """Return authentication headers."""
14
+ raise NotImplementedError
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from pyrestkit.auth.auth_strategy import AuthenticationStrategy
4
+
5
+
6
+ class AuthenticationManager:
7
+ """
8
+ Responsible for providing authentication headers.
9
+
10
+ APIClient communicates only with this class and remains
11
+ unaware of the underlying authentication mechanism.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ strategy: AuthenticationStrategy | None = None,
17
+ ) -> None:
18
+ self._strategy = strategy
19
+
20
+ @property
21
+ def strategy(self) -> AuthenticationStrategy | None:
22
+ return self._strategy
23
+
24
+ @strategy.setter
25
+ def strategy(
26
+ self,
27
+ strategy: AuthenticationStrategy | None,
28
+ ) -> None:
29
+ self._strategy = strategy
30
+
31
+ def get_headers(self) -> dict[str, str]:
32
+ if self._strategy is None:
33
+ return {}
34
+
35
+ return self._strategy.get_headers()
@@ -0,0 +1,5 @@
1
+ from .api_key_auth import ApiKeyAuth
2
+ from .basic_auth import BasicAuth
3
+ from .bearer_auth import BearerAuth
4
+
5
+ __all__ = ["ApiKeyAuth", "BasicAuth", "BearerAuth"]
@@ -0,0 +1,18 @@
1
+ from pyrestkit.auth.auth_strategy import AuthenticationStrategy
2
+
3
+
4
+ class ApiKeyAuth(AuthenticationStrategy):
5
+ """API Key Authentication."""
6
+
7
+ def __init__(
8
+ self,
9
+ api_key: str,
10
+ header_name: str = "x-api-key",
11
+ ) -> None:
12
+ self._api_key = api_key
13
+ self._header_name = header_name
14
+
15
+ def get_headers(self) -> dict[str, str]:
16
+ return {
17
+ self._header_name: self._api_key,
18
+ }
@@ -0,0 +1,24 @@
1
+ import base64
2
+
3
+ from pyrestkit.auth.auth_strategy import AuthenticationStrategy
4
+
5
+
6
+ class BasicAuth(AuthenticationStrategy):
7
+ """HTTP Basic Authentication."""
8
+
9
+ def __init__(
10
+ self,
11
+ username: str,
12
+ password: str,
13
+ ) -> None:
14
+ self._username = username
15
+ self._password = password
16
+
17
+ def get_headers(self) -> dict[str, str]:
18
+ credentials = (f"{self._username}:{self._password}").encode()
19
+
20
+ encoded = base64.b64encode(credentials).decode("utf-8")
21
+
22
+ return {
23
+ "Authorization": f"Basic {encoded}",
24
+ }
@@ -0,0 +1,17 @@
1
+ from pyrestkit.auth.auth_strategy import AuthenticationStrategy
2
+ from pyrestkit.auth.token_manager import TokenManager
3
+
4
+
5
+ class BearerAuth(AuthenticationStrategy):
6
+ def __init__(
7
+ self,
8
+ token_manager: TokenManager,
9
+ ) -> None:
10
+ self._token_manager = token_manager
11
+
12
+ def get_headers(self) -> dict[str, str]:
13
+ token = self._token_manager.get_token()
14
+
15
+ return {
16
+ "Authorization": f"Bearer {token}",
17
+ }
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime, timedelta
4
+ from threading import Lock
5
+
6
+ from pyrestkit.auth.token_response import TokenResponse
7
+
8
+
9
+ class TokenCache:
10
+ """
11
+ Thread-safe cache for authentication tokens.
12
+ """
13
+
14
+ def __init__(self) -> None:
15
+ self._response: TokenResponse | None = None
16
+ self._expires_at: datetime | None = None
17
+ self._lock = Lock()
18
+
19
+ def get(self) -> TokenResponse | None:
20
+ if self._response is None:
21
+ return None
22
+
23
+ if self._expires_at is None:
24
+ return None
25
+
26
+ if datetime.now(UTC) >= self._expires_at:
27
+ return None
28
+
29
+ return self._response
30
+
31
+ def set(
32
+ self,
33
+ response: TokenResponse,
34
+ ) -> None:
35
+ with self._lock:
36
+ self._response = response
37
+ self._expires_at = datetime.now(UTC) + timedelta(
38
+ seconds=response.expires_in,
39
+ )
40
+
41
+ def clear(self) -> None:
42
+ with self._lock:
43
+ self._response = None
44
+ self._expires_at = None
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from pyrestkit.auth.token_cache import TokenCache
4
+ from pyrestkit.auth.token_provider import TokenProvider
5
+
6
+
7
+ class TokenManager:
8
+ """
9
+ Responsible for retrieving access tokens.
10
+
11
+ Caching and refresh logic will be added in later modules.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ provider: TokenProvider,
17
+ cache: TokenCache,
18
+ ) -> None:
19
+ self._provider = provider
20
+ self._cache = cache
21
+
22
+ def get_token(self) -> str:
23
+ cached = self._cache.get()
24
+
25
+ if cached is not None:
26
+ return cached.access_token
27
+
28
+ response = self._provider.get_token()
29
+
30
+ self._cache.set(response)
31
+
32
+ return response.access_token
@@ -0,0 +1,12 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from pyrestkit.auth.token_response import TokenResponse
4
+
5
+
6
+ class TokenProvider(ABC):
7
+ @abstractmethod
8
+ def get_token(self) -> TokenResponse:
9
+ """
10
+ Fetch a new token.
11
+ """
12
+ raise NotImplementedError
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(slots=True)
7
+ class TokenResponse:
8
+ """
9
+ Represents an authentication token returned by the auth server.
10
+ """
11
+
12
+ access_token: str
13
+ expires_in: int