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.
- pyrestkit/__init__.py +35 -0
- pyrestkit/ai/__init__.py +17 -0
- pyrestkit/ai/analyzer.py +137 -0
- pyrestkit/ai/client.py +101 -0
- pyrestkit/ai/config/__init__.py +5 -0
- pyrestkit/ai/config/ai_config.py +200 -0
- pyrestkit/ai/exceptions.py +22 -0
- pyrestkit/ai/generators/__init__.py +0 -0
- pyrestkit/ai/models.py +44 -0
- pyrestkit/ai/parsers/__init__.py +0 -0
- pyrestkit/ai/prompts/failure_analysis.md +21 -0
- pyrestkit/ai/provider.py +58 -0
- pyrestkit/ai/providers/__init__.py +21 -0
- pyrestkit/ai/providers/anthropic.py +85 -0
- pyrestkit/ai/providers/azure_openai.py +84 -0
- pyrestkit/ai/providers/base.py +39 -0
- pyrestkit/ai/providers/bedrock.py +70 -0
- pyrestkit/ai/providers/cohere.py +82 -0
- pyrestkit/ai/providers/gemini.py +113 -0
- pyrestkit/ai/providers/groq.py +81 -0
- pyrestkit/ai/providers/mistral.py +88 -0
- pyrestkit/ai/providers/ollama.py +82 -0
- pyrestkit/ai/providers/openai.py +124 -0
- pyrestkit/ai/utils/__init__.py +0 -0
- pyrestkit/ai/utils/prompt_loader.py +52 -0
- pyrestkit/assertions/__init__.py +7 -0
- pyrestkit/assertions/assertion_exception.py +4 -0
- pyrestkit/assertions/response_assertions.py +181 -0
- pyrestkit/auth/__init__.py +11 -0
- pyrestkit/auth/auth_strategy.py +14 -0
- pyrestkit/auth/authentication_manager.py +35 -0
- pyrestkit/auth/strategies/__init__.py +5 -0
- pyrestkit/auth/strategies/api_key_auth.py +18 -0
- pyrestkit/auth/strategies/basic_auth.py +24 -0
- pyrestkit/auth/strategies/bearer_auth.py +17 -0
- pyrestkit/auth/token_cache.py +44 -0
- pyrestkit/auth/token_manager.py +32 -0
- pyrestkit/auth/token_provider.py +12 -0
- pyrestkit/auth/token_response.py +13 -0
- pyrestkit/builder/__init__.py +5 -0
- pyrestkit/builder/fluent_request_builder.py +167 -0
- pyrestkit/clients/__init__.py +7 -0
- pyrestkit/clients/base_client.py +68 -0
- pyrestkit/clients/user_client.py +66 -0
- pyrestkit/config/__init__.py +5 -0
- pyrestkit/config/config.py +97 -0
- pyrestkit/constants/__init__.py +0 -0
- pyrestkit/constants/content_types.py +0 -0
- pyrestkit/constants/headers.py +0 -0
- pyrestkit/constants/status_codes.py +0 -0
- pyrestkit/core/__init__.py +13 -0
- pyrestkit/core/api_client.py +129 -0
- pyrestkit/core/logger.py +41 -0
- pyrestkit/core/request_builder.py +45 -0
- pyrestkit/core/request_executor.py +64 -0
- pyrestkit/core/request_logger.py +0 -0
- pyrestkit/core/response_logger.py +0 -0
- pyrestkit/core/session_manager.py +19 -0
- pyrestkit/database/__init__.py +0 -0
- pyrestkit/endpoints/__init__.py +5 -0
- pyrestkit/endpoints/base_endpoints.py +32 -0
- pyrestkit/endpoints/order_endpoints.py +9 -0
- pyrestkit/endpoints/payment_endpoints.py +5 -0
- pyrestkit/endpoints/user_endpoints.py +48 -0
- pyrestkit/exceptions/__init__.py +21 -0
- pyrestkit/exceptions/api_exception.py +8 -0
- pyrestkit/exceptions/authentication_exception.py +10 -0
- pyrestkit/exceptions/configuration_exception.py +10 -0
- pyrestkit/exceptions/exception_mapper.py +32 -0
- pyrestkit/exceptions/network_exception.py +10 -0
- pyrestkit/exceptions/response_exception.py +10 -0
- pyrestkit/exceptions/serialization_exception.py +10 -0
- pyrestkit/exceptions/validation_exception.py +10 -0
- pyrestkit/factories/__init__.py +5 -0
- pyrestkit/factories/base_factory.py +25 -0
- pyrestkit/factories/user_factory.py +37 -0
- pyrestkit/hooks/__init__.py +5 -0
- pyrestkit/hooks/hook.py +27 -0
- pyrestkit/hooks/hook_manager.py +39 -0
- pyrestkit/hooks/request_hook.py +18 -0
- pyrestkit/hooks/response_hook.py +17 -0
- pyrestkit/hooks/timing_hook.py +32 -0
- pyrestkit/models/__init__.py +8 -0
- pyrestkit/models/base_response.py +11 -0
- pyrestkit/models/request/__init__.py +7 -0
- pyrestkit/models/request/create_user_request.py +11 -0
- pyrestkit/models/request/update_user_request.py +10 -0
- pyrestkit/models/response/__init__.py +5 -0
- pyrestkit/models/response/create_user_response.py +26 -0
- pyrestkit/models/response/get_user_response.py +28 -0
- pyrestkit/models/response/user_response.py +12 -0
- pyrestkit/pipeline/__init__.py +5 -0
- pyrestkit/pipeline/middleware.py +18 -0
- pyrestkit/pipeline/middleware_chain.py +11 -0
- pyrestkit/pipeline/pipeline.py +27 -0
- pyrestkit/pipeline/request_context.py +26 -0
- pyrestkit/response/__init__.py +8 -0
- pyrestkit/response/framework_response.py +271 -0
- pyrestkit/response/response_body.py +124 -0
- pyrestkit/retry/__init__.py +5 -0
- pyrestkit/retry/backoff.py +32 -0
- pyrestkit/retry/retry_handler.py +52 -0
- pyrestkit/retry/retry_policy.py +33 -0
- pyrestkit/serializers/__init__.py +0 -0
- pyrestkit/serializers/response_mapper.py +25 -0
- pyrestkit/types/__init__.py +0 -0
- pyrestkit/types/model_protocol.py +17 -0
- pyrestkit/utils/__init__.py +0 -0
- pyrestkit/validators/__init__.py +7 -0
- pyrestkit/validators/response_validator.py +57 -0
- pyrestkit/validators/schema_validator.py +33 -0
- pyrestkit-0.0.0.dist-info/METADATA +741 -0
- pyrestkit-0.0.0.dist-info/RECORD +115 -0
- pyrestkit-0.0.0.dist-info/WHEEL +5 -0
- 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,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,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
|