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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ klab_pytest_toolkit_web = klab_pytest_toolkit_web.plugin