klab-pytest-toolkit-web 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.
- klab_pytest_toolkit_web/__init__.py +28 -0
- klab_pytest_toolkit_web/_api_client_types/__init__.py +22 -0
- klab_pytest_toolkit_web/_api_client_types/grpc_client.py +229 -0
- klab_pytest_toolkit_web/_api_client_types/rest_client.py +147 -0
- klab_pytest_toolkit_web/api_client.py +83 -0
- klab_pytest_toolkit_web/core.py +0 -0
- klab_pytest_toolkit_web/plugin.py +62 -0
- klab_pytest_toolkit_web/validators.py +88 -0
- klab_pytest_toolkit_web/web_client.py +360 -0
- klab_pytest_toolkit_web-0.0.0.dist-info/METADATA +235 -0
- klab_pytest_toolkit_web-0.0.0.dist-info/RECORD +13 -0
- klab_pytest_toolkit_web-0.0.0.dist-info/WHEEL +4 -0
- klab_pytest_toolkit_web-0.0.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Klab Pytest Toolkit - Web Fixtures"""
|
|
2
|
+
|
|
3
|
+
from klab_pytest_toolkit_web.validators import (
|
|
4
|
+
JsonResponseValidator,
|
|
5
|
+
ResponseValidatorFactory,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
from klab_pytest_toolkit_web.api_client import ApiClientFactory
|
|
9
|
+
|
|
10
|
+
from klab_pytest_toolkit_web.web_client import (
|
|
11
|
+
WebClientFactory,
|
|
12
|
+
WebClient,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from klab_pytest_toolkit_web._api_client_types.grpc_client import GrpcClient
|
|
16
|
+
from klab_pytest_toolkit_web._api_client_types.rest_client import RestApiClient
|
|
17
|
+
|
|
18
|
+
__version__ = "0.0.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"JsonResponseValidator",
|
|
22
|
+
"ResponseValidatorFactory",
|
|
23
|
+
"RestApiClient",
|
|
24
|
+
"GrpcClient",
|
|
25
|
+
"ApiClientFactory",
|
|
26
|
+
"WebClient",
|
|
27
|
+
"WebClientFactory",
|
|
28
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""API client types for the klab-pytest-toolkit-web package."""
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ApiClient(abc.ABC):
|
|
7
|
+
"""Abstract base class for API clients."""
|
|
8
|
+
|
|
9
|
+
def close(self) -> None:
|
|
10
|
+
"""Close any resources held by the client."""
|
|
11
|
+
raise NotImplementedError
|
|
12
|
+
|
|
13
|
+
def __enter__(self):
|
|
14
|
+
"""Enter the runtime context related to this object."""
|
|
15
|
+
return self
|
|
16
|
+
|
|
17
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
18
|
+
"""Exit the runtime context related to this object."""
|
|
19
|
+
self.close()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = ["ApiClient"]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import grpc
|
|
2
|
+
from typing import Optional, Any, List, Tuple, Callable, Dict
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import importlib.util
|
|
5
|
+
import tempfile
|
|
6
|
+
import sys
|
|
7
|
+
from grpc_tools import protoc
|
|
8
|
+
from klab_pytest_toolkit_web._api_client_types import ApiClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GrpcClient(ApiClient):
|
|
12
|
+
"""
|
|
13
|
+
gRPC client with dynamic method binding.
|
|
14
|
+
|
|
15
|
+
Supports proto file loading for service discovery.
|
|
16
|
+
Methods are dynamically bound to the client instance for easy access.
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
client = GrpcClient(
|
|
20
|
+
target="localhost:50051",
|
|
21
|
+
proto_file="service.proto"
|
|
22
|
+
)
|
|
23
|
+
response = client.call("SayHello", name="World")
|
|
24
|
+
# or
|
|
25
|
+
response = client.SayHello(name="World")
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
target: str,
|
|
31
|
+
proto_file: str,
|
|
32
|
+
credentials: Optional[grpc.ChannelCredentials] = None,
|
|
33
|
+
options: Optional[List[Tuple[str, Any]]] = None,
|
|
34
|
+
metadata: Optional[List[Tuple[str, str]]] = None,
|
|
35
|
+
):
|
|
36
|
+
self.target = target
|
|
37
|
+
self.metadata = metadata or []
|
|
38
|
+
self._stubs: Dict[str, Any] = {}
|
|
39
|
+
self._methods: Dict[str, Any] = {}
|
|
40
|
+
self._request_classes: Dict[str, Any] = {}
|
|
41
|
+
self._channel = None
|
|
42
|
+
self._temp_dir = None # Keep temp dir alive!
|
|
43
|
+
|
|
44
|
+
# Create channel
|
|
45
|
+
if credentials:
|
|
46
|
+
self._channel = grpc.secure_channel(target, credentials, options=options)
|
|
47
|
+
else:
|
|
48
|
+
self._channel = grpc.insecure_channel(target, options=options)
|
|
49
|
+
|
|
50
|
+
# Load service definitions from proto file
|
|
51
|
+
self._load_from_proto(proto_file)
|
|
52
|
+
|
|
53
|
+
def _load_from_proto(self, proto_file: str) -> None:
|
|
54
|
+
"""Load service definition from proto file."""
|
|
55
|
+
proto_path = Path(proto_file)
|
|
56
|
+
if not proto_path.exists():
|
|
57
|
+
raise FileNotFoundError(f"Proto file not found: {proto_file}")
|
|
58
|
+
|
|
59
|
+
# Keep temp directory alive for the lifetime of the client
|
|
60
|
+
self._temp_dir = tempfile.TemporaryDirectory()
|
|
61
|
+
temp_path = Path(self._temp_dir.name)
|
|
62
|
+
|
|
63
|
+
# Compile proto file
|
|
64
|
+
result = protoc.main(
|
|
65
|
+
[
|
|
66
|
+
"grpc_tools.protoc",
|
|
67
|
+
f"--proto_path={proto_path.parent}",
|
|
68
|
+
f"--python_out={temp_path}",
|
|
69
|
+
f"--grpc_python_out={temp_path}",
|
|
70
|
+
proto_path.name,
|
|
71
|
+
]
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if result != 0:
|
|
75
|
+
raise RuntimeError(f"Proto compilation failed with exit code: {result}")
|
|
76
|
+
|
|
77
|
+
# Load generated modules
|
|
78
|
+
proto_name = proto_path.stem
|
|
79
|
+
pb2_file = temp_path / f"{proto_name}_pb2.py"
|
|
80
|
+
grpc_file = temp_path / f"{proto_name}_pb2_grpc.py"
|
|
81
|
+
|
|
82
|
+
spec_pb2 = importlib.util.spec_from_file_location(f"{proto_name}_pb2", pb2_file)
|
|
83
|
+
spec_grpc = importlib.util.spec_from_file_location(f"{proto_name}_pb2_grpc", grpc_file)
|
|
84
|
+
|
|
85
|
+
if not spec_pb2 or not spec_grpc:
|
|
86
|
+
raise RuntimeError("Failed to load generated proto modules")
|
|
87
|
+
|
|
88
|
+
pb2_module = importlib.util.module_from_spec(spec_pb2)
|
|
89
|
+
grpc_module = importlib.util.module_from_spec(spec_grpc)
|
|
90
|
+
|
|
91
|
+
sys.modules[f"{proto_name}_pb2"] = pb2_module
|
|
92
|
+
sys.modules[f"{proto_name}_pb2_grpc"] = grpc_module
|
|
93
|
+
|
|
94
|
+
if spec_pb2.loader and spec_grpc.loader:
|
|
95
|
+
spec_pb2.loader.exec_module(pb2_module)
|
|
96
|
+
spec_grpc.loader.exec_module(grpc_module)
|
|
97
|
+
else:
|
|
98
|
+
raise RuntimeError("Failed to load proto module loaders")
|
|
99
|
+
|
|
100
|
+
# Register services
|
|
101
|
+
self._register_services(pb2_module, grpc_module)
|
|
102
|
+
|
|
103
|
+
def _register_services(self, pb2_module: Any, grpc_module: Any) -> None:
|
|
104
|
+
"""Register services from compiled proto modules."""
|
|
105
|
+
|
|
106
|
+
method_to_request_class: Dict[str, Any] = {}
|
|
107
|
+
|
|
108
|
+
descriptor = pb2_module.DESCRIPTOR
|
|
109
|
+
for service_name, service_desc in descriptor.services_by_name.items():
|
|
110
|
+
for method in service_desc.methods:
|
|
111
|
+
# Get the request class name from the descriptor
|
|
112
|
+
request_class_name = method.input_type.name
|
|
113
|
+
request_class = getattr(pb2_module, request_class_name)
|
|
114
|
+
method_to_request_class[method.name] = request_class
|
|
115
|
+
|
|
116
|
+
# Find and instantiate stub classes
|
|
117
|
+
for name in dir(grpc_module):
|
|
118
|
+
if name.endswith("Stub") and not name.startswith("_"):
|
|
119
|
+
stub_class = getattr(grpc_module, name)
|
|
120
|
+
service_name = name[:-4] # Remove "Stub" suffix
|
|
121
|
+
|
|
122
|
+
stub = stub_class(self._channel)
|
|
123
|
+
self._stubs[service_name] = stub
|
|
124
|
+
|
|
125
|
+
# Register methods
|
|
126
|
+
for method_name in dir(stub):
|
|
127
|
+
if not method_name.startswith("_"):
|
|
128
|
+
method = getattr(stub, method_name)
|
|
129
|
+
if callable(method):
|
|
130
|
+
self._methods[method_name] = method
|
|
131
|
+
|
|
132
|
+
# Use the descriptor-based mapping
|
|
133
|
+
if method_name in method_to_request_class:
|
|
134
|
+
self._request_classes[method_name] = method_to_request_class[
|
|
135
|
+
method_name
|
|
136
|
+
]
|
|
137
|
+
else:
|
|
138
|
+
print(f" Warning: No request class found for {method_name}")
|
|
139
|
+
|
|
140
|
+
# Pre-bind method
|
|
141
|
+
setattr(
|
|
142
|
+
self,
|
|
143
|
+
method_name,
|
|
144
|
+
self._create_method_wrapper(method_name, method),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def call(self, method_name: str, request_dict: Optional[dict] = None, **kwargs) -> Any:
|
|
148
|
+
"""
|
|
149
|
+
Call a gRPC method by name with parameters.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
method_name: Name of the gRPC method to call
|
|
153
|
+
request_dict: Optional dictionary of request parameters
|
|
154
|
+
**kwargs: Request parameters as keyword arguments
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
The response from the gRPC method
|
|
158
|
+
|
|
159
|
+
Examples:
|
|
160
|
+
response = client.call("SayHello", name="World")
|
|
161
|
+
response = client.call("SayHello", {"name": "World"})
|
|
162
|
+
"""
|
|
163
|
+
if method_name not in self._methods:
|
|
164
|
+
from difflib import get_close_matches
|
|
165
|
+
|
|
166
|
+
suggestions = get_close_matches(method_name, self._methods.keys(), n=3, cutoff=0.6)
|
|
167
|
+
suggestion_text = f" Did you mean: {', '.join(suggestions)}?" if suggestions else ""
|
|
168
|
+
raise AttributeError(
|
|
169
|
+
f"Method '{method_name}' not found.{suggestion_text} "
|
|
170
|
+
f"Available methods: {list(self._methods.keys())}"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
params = {**(request_dict or {}), **kwargs}
|
|
174
|
+
grpc_method = self._methods[method_name]
|
|
175
|
+
request_class = self._request_classes[method_name]
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
request = request_class(**params)
|
|
179
|
+
return grpc_method(request, metadata=self.metadata)
|
|
180
|
+
except grpc.RpcError as e:
|
|
181
|
+
details = e.details() if hasattr(e, "details") and callable(e.details) else str(e)
|
|
182
|
+
raise RuntimeError(f"gRPC call to {method_name} failed: {details}") from e
|
|
183
|
+
except Exception as e:
|
|
184
|
+
raise RuntimeError(f"Error calling {method_name}: {str(e)}") from e
|
|
185
|
+
|
|
186
|
+
def _create_method_wrapper(self, method_name: str, grpc_method: Any) -> Callable[..., Any]:
|
|
187
|
+
"""Create a wrapper for gRPC method that delegates to call()."""
|
|
188
|
+
|
|
189
|
+
def wrapper(*args, **kwargs):
|
|
190
|
+
if args:
|
|
191
|
+
if len(args) > 1:
|
|
192
|
+
raise ValueError(f"Expected 0 or 1 positional arguments, got {len(args)}")
|
|
193
|
+
request = args[0]
|
|
194
|
+
try:
|
|
195
|
+
return grpc_method(request, metadata=self.metadata)
|
|
196
|
+
except grpc.RpcError as e:
|
|
197
|
+
details = (
|
|
198
|
+
e.details() if hasattr(e, "details") and callable(e.details) else str(e)
|
|
199
|
+
)
|
|
200
|
+
raise RuntimeError(f"gRPC call to {method_name} failed: {details}") from e
|
|
201
|
+
else:
|
|
202
|
+
return self.call(method_name, **kwargs)
|
|
203
|
+
|
|
204
|
+
wrapper.__name__ = method_name
|
|
205
|
+
return wrapper
|
|
206
|
+
|
|
207
|
+
def get_available_methods(self) -> List[str]:
|
|
208
|
+
"""Get list of available RPC methods."""
|
|
209
|
+
return list(self._methods.keys())
|
|
210
|
+
|
|
211
|
+
def get_request_class(self, method_name: str) -> Any:
|
|
212
|
+
"""Get the request class for a method."""
|
|
213
|
+
return self._request_classes.get(method_name)
|
|
214
|
+
|
|
215
|
+
def close(self) -> None:
|
|
216
|
+
"""Close the gRPC channel and cleanup."""
|
|
217
|
+
if self._channel:
|
|
218
|
+
self._channel.close()
|
|
219
|
+
self._channel = None
|
|
220
|
+
if self._temp_dir:
|
|
221
|
+
self._temp_dir.cleanup()
|
|
222
|
+
self._temp_dir = None
|
|
223
|
+
|
|
224
|
+
def __del__(self):
|
|
225
|
+
self.close()
|
|
226
|
+
|
|
227
|
+
def __repr__(self) -> str:
|
|
228
|
+
methods = ", ".join(self._methods.keys()) if self._methods else "No methods loaded"
|
|
229
|
+
return f"<GrpcClient(target='{self.target}', methods=[{methods}])>"
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from typing import Any, Dict, Optional
|
|
2
|
+
from klab_pytest_toolkit_web._api_client_types import ApiClient
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RestApiClient(ApiClient):
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
base_url: str,
|
|
10
|
+
headers: Optional[Dict[str, str]] = None,
|
|
11
|
+
):
|
|
12
|
+
"""
|
|
13
|
+
Initialize REST API client.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
base_url: Base URL for API requests
|
|
17
|
+
headers: Optional default headers for all requests
|
|
18
|
+
"""
|
|
19
|
+
self.base_url = base_url
|
|
20
|
+
self.headers = headers or {}
|
|
21
|
+
self.session = requests.Session()
|
|
22
|
+
|
|
23
|
+
def get(
|
|
24
|
+
self,
|
|
25
|
+
endpoint: str,
|
|
26
|
+
params: Optional[Dict[str, Any]] = None,
|
|
27
|
+
timeout: Optional[int] = None,
|
|
28
|
+
) -> requests.Response:
|
|
29
|
+
"""
|
|
30
|
+
Make a GET request.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
endpoint: API endpoint path
|
|
34
|
+
params: Optional query parameters
|
|
35
|
+
timeout: Optional timeout in seconds for this request
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Response object
|
|
39
|
+
"""
|
|
40
|
+
url = f"{self.base_url}{endpoint}"
|
|
41
|
+
return self.session.get(
|
|
42
|
+
url,
|
|
43
|
+
params=params,
|
|
44
|
+
headers=self.headers,
|
|
45
|
+
timeout=timeout,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def post(
|
|
49
|
+
self,
|
|
50
|
+
endpoint: str,
|
|
51
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
52
|
+
timeout: Optional[int] = None,
|
|
53
|
+
) -> requests.Response:
|
|
54
|
+
"""
|
|
55
|
+
Make a POST request.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
endpoint: API endpoint path
|
|
59
|
+
payload: Optional JSON payload
|
|
60
|
+
timeout: Optional timeout in seconds for this request
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Response object
|
|
64
|
+
"""
|
|
65
|
+
url = f"{self.base_url}{endpoint}"
|
|
66
|
+
return self.session.post(
|
|
67
|
+
url,
|
|
68
|
+
json=payload,
|
|
69
|
+
headers=self.headers,
|
|
70
|
+
timeout=timeout,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def put(
|
|
74
|
+
self,
|
|
75
|
+
endpoint: str,
|
|
76
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
77
|
+
timeout: Optional[int] = None,
|
|
78
|
+
) -> requests.Response:
|
|
79
|
+
"""
|
|
80
|
+
Make a PUT request.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
endpoint: API endpoint path
|
|
84
|
+
payload: Optional JSON payload
|
|
85
|
+
timeout: Optional timeout in seconds for this request
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Response object
|
|
89
|
+
"""
|
|
90
|
+
url = f"{self.base_url}{endpoint}"
|
|
91
|
+
return self.session.put(
|
|
92
|
+
url,
|
|
93
|
+
json=payload,
|
|
94
|
+
headers=self.headers,
|
|
95
|
+
timeout=timeout,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def patch(
|
|
99
|
+
self,
|
|
100
|
+
endpoint: str,
|
|
101
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
102
|
+
timeout: Optional[int] = None,
|
|
103
|
+
) -> requests.Response:
|
|
104
|
+
"""
|
|
105
|
+
Make a PATCH request.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
endpoint: API endpoint path
|
|
109
|
+
payload: Optional JSON payload
|
|
110
|
+
timeout: Optional timeout in seconds for this request
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Response object
|
|
114
|
+
"""
|
|
115
|
+
url = f"{self.base_url}{endpoint}"
|
|
116
|
+
return self.session.patch(
|
|
117
|
+
url,
|
|
118
|
+
json=payload,
|
|
119
|
+
headers=self.headers,
|
|
120
|
+
timeout=timeout,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def delete(
|
|
124
|
+
self,
|
|
125
|
+
endpoint: str,
|
|
126
|
+
timeout: Optional[int] = None,
|
|
127
|
+
) -> requests.Response:
|
|
128
|
+
"""
|
|
129
|
+
Make a DELETE request.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
endpoint: API endpoint path
|
|
133
|
+
timeout: Optional timeout in seconds for this request
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Response object
|
|
137
|
+
"""
|
|
138
|
+
url = f"{self.base_url}{endpoint}"
|
|
139
|
+
return self.session.delete(
|
|
140
|
+
url,
|
|
141
|
+
headers=self.headers,
|
|
142
|
+
timeout=timeout,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def close(self) -> None:
|
|
146
|
+
"""Close the session."""
|
|
147
|
+
self.session.close()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import grpc
|
|
2
|
+
from klab_pytest_toolkit_web._api_client_types.rest_client import RestApiClient
|
|
3
|
+
from typing import Dict, Any, Optional, List, Tuple
|
|
4
|
+
from klab_pytest_toolkit_web._api_client_types.grpc_client import GrpcClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ApiClientFactory:
|
|
8
|
+
def create_rest_client(
|
|
9
|
+
self,
|
|
10
|
+
base_url: str,
|
|
11
|
+
headers: Optional[Dict[str, str]] = None,
|
|
12
|
+
) -> RestApiClient:
|
|
13
|
+
"""
|
|
14
|
+
Create a REST API client instance.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
base_url: Base URL for API requests
|
|
18
|
+
headers: Optional default headers
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
RestApiClient instance
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
>>> factory = ApiClientFactory()
|
|
25
|
+
>>> client = factory.create_rest_client(
|
|
26
|
+
... base_url="https://api.example.com",
|
|
27
|
+
... headers={"Authorization": "Bearer token"}
|
|
28
|
+
... )
|
|
29
|
+
>>> response = client.get("/users/1")
|
|
30
|
+
>>> # Set timeout per request when needed
|
|
31
|
+
>>> response = client.get("/slow-endpoint", timeout=120)
|
|
32
|
+
"""
|
|
33
|
+
return RestApiClient(base_url=base_url, headers=headers)
|
|
34
|
+
|
|
35
|
+
def create_grpc_client(
|
|
36
|
+
self,
|
|
37
|
+
target: str,
|
|
38
|
+
proto_file: str,
|
|
39
|
+
credentials: Optional[grpc.ChannelCredentials] = None,
|
|
40
|
+
options: Optional[List[Tuple[str, Any]]] = None,
|
|
41
|
+
metadata: Optional[List[Tuple[str, str]]] = None,
|
|
42
|
+
) -> GrpcClient:
|
|
43
|
+
"""
|
|
44
|
+
Create a gRPC client instance with dynamic method binding.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
target: gRPC server address (e.g., "localhost:50051")
|
|
48
|
+
proto_file: Path to .proto file for service definition
|
|
49
|
+
credentials: Optional channel credentials for secure connection
|
|
50
|
+
options: Optional channel options (e.g., [('grpc.max_receive_message_length', -1)])
|
|
51
|
+
metadata: Optional metadata to send with each request
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
GrpcClient instance with dynamically bound methods
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
ValueError: If neither proto_file nor use_reflection is provided
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
>>> # Using proto file
|
|
61
|
+
>>> factory = ApiClientFactory()
|
|
62
|
+
>>> client = factory.create_grpc_client(
|
|
63
|
+
... target="localhost:50051",
|
|
64
|
+
... proto_file="path/to/service.proto"
|
|
65
|
+
... )
|
|
66
|
+
>>> response = client.GetUser(id=123)
|
|
67
|
+
>>>
|
|
68
|
+
>>> # With SSL/TLS
|
|
69
|
+
>>> creds = grpc.ssl_channel_credentials()
|
|
70
|
+
>>> client = factory.create_grpc_client(
|
|
71
|
+
... target="secure.example.com:443",
|
|
72
|
+
... proto_file="service.proto",
|
|
73
|
+
... credentials=creds
|
|
74
|
+
... )
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
return GrpcClient(
|
|
78
|
+
target=target,
|
|
79
|
+
proto_file=proto_file,
|
|
80
|
+
credentials=credentials,
|
|
81
|
+
options=options,
|
|
82
|
+
metadata=metadata,
|
|
83
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Pytest plugin to register web fixtures."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from klab_pytest_toolkit_web import (
|
|
5
|
+
ApiClientFactory,
|
|
6
|
+
ResponseValidatorFactory,
|
|
7
|
+
WebClientFactory,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def pytest_configure(config):
|
|
12
|
+
"""Register custom markers and configure web fixtures."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def pytest_addoption(parser):
|
|
17
|
+
"""Add command line and ini file options."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def response_validator_factory() -> ResponseValidatorFactory:
|
|
23
|
+
"""
|
|
24
|
+
Factory fixture to create multiple validators with different schemas.
|
|
25
|
+
|
|
26
|
+
Useful when you need multiple validators in a single test.
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
def test_multiple_schemas(response_validator_factory: JsonResponseValidatorFactory):
|
|
30
|
+
user_validator = response_validator_factory.create(user_schema)
|
|
31
|
+
post_validator = response_validator_factory.create(post_schema)
|
|
32
|
+
|
|
33
|
+
assert user_validator.validate_response(user_data)
|
|
34
|
+
assert post_validator.validate_response(post_data)
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
JsonResponseValidatorFactory: Factory to create JsonResponseValidator instances
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
return ResponseValidatorFactory()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture
|
|
44
|
+
def api_client_factory() -> ApiClientFactory:
|
|
45
|
+
"""
|
|
46
|
+
Fixture to provide an API client factory for making web requests.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
ApiClientFactory: Factory to create API client instances
|
|
50
|
+
"""
|
|
51
|
+
return ApiClientFactory()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.fixture
|
|
55
|
+
def web_client_factory() -> WebClientFactory:
|
|
56
|
+
"""
|
|
57
|
+
Fixture to provide a Web client factory for making web requests.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
WebClientFactory: Factory to create Web client instances
|
|
61
|
+
"""
|
|
62
|
+
return WebClientFactory()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from typing import Dict, Any, Optional
|
|
2
|
+
from jsonschema import validate, ValidationError
|
|
3
|
+
from jsonschema.exceptions import SchemaError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class JsonResponseValidator:
|
|
7
|
+
"""Validator for JSON responses with schema validation and additional checks."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
schema: Optional[Dict[str, Any]] = None,
|
|
12
|
+
raise_on_error: Optional[bool] = None,
|
|
13
|
+
):
|
|
14
|
+
"""
|
|
15
|
+
Initialize the JSON response validator.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
schema: JSON schema to validate against (can be set later)
|
|
19
|
+
strict_mode: If True, disallow additional properties not in schema
|
|
20
|
+
raise_on_error: If True, raise ValidationError instead of returning False
|
|
21
|
+
"""
|
|
22
|
+
self.schema = schema
|
|
23
|
+
self.raise_on_error = raise_on_error
|
|
24
|
+
self.last_error: Optional[str] = None
|
|
25
|
+
|
|
26
|
+
def validate_response(self, response_data: Dict[str, Any]) -> bool:
|
|
27
|
+
"""
|
|
28
|
+
Validate response data against the schema.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
response_data: The JSON response data to validate
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
True if valid, False otherwise (unless raise_on_error=True)
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
ValidationError: If raise_on_error=True and validation fails
|
|
38
|
+
ValueError: If no schema is set
|
|
39
|
+
"""
|
|
40
|
+
if self.schema is None:
|
|
41
|
+
raise ValueError("No schema set for validation")
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
validate(instance=response_data, schema=self.schema)
|
|
45
|
+
self.last_error = None
|
|
46
|
+
return True
|
|
47
|
+
except ValidationError as e:
|
|
48
|
+
self.last_error = str(e)
|
|
49
|
+
if self.raise_on_error:
|
|
50
|
+
raise
|
|
51
|
+
return False
|
|
52
|
+
except SchemaError as e:
|
|
53
|
+
self.last_error = f"Invalid schema: {str(e)}"
|
|
54
|
+
if self.raise_on_error:
|
|
55
|
+
raise
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
def get_last_error(self) -> str:
|
|
59
|
+
"""Get the last validation error message."""
|
|
60
|
+
if self.last_error is None:
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
return self.last_error
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ResponseValidatorFactory:
|
|
67
|
+
"""Factory to create different Response Validators instances with different configurations."""
|
|
68
|
+
|
|
69
|
+
def create_json_validator(
|
|
70
|
+
self,
|
|
71
|
+
schema: Optional[Dict[str, Any]] = None,
|
|
72
|
+
raise_on_error: Optional[bool] = None,
|
|
73
|
+
) -> JsonResponseValidator:
|
|
74
|
+
"""
|
|
75
|
+
Create a new JsonResponseValidator instance.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
schema: JSON schema for the validator
|
|
79
|
+
strict_mode: Override default strict mode
|
|
80
|
+
raise_on_error: Override default error handling
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
JsonResponseValidator: Configured validator instance
|
|
84
|
+
"""
|
|
85
|
+
return JsonResponseValidator(
|
|
86
|
+
schema=schema,
|
|
87
|
+
raise_on_error=raise_on_error,
|
|
88
|
+
)
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class WebClient(abc.ABC):
|
|
6
|
+
"""A simple web client is a class which can test websites by making
|
|
7
|
+
- searching element and clicking
|
|
8
|
+
- filling forms
|
|
9
|
+
- navigating pages
|
|
10
|
+
- checks if page contains some text or elements
|
|
11
|
+
|
|
12
|
+
This is an abstract base class that defines the interface for web clients.
|
|
13
|
+
Implementations should provide browser automation capabilities for e2e testing.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
@abc.abstractmethod
|
|
17
|
+
def navigate_to(self, url: str) -> None:
|
|
18
|
+
"""Navigate to a specified URL.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
url: The URL to navigate to
|
|
22
|
+
"""
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
@abc.abstractmethod
|
|
26
|
+
def click(self, selector: str) -> None:
|
|
27
|
+
"""Click on an element identified by a selector.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
selector: CSS selector, XPath, or text selector to identify the element
|
|
31
|
+
"""
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
|
|
34
|
+
@abc.abstractmethod
|
|
35
|
+
def fill(self, selector: str, value: str) -> None:
|
|
36
|
+
"""Fill a form field with a value.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
selector: CSS selector, XPath, or text selector to identify the input element
|
|
40
|
+
value: The value to fill into the field
|
|
41
|
+
"""
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
|
|
44
|
+
@abc.abstractmethod
|
|
45
|
+
def get_text(self, selector: str) -> str:
|
|
46
|
+
"""Get the text content of an element.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
selector: CSS selector, XPath, or text selector to identify the element
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The text content of the element
|
|
53
|
+
"""
|
|
54
|
+
raise NotImplementedError
|
|
55
|
+
|
|
56
|
+
@abc.abstractmethod
|
|
57
|
+
def get_attribute(self, selector: str, attribute: str) -> Optional[str]:
|
|
58
|
+
"""Get an attribute value of an element.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
selector: CSS selector, XPath, or text selector to identify the element
|
|
62
|
+
attribute: The name of the attribute to retrieve
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The attribute value or None if not found
|
|
66
|
+
"""
|
|
67
|
+
raise NotImplementedError
|
|
68
|
+
|
|
69
|
+
@abc.abstractmethod
|
|
70
|
+
def get_input_value(self, selector: str) -> str:
|
|
71
|
+
"""Get the current value of an input element.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
selector: CSS selector to identify the input element
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The current value of the input
|
|
78
|
+
"""
|
|
79
|
+
raise NotImplementedError
|
|
80
|
+
|
|
81
|
+
@abc.abstractmethod
|
|
82
|
+
def is_checked(self, selector: str) -> bool:
|
|
83
|
+
"""Check if a checkbox or radio button is checked.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
selector: CSS selector to identify the checkbox/radio element
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
True if checked, False otherwise
|
|
90
|
+
"""
|
|
91
|
+
raise NotImplementedError
|
|
92
|
+
|
|
93
|
+
@abc.abstractmethod
|
|
94
|
+
def is_visible(self, selector: str) -> bool:
|
|
95
|
+
"""Check if an element is visible on the page.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
selector: CSS selector, XPath, or text selector to identify the element
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
True if the element is visible, False otherwise
|
|
102
|
+
"""
|
|
103
|
+
raise NotImplementedError
|
|
104
|
+
|
|
105
|
+
@abc.abstractmethod
|
|
106
|
+
def is_enabled(self, selector: str) -> bool:
|
|
107
|
+
"""Check if an element is enabled (not disabled).
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
selector: CSS selector, XPath, or text selector to identify the element
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
True if the element is enabled, False otherwise
|
|
114
|
+
"""
|
|
115
|
+
raise NotImplementedError
|
|
116
|
+
|
|
117
|
+
@abc.abstractmethod
|
|
118
|
+
def wait_for_element(self, selector: str, timeout: int = 30000) -> None:
|
|
119
|
+
"""Wait for an element to be present in the DOM.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
selector: CSS selector, XPath, or text selector to identify the element
|
|
123
|
+
timeout: Maximum time to wait in milliseconds (default: 30000)
|
|
124
|
+
"""
|
|
125
|
+
raise NotImplementedError
|
|
126
|
+
|
|
127
|
+
@abc.abstractmethod
|
|
128
|
+
def wait_for_element_visible(self, selector: str, timeout: int = 30000) -> None:
|
|
129
|
+
"""Wait for an element to be visible on the page.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
selector: CSS selector, XPath, or text selector to identify the element
|
|
133
|
+
timeout: Maximum time to wait in milliseconds (default: 30000)
|
|
134
|
+
"""
|
|
135
|
+
raise NotImplementedError
|
|
136
|
+
|
|
137
|
+
@abc.abstractmethod
|
|
138
|
+
def get_title(self) -> str:
|
|
139
|
+
"""Get the page title.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
The title of the current page
|
|
143
|
+
"""
|
|
144
|
+
raise NotImplementedError
|
|
145
|
+
|
|
146
|
+
@abc.abstractmethod
|
|
147
|
+
def get_url(self) -> str:
|
|
148
|
+
"""Get the current page URL.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
The current URL
|
|
152
|
+
"""
|
|
153
|
+
raise NotImplementedError
|
|
154
|
+
|
|
155
|
+
@abc.abstractmethod
|
|
156
|
+
def get_page_source(self) -> str:
|
|
157
|
+
"""Get the HTML source of the current page.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
The HTML source as a string
|
|
161
|
+
"""
|
|
162
|
+
raise NotImplementedError
|
|
163
|
+
|
|
164
|
+
@abc.abstractmethod
|
|
165
|
+
def contains_text(self, text: str) -> bool:
|
|
166
|
+
"""Check if the page contains the specified text.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
text: The text to search for
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
True if the text is found, False otherwise
|
|
173
|
+
"""
|
|
174
|
+
raise NotImplementedError
|
|
175
|
+
|
|
176
|
+
@abc.abstractmethod
|
|
177
|
+
def get_elements_count(self, selector: str) -> int:
|
|
178
|
+
"""Get the count of elements matching the selector.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
selector: CSS selector, XPath, or text selector to identify the elements
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
The number of matching elements
|
|
185
|
+
"""
|
|
186
|
+
raise NotImplementedError
|
|
187
|
+
|
|
188
|
+
@abc.abstractmethod
|
|
189
|
+
def select_option(self, selector: str, value: str) -> None:
|
|
190
|
+
"""Select an option from a dropdown/select element.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
selector: CSS selector to identify the select element
|
|
194
|
+
value: The value of the option to select
|
|
195
|
+
"""
|
|
196
|
+
raise NotImplementedError
|
|
197
|
+
|
|
198
|
+
@abc.abstractmethod
|
|
199
|
+
def check(self, selector: str) -> None:
|
|
200
|
+
"""Check a checkbox or radio button.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
selector: CSS selector to identify the checkbox/radio element
|
|
204
|
+
"""
|
|
205
|
+
raise NotImplementedError
|
|
206
|
+
|
|
207
|
+
@abc.abstractmethod
|
|
208
|
+
def uncheck(self, selector: str) -> None:
|
|
209
|
+
"""Uncheck a checkbox.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
selector: CSS selector to identify the checkbox element
|
|
213
|
+
"""
|
|
214
|
+
raise NotImplementedError
|
|
215
|
+
|
|
216
|
+
@abc.abstractmethod
|
|
217
|
+
def screenshot(self, path: str) -> None:
|
|
218
|
+
"""Take a screenshot of the current page.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
path: File path where the screenshot should be saved
|
|
222
|
+
"""
|
|
223
|
+
raise NotImplementedError
|
|
224
|
+
|
|
225
|
+
@abc.abstractmethod
|
|
226
|
+
def close(self) -> None:
|
|
227
|
+
"""Close the browser and clean up resources."""
|
|
228
|
+
raise NotImplementedError
|
|
229
|
+
|
|
230
|
+
def __enter__(self) -> "WebClient":
|
|
231
|
+
"""Context manager entry."""
|
|
232
|
+
return self
|
|
233
|
+
|
|
234
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
235
|
+
"""Context manager exit - ensures cleanup."""
|
|
236
|
+
self.close()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class PlayWrightWebClient(WebClient):
|
|
240
|
+
"""A web client implementation using Playwright."""
|
|
241
|
+
|
|
242
|
+
def __init__(self, headless: bool = True):
|
|
243
|
+
"""Initialize Playwright web client.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
headless: Whether to run the browser in headless mode (default: True)
|
|
247
|
+
"""
|
|
248
|
+
from playwright.sync_api import sync_playwright
|
|
249
|
+
|
|
250
|
+
self._playwright = sync_playwright().start()
|
|
251
|
+
self._browser = self._playwright.chromium.launch(headless=headless)
|
|
252
|
+
self._page = self._browser.new_page()
|
|
253
|
+
|
|
254
|
+
def navigate_to(self, url: str) -> None:
|
|
255
|
+
"""Navigate to a specified URL."""
|
|
256
|
+
self._page.goto(url)
|
|
257
|
+
|
|
258
|
+
def click(self, selector: str) -> None:
|
|
259
|
+
"""Click on an element identified by a selector."""
|
|
260
|
+
self._page.click(selector)
|
|
261
|
+
|
|
262
|
+
def fill(self, selector: str, value: str) -> None:
|
|
263
|
+
"""Fill a form field with a value."""
|
|
264
|
+
self._page.fill(selector, value)
|
|
265
|
+
|
|
266
|
+
def get_text(self, selector: str) -> str:
|
|
267
|
+
"""Get the text content of an element."""
|
|
268
|
+
return self._page.text_content(selector) or ""
|
|
269
|
+
|
|
270
|
+
def get_attribute(self, selector: str, attribute: str) -> Optional[str]:
|
|
271
|
+
"""Get an attribute value of an element."""
|
|
272
|
+
return self._page.get_attribute(selector, attribute)
|
|
273
|
+
|
|
274
|
+
def get_input_value(self, selector: str) -> str:
|
|
275
|
+
"""Get the current value of an input element."""
|
|
276
|
+
return self._page.input_value(selector)
|
|
277
|
+
|
|
278
|
+
def is_checked(self, selector: str) -> bool:
|
|
279
|
+
"""Check if a checkbox or radio button is checked."""
|
|
280
|
+
return self._page.is_checked(selector)
|
|
281
|
+
|
|
282
|
+
def is_visible(self, selector: str) -> bool:
|
|
283
|
+
"""Check if an element is visible on the page."""
|
|
284
|
+
return self._page.is_visible(selector)
|
|
285
|
+
|
|
286
|
+
def is_enabled(self, selector: str) -> bool:
|
|
287
|
+
"""Check if an element is enabled (not disabled)."""
|
|
288
|
+
return self._page.is_enabled(selector)
|
|
289
|
+
|
|
290
|
+
def wait_for_element(self, selector: str, timeout: int = 30000) -> None:
|
|
291
|
+
"""Wait for an element to be present in the DOM."""
|
|
292
|
+
self._page.wait_for_selector(selector, timeout=timeout)
|
|
293
|
+
|
|
294
|
+
def wait_for_element_visible(self, selector: str, timeout: int = 30000) -> None:
|
|
295
|
+
"""Wait for an element to be visible on the page."""
|
|
296
|
+
self._page.wait_for_selector(selector, state="visible", timeout=timeout)
|
|
297
|
+
|
|
298
|
+
def get_title(self) -> str:
|
|
299
|
+
"""Get the page title."""
|
|
300
|
+
return self._page.title()
|
|
301
|
+
|
|
302
|
+
def get_url(self) -> str:
|
|
303
|
+
"""Get the current page URL."""
|
|
304
|
+
return self._page.url
|
|
305
|
+
|
|
306
|
+
def get_page_source(self) -> str:
|
|
307
|
+
"""Get the HTML source of the current page."""
|
|
308
|
+
return self._page.content()
|
|
309
|
+
|
|
310
|
+
def contains_text(self, text: str) -> bool:
|
|
311
|
+
"""Check if the page contains the specified text."""
|
|
312
|
+
return text in self._page.content()
|
|
313
|
+
|
|
314
|
+
def get_elements_count(self, selector: str) -> int:
|
|
315
|
+
"""Get the count of elements matching the selector."""
|
|
316
|
+
return len(self._page.query_selector_all(selector))
|
|
317
|
+
|
|
318
|
+
def select_option(self, selector: str, value: str) -> None:
|
|
319
|
+
"""Select an option from a dropdown/select element."""
|
|
320
|
+
self._page.select_option(selector, value)
|
|
321
|
+
|
|
322
|
+
def check(self, selector: str) -> None:
|
|
323
|
+
"""Check a checkbox or radio button."""
|
|
324
|
+
self._page.check(selector)
|
|
325
|
+
|
|
326
|
+
def uncheck(self, selector: str) -> None:
|
|
327
|
+
"""Uncheck a checkbox."""
|
|
328
|
+
self._page.uncheck(selector)
|
|
329
|
+
|
|
330
|
+
def screenshot(self, path: str) -> None:
|
|
331
|
+
"""Take a screenshot of the current page."""
|
|
332
|
+
self._page.screenshot(path=path)
|
|
333
|
+
|
|
334
|
+
def close(self) -> None:
|
|
335
|
+
"""Close the browser and Playwright."""
|
|
336
|
+
self._browser.close()
|
|
337
|
+
self._playwright.stop()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class WebClientFactory:
|
|
341
|
+
"""Factory to create web client instances."""
|
|
342
|
+
|
|
343
|
+
class WebClientType:
|
|
344
|
+
PLAYWRIGHT = "playwright"
|
|
345
|
+
|
|
346
|
+
@staticmethod
|
|
347
|
+
def create_client(client_type: str = "playwright", headless: bool = True) -> WebClient:
|
|
348
|
+
"""Create a web client based on the specified type.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
client_type: Type of web client to create (e.g., "playwright")
|
|
352
|
+
headless: Whether to run the browser in headless mode (default: True)
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
An instance of WebClient
|
|
356
|
+
"""
|
|
357
|
+
if client_type == WebClientFactory.WebClientType.PLAYWRIGHT:
|
|
358
|
+
return PlayWrightWebClient(headless=headless)
|
|
359
|
+
else:
|
|
360
|
+
raise ValueError(f"Unsupported client type: {client_type}")
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: klab-pytest-toolkit-web
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Pytest web fixtures for the Klab Pytest Toolkit
|
|
5
|
+
Project-URL: Changelog, https://github.com/klab365/klab-pytest-toolkit/blob/main/CHANGELOG.md
|
|
6
|
+
Project-URL: Repository, https://github.com/klab365/klab-pytest-toolkit
|
|
7
|
+
Project-URL: Issues, https://github.com/klab365/klab-pytest-toolkit/issues
|
|
8
|
+
Author-email: Burak Kizilkaya <burak.kizilkaya@outlook.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Requires-Python: <4,>=3.11
|
|
15
|
+
Requires-Dist: grpcio-reflection>=1.66.0
|
|
16
|
+
Requires-Dist: grpcio-tools>=1.66.0
|
|
17
|
+
Requires-Dist: grpcio>=1.66.0
|
|
18
|
+
Requires-Dist: jsonschema>=4.25.1
|
|
19
|
+
Requires-Dist: playwright>=1.57.0
|
|
20
|
+
Requires-Dist: pytest>=8.3.5
|
|
21
|
+
Requires-Dist: requests>=2.32.5
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Klab Pytest Toolkit - Web Fixtures
|
|
25
|
+
|
|
26
|
+
Custom pytest fixtures for web testing.
|
|
27
|
+
The goal is to allow testers to easily test web applications (html/json/rest api) with reusable components.
|
|
28
|
+
|
|
29
|
+
At the moment the package provides the following fixtures:
|
|
30
|
+
|
|
31
|
+
- `response_validator_factory`: Factory for create JSON response validator instances with custom configurations.
|
|
32
|
+
- `api_client_factory`: Factory for create different API client instances.
|
|
33
|
+
- REST API client for making HTTP requests to RESTful services.
|
|
34
|
+
- `web_client_factory`: Factory to create web client instances for browser automation
|
|
35
|
+
- Playwright-based web client for end-to-end testing of web applications.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install klab-pytest-toolkit-web
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
### JSON Response Validator
|
|
46
|
+
|
|
47
|
+
**Create the fixture**
|
|
48
|
+
|
|
49
|
+
The factory class `ResponseValidatorFactory` is already provided as a pytest fixture `response_validator_factory`.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def json_validator_user_schema(response_validator_factory) -> JsonResponseValidator:
|
|
54
|
+
"""Fixture to provide a JSON response validator for user schema."""
|
|
55
|
+
user_schema = {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"properties": {
|
|
58
|
+
"id": {"type": "integer"},
|
|
59
|
+
"name": {"type": "string"},
|
|
60
|
+
},
|
|
61
|
+
"required": ["id", "name"]
|
|
62
|
+
}
|
|
63
|
+
return response_validator_factory.create_json_validator(schema=user_schema)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Functions**
|
|
67
|
+
|
|
68
|
+
The validator contains one main function to validate a response against the schema. Below is an example of how to use the validator in a test.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
def test_user_api(json_validator_user_schema):
|
|
72
|
+
"""Test user API response validation."""
|
|
73
|
+
response_data = {
|
|
74
|
+
"id": 1,
|
|
75
|
+
"name": "John Doe"
|
|
76
|
+
}
|
|
77
|
+
assert json_validator_user_schema.validate_response(response_data)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### REST API Client
|
|
81
|
+
|
|
82
|
+
**Create the fixture**
|
|
83
|
+
|
|
84
|
+
The factory class `ApiClientFactory` is already provided as a pytest fixture `api_client_factory`.
|
|
85
|
+
To pass the url or other header information, you can pass this as environment variables or configure directly in the fixture.
|
|
86
|
+
Below is an example of creating a REST API client fixture from a testcontainer url.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
|
|
90
|
+
@pytest.fixture(scope="session")
|
|
91
|
+
def httpbin_container():
|
|
92
|
+
"""Fixture to provide an HTTPBin container for testing."""
|
|
93
|
+
|
|
94
|
+
with DockerContainer("kennethreitz/httpbin:latest") as httpbin:
|
|
95
|
+
httpbin.with_exposed_ports(80)
|
|
96
|
+
httpbin.waiting_for(HttpWaitStrategy(path="/get", port=80).for_status_code(200))
|
|
97
|
+
httpbin.start()
|
|
98
|
+
port = httpbin.get_exposed_port(80)
|
|
99
|
+
base_url = f"http://localhost:{port}"
|
|
100
|
+
yield base_url
|
|
101
|
+
|
|
102
|
+
@pytest.fixture
|
|
103
|
+
def rest_api_client(api_client_factory, httpbin_container) -> RestApiClient:
|
|
104
|
+
"""Fixture to provide a REST API client."""
|
|
105
|
+
return api_client_factory.create_rest_client(base_url=httpbin_container)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Functions**
|
|
109
|
+
|
|
110
|
+
The REST API client provides functions to make HTTP requests. These are some examples:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
def test_get_request(rest_api_client: RestApiClient):
|
|
114
|
+
"""Test basic GET request with query parameters."""
|
|
115
|
+
response = rest_api_client.get("/get", params={"test": "value"})
|
|
116
|
+
|
|
117
|
+
assert response.status_code == 200
|
|
118
|
+
json_data = response.json()
|
|
119
|
+
assert json_data["args"]["test"] == "value"
|
|
120
|
+
|
|
121
|
+
def test_post_request(rest_api_client: RestApiClient):
|
|
122
|
+
"""Test basic POST request with JSON body."""
|
|
123
|
+
response = rest_api_client.post("/post", payload={"key": "value"})
|
|
124
|
+
|
|
125
|
+
assert response.status_code == 200
|
|
126
|
+
json_data = response.json()
|
|
127
|
+
assert json_data["json"]["key"] == "value"
|
|
128
|
+
|
|
129
|
+
def test_update_request(rest_api_client: RestApiClient):
|
|
130
|
+
"""Test basic PUT request with JSON body."""
|
|
131
|
+
response = rest_api_client.put("/put", payload={"update": "data"})
|
|
132
|
+
|
|
133
|
+
assert response.status_code == 200
|
|
134
|
+
json_data = response.json()
|
|
135
|
+
assert json_data["json"]["update"] == "data"
|
|
136
|
+
|
|
137
|
+
def test_delete_request(rest_api_client: RestApiClient):
|
|
138
|
+
"""Test basic DELETE request."""
|
|
139
|
+
response = rest_api_client.delete("/delete")
|
|
140
|
+
|
|
141
|
+
assert response.status_code == 200
|
|
142
|
+
json_data = response.json()
|
|
143
|
+
assert json_data["url"].endswith("/delete")
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### gRPC Client
|
|
147
|
+
|
|
148
|
+
**Create the fixture**
|
|
149
|
+
|
|
150
|
+
The factory class `ApiClientFactory` is already provided as a pytest fixture `api_client_factory`.
|
|
151
|
+
You can create a gRPC client fixture as shown below:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
@pytest.fixture
|
|
155
|
+
def grpc_client(api_client_factory) -> GrpcClient:
|
|
156
|
+
"""Fixture to provide a gRPC client."""
|
|
157
|
+
return api_client_factory.create_grpc_client(
|
|
158
|
+
target="localhost:50051",
|
|
159
|
+
proto_file="path/to/your/service.proto"
|
|
160
|
+
)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**Functions**
|
|
164
|
+
|
|
165
|
+
The `GrpcClient` provides gRPC call functionality by dynamically invoking methods defined in the provided proto file. It is imported the that the proto file defines a service with methods like `GetUser`. You will not get any code completion in your IDE since the methods are dynamically resolved at runtime, but you get error handling if the method does not exist.
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
def test_grpc_get_user(grpc_client: GrpcClient):
|
|
169
|
+
"""Test gRPC GetUser call."""
|
|
170
|
+
response = grpc_client.GetUser(id=123)
|
|
171
|
+
|
|
172
|
+
assert response.id == 123
|
|
173
|
+
assert response.name == "John Doe"
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Playwright Web Client
|
|
177
|
+
|
|
178
|
+
**Create the fixture**
|
|
179
|
+
|
|
180
|
+
The factory class `WebClientFactory` is already provided as a pytest fixture `web_client_factory`.
|
|
181
|
+
For playwright, you might install the browsers first by running `playwright install` in your environment.
|
|
182
|
+
You can create a Playwright web client fixture as shown below:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
@pytest.fixture
|
|
186
|
+
def web_client(web_client_factory) -> PlaywrightWebClient:
|
|
187
|
+
"""Fixture to provide a Playwright web client."""
|
|
188
|
+
with web_client_factory.create_client(client_type="playwright", headless=True) as client:
|
|
189
|
+
yield client
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
**Functions**
|
|
193
|
+
|
|
194
|
+
The `WebClient` provides a variety of functions for browser automation. Refer to the api of the instance.
|
|
195
|
+
Here are some examples of common operations:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
def test_navigate_and_click(web_client):
|
|
199
|
+
"""Test navigation and clicking a button."""
|
|
200
|
+
web_client.navigate_to("https://example.com")
|
|
201
|
+
web_client.click("#start-button")
|
|
202
|
+
assert web_client.get_text("#result") == "Started"
|
|
203
|
+
|
|
204
|
+
def test_form_submission(web_client):
|
|
205
|
+
"""Test form submission."""
|
|
206
|
+
web_client.navigate_to("https://example.com/form")
|
|
207
|
+
web_client.fill_input("#name", "Test User")
|
|
208
|
+
web_client.fill_input("#email", "max@muster.com")
|
|
209
|
+
web_client.click("#submit-button")
|
|
210
|
+
assert web_client.get_text("#confirmation") == "Thank you for your submission!"
|
|
211
|
+
|
|
212
|
+
def test_wait_for_element(web_client):
|
|
213
|
+
"""Test waiting for an element to appear."""
|
|
214
|
+
web_client.navigate_to("https://example.com/dynamic")
|
|
215
|
+
web_client.wait_for_element("#dynamic-content", timeout=10)
|
|
216
|
+
assert web_client.get_text("#dynamic-content") == "Loaded Content"
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Examples
|
|
220
|
+
|
|
221
|
+
See the test files for comprehensive examples:
|
|
222
|
+
- `tests/test_jsonvalidator.py` - JSON validation examples covering basic validation, type checking, nested objects, constraints, and error handling
|
|
223
|
+
- `tests/test_restapiclient.py` - REST API client examples with testcontainers integration
|
|
224
|
+
- `tests/test_playwrightclient.py` - Playwright web client examples for browser automation with testcontainers
|
|
225
|
+
|
|
226
|
+
## Best Practices
|
|
227
|
+
|
|
228
|
+
### Use Testcontainers for Isolated Environments
|
|
229
|
+
|
|
230
|
+
When testing web applications, it's recommended to use testcontainers to create isolated environments for your services.
|
|
231
|
+
This ensures that your tests are reproducible and do not interfere with each other.
|
|
232
|
+
|
|
233
|
+
## License
|
|
234
|
+
|
|
235
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
klab_pytest_toolkit_web/__init__.py,sha256=9JZoztTOF3lVXzGtlc_CcpCmhHvwginhvJaXLPRrdKo,665
|
|
2
|
+
klab_pytest_toolkit_web/api_client.py,sha256=ConLSFAmHi_32NwkFIFh4JJT5iKVqyBbiM7UGJjlB_s,2874
|
|
3
|
+
klab_pytest_toolkit_web/core.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
klab_pytest_toolkit_web/plugin.py,sha256=tPTAvVe83XOUCCHIpM6D1gNzm8cIFlv8irQJSITlCuM,1646
|
|
5
|
+
klab_pytest_toolkit_web/validators.py,sha256=LpnC--DeWz_GSjKEu1oT3ca0Ce12-VG4wyr8RrMxwY8,2785
|
|
6
|
+
klab_pytest_toolkit_web/web_client.py,sha256=WWXpGyu1kiEkJHy7kTzG3AD_7Njd4vW3M29HlfLNZ-c,11276
|
|
7
|
+
klab_pytest_toolkit_web/_api_client_types/__init__.py,sha256=rvvtKkS8QeXOAwIR5Zva-ecybQhTIVPYH5-PfGFSPJY,546
|
|
8
|
+
klab_pytest_toolkit_web/_api_client_types/grpc_client.py,sha256=5KASvTKcT2AE1jGhnRHZ-7wnm3JCrDSNH7GYOmBWf9Q,8924
|
|
9
|
+
klab_pytest_toolkit_web/_api_client_types/rest_client.py,sha256=4CdHoHRDHIyuYCf41cWVdb81Suy1uF59D3NxA-vL3m4,3667
|
|
10
|
+
klab_pytest_toolkit_web-0.0.0.dist-info/METADATA,sha256=udXvU5VLeA-bmhWvNunfJ4s1Lv_8CY3fV44JpfzPZZs,8430
|
|
11
|
+
klab_pytest_toolkit_web-0.0.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
+
klab_pytest_toolkit_web-0.0.0.dist-info/entry_points.txt,sha256=PKiXdDbin0Zp-a4DdI7me4bx1Oqwey2bw3jBzPNsNe0,68
|
|
13
|
+
klab_pytest_toolkit_web-0.0.0.dist-info/RECORD,,
|