spud-testing 0.1.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.
- spud/__init__.py +7 -0
- spud/api_client.py +82 -0
- spud/api_response.py +31 -0
- spud/endpoint.py +15 -0
- spud/exceptions.py +2 -0
- spud/httpx_transport.py +21 -0
- spud/raw_response_protocol.py +18 -0
- spud/response_checks.py +134 -0
- spud/transport_protocol.py +17 -0
- spud_testing-0.1.0.dist-info/METADATA +51 -0
- spud_testing-0.1.0.dist-info/RECORD +14 -0
- spud_testing-0.1.0.dist-info/WHEEL +5 -0
- spud_testing-0.1.0.dist-info/licenses/LICENSE +0 -0
- spud_testing-0.1.0.dist-info/top_level.txt +1 -0
spud/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from .api_client import ApiClient
|
|
2
|
+
from .api_response import ApiResponse
|
|
3
|
+
from .endpoint import Endpoint
|
|
4
|
+
from .exceptions import SpudCheckError
|
|
5
|
+
from .response_checks import ResponseChecks
|
|
6
|
+
|
|
7
|
+
__all__ = ["ApiClient", "ApiResponse", "Endpoint", "ResponseChecks", "SpudCheckError"]
|
spud/api_client.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from . import api_response, httpx_transport, transport_protocol
|
|
5
|
+
from .transport_protocol import HttpMethod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ApiClient:
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
base_url: str,
|
|
12
|
+
transport: transport_protocol.TransportProtocol | None = None,
|
|
13
|
+
headers: Mapping[str, str] | None = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
self.base_url = base_url.rstrip("/")
|
|
16
|
+
if transport is None:
|
|
17
|
+
self.transport: transport_protocol.TransportProtocol = (
|
|
18
|
+
httpx_transport.HttpxTransport()
|
|
19
|
+
)
|
|
20
|
+
else:
|
|
21
|
+
self.transport = transport
|
|
22
|
+
self.headers = headers
|
|
23
|
+
|
|
24
|
+
def get(
|
|
25
|
+
self,
|
|
26
|
+
path: str,
|
|
27
|
+
params: Mapping[str, Any] | None = None,
|
|
28
|
+
headers: Mapping[str, str] | None = None,
|
|
29
|
+
) -> api_response.ApiResponse:
|
|
30
|
+
return self.request(method="GET", path=path, params=params, headers=headers)
|
|
31
|
+
|
|
32
|
+
def post(
|
|
33
|
+
self, path: str, json: Any = None, headers: Mapping[str, str] | None = None
|
|
34
|
+
) -> api_response.ApiResponse:
|
|
35
|
+
return self.request(method="POST", path=path, json=json, headers=headers)
|
|
36
|
+
|
|
37
|
+
def put(
|
|
38
|
+
self, path: str, json: Any = None, headers: Mapping[str, str] | None = None
|
|
39
|
+
) -> api_response.ApiResponse:
|
|
40
|
+
return self.request(method="PUT", path=path, json=json, headers=headers)
|
|
41
|
+
|
|
42
|
+
def patch(
|
|
43
|
+
self, path: str, json: Any = None, headers: Mapping[str, str] | None = None
|
|
44
|
+
) -> api_response.ApiResponse:
|
|
45
|
+
return self.request(method="PATCH", path=path, json=json, headers=headers)
|
|
46
|
+
|
|
47
|
+
def delete(
|
|
48
|
+
self,
|
|
49
|
+
path: str,
|
|
50
|
+
headers: Mapping[str, str] | None = None,
|
|
51
|
+
params: Mapping[str, Any] | None = None,
|
|
52
|
+
) -> api_response.ApiResponse:
|
|
53
|
+
return self.request(method="DELETE", path=path, headers=headers, params=params)
|
|
54
|
+
|
|
55
|
+
def request(
|
|
56
|
+
self,
|
|
57
|
+
method: HttpMethod,
|
|
58
|
+
path: str,
|
|
59
|
+
params: Mapping[str, Any] | None = None,
|
|
60
|
+
headers: Mapping[str, str] | None = None,
|
|
61
|
+
json: Any = None,
|
|
62
|
+
) -> api_response.ApiResponse:
|
|
63
|
+
if not path.startswith("/"):
|
|
64
|
+
raise ValueError("Path should start with '/'")
|
|
65
|
+
if headers is None:
|
|
66
|
+
effective_headers = self.headers
|
|
67
|
+
else:
|
|
68
|
+
if self.headers is None:
|
|
69
|
+
effective_headers = headers
|
|
70
|
+
else:
|
|
71
|
+
update_headers = dict(self.headers)
|
|
72
|
+
update_headers.update(headers)
|
|
73
|
+
effective_headers = update_headers
|
|
74
|
+
|
|
75
|
+
raw_response = self.transport.request(
|
|
76
|
+
method=method,
|
|
77
|
+
url=self.base_url + path,
|
|
78
|
+
params=params,
|
|
79
|
+
headers=effective_headers,
|
|
80
|
+
json=json,
|
|
81
|
+
)
|
|
82
|
+
return api_response.ApiResponse(raw_response)
|
spud/api_response.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from spud import raw_response_protocol, response_checks
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ApiResponse:
|
|
7
|
+
def __init__(self, raw_response: raw_response_protocol.RawResponseProtocol) -> None:
|
|
8
|
+
self.raw = raw_response
|
|
9
|
+
self._checker = None
|
|
10
|
+
|
|
11
|
+
def json(self) -> Any:
|
|
12
|
+
return self.raw.json()
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def status_code(self):
|
|
16
|
+
return self.raw.status_code
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def headers(self):
|
|
20
|
+
return self.raw.headers
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def text(self):
|
|
24
|
+
return self.raw.text
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def check(self):
|
|
28
|
+
if self._checker is None:
|
|
29
|
+
checker = response_checks.ResponseChecks(response=self)
|
|
30
|
+
self._checker = checker
|
|
31
|
+
return self._checker
|
spud/endpoint.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from . import api_client
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Endpoint:
|
|
5
|
+
path: str
|
|
6
|
+
|
|
7
|
+
def __init__(self, client: api_client.ApiClient) -> None:
|
|
8
|
+
self.client = client
|
|
9
|
+
|
|
10
|
+
def __init_subclass__(cls, **kwargs):
|
|
11
|
+
super().__init_subclass__(**kwargs)
|
|
12
|
+
if not hasattr(cls, "path"):
|
|
13
|
+
raise TypeError("Endpoint subclass must define 'path'")
|
|
14
|
+
if not isinstance(cls.path, str):
|
|
15
|
+
raise TypeError("Endpoint path must be a string")
|
spud/exceptions.py
ADDED
spud/httpx_transport.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from . import raw_response_protocol
|
|
7
|
+
from .transport_protocol import HttpMethod
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HttpxTransport:
|
|
11
|
+
def request(
|
|
12
|
+
self,
|
|
13
|
+
method: HttpMethod,
|
|
14
|
+
url: str,
|
|
15
|
+
params: Mapping[str, Any] | None = None,
|
|
16
|
+
headers: Mapping[str, str] | None = None,
|
|
17
|
+
json: Any = None,
|
|
18
|
+
) -> raw_response_protocol.RawResponseProtocol:
|
|
19
|
+
return httpx.request(
|
|
20
|
+
method=method, url=url, params=params, headers=headers, json=json
|
|
21
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from typing import Any, Protocol
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RawResponseProtocol(Protocol):
|
|
6
|
+
@property
|
|
7
|
+
def headers(self):
|
|
8
|
+
return Mapping[str, str]
|
|
9
|
+
|
|
10
|
+
@property
|
|
11
|
+
def text(self):
|
|
12
|
+
return self.text
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def status_code(self) -> int:
|
|
16
|
+
return self.status_code
|
|
17
|
+
|
|
18
|
+
def json(self) -> Any: ...
|
spud/response_checks.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from http import HTTPStatus
|
|
2
|
+
from typing import TYPE_CHECKING, Any
|
|
3
|
+
|
|
4
|
+
from spud.exceptions import SpudCheckError
|
|
5
|
+
|
|
6
|
+
_MISSING = object()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ResponseChecks:
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from . import api_response
|
|
12
|
+
|
|
13
|
+
def __init__(self, response: "api_response.ApiResponse") -> None:
|
|
14
|
+
self.response = response
|
|
15
|
+
|
|
16
|
+
def status(self, expected_status: int) -> "ResponseChecks":
|
|
17
|
+
status = self.response.status_code
|
|
18
|
+
phrase = f"Expected status {expected_status}, got {status}"
|
|
19
|
+
if status != expected_status:
|
|
20
|
+
try:
|
|
21
|
+
phrase = phrase + f" {HTTPStatus(status).phrase}"
|
|
22
|
+
except ValueError:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
raise SpudCheckError(phrase)
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def json_path(self, path: str = "", expected: Any = _MISSING) -> "ResponseChecks":
|
|
29
|
+
if expected is _MISSING:
|
|
30
|
+
raise TypeError("json_path() missing required argument: expected")
|
|
31
|
+
current = self.response.json()
|
|
32
|
+
for key in parse_json_path(path=path):
|
|
33
|
+
if not (
|
|
34
|
+
(isinstance(current, dict) and isinstance(key, str))
|
|
35
|
+
or (isinstance(current, list) and isinstance(key, int))
|
|
36
|
+
):
|
|
37
|
+
raise SpudCheckError(f"JSON path {path!r} not found")
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
if isinstance(current, dict) and isinstance(key, str):
|
|
41
|
+
current = current[key]
|
|
42
|
+
elif isinstance(current, list) and isinstance(key, int):
|
|
43
|
+
current = current[key]
|
|
44
|
+
except (KeyError, IndexError):
|
|
45
|
+
raise SpudCheckError(f"JSON path {path!r} not found") from None
|
|
46
|
+
if not self.json_equal(current, expected):
|
|
47
|
+
raise SpudCheckError(f"expected {expected!r}, got {current!r}")
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def json_equal(self, actual: Any, expected: Any) -> bool:
|
|
51
|
+
if type(actual) != type(expected):
|
|
52
|
+
return False
|
|
53
|
+
if isinstance(actual, dict):
|
|
54
|
+
if actual.keys() != expected.keys():
|
|
55
|
+
return False
|
|
56
|
+
for key in actual:
|
|
57
|
+
if not self.json_equal(actual[key], expected[key]):
|
|
58
|
+
return False
|
|
59
|
+
return True
|
|
60
|
+
if isinstance(actual, list):
|
|
61
|
+
if len(actual) != len(expected):
|
|
62
|
+
return False
|
|
63
|
+
for actual_item, expected_item in zip(actual, expected):
|
|
64
|
+
if not self.json_equal(actual_item, expected_item):
|
|
65
|
+
return False
|
|
66
|
+
return True
|
|
67
|
+
return actual == expected
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_json_path(path: str) -> list[str | int]:
|
|
71
|
+
tokens: list[str | int] = []
|
|
72
|
+
buffer = ""
|
|
73
|
+
inside_brackets = False
|
|
74
|
+
after_closing_bracket = False
|
|
75
|
+
if path.endswith("."):
|
|
76
|
+
raise ValueError(f"Invalid JSON path {path!r}: empty path segment")
|
|
77
|
+
for index, char in enumerate(path):
|
|
78
|
+
if char == "[":
|
|
79
|
+
if inside_brackets:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"Invalid JSON path {path!r}: unexpected opening bracket"
|
|
82
|
+
)
|
|
83
|
+
if index > 0 and path[index - 1] == ".":
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"Invalid JSON path {path!r}: unexpected opening bracket"
|
|
86
|
+
)
|
|
87
|
+
if buffer:
|
|
88
|
+
tokens.append(buffer)
|
|
89
|
+
buffer = ""
|
|
90
|
+
inside_brackets = True
|
|
91
|
+
after_closing_bracket = False
|
|
92
|
+
continue
|
|
93
|
+
if char == "]":
|
|
94
|
+
if not inside_brackets:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"Invalid JSON path {path!r}: unexpected closing bracket"
|
|
97
|
+
)
|
|
98
|
+
if buffer:
|
|
99
|
+
try:
|
|
100
|
+
tokens.append(int(buffer))
|
|
101
|
+
except ValueError:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"Invalid JSON path {path!r}: array index must be an integer"
|
|
104
|
+
) from None
|
|
105
|
+
else:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"Invalid JSON path {path!r}: array index cannot be empty"
|
|
108
|
+
) from None
|
|
109
|
+
buffer = ""
|
|
110
|
+
inside_brackets = False
|
|
111
|
+
after_closing_bracket = True
|
|
112
|
+
continue
|
|
113
|
+
if char == ".":
|
|
114
|
+
if inside_brackets:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"Invalid JSON path {path!r}: array index must be integer"
|
|
117
|
+
)
|
|
118
|
+
if buffer == "" and not after_closing_bracket:
|
|
119
|
+
raise ValueError(f"Invalid JSON path {path!r}: empty path segment")
|
|
120
|
+
if buffer:
|
|
121
|
+
tokens.append(buffer)
|
|
122
|
+
buffer = ""
|
|
123
|
+
after_closing_bracket = False
|
|
124
|
+
continue
|
|
125
|
+
if after_closing_bracket:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"Invalid JSON path {path!r}: expected separator after closing bracket"
|
|
128
|
+
)
|
|
129
|
+
buffer += char
|
|
130
|
+
if buffer:
|
|
131
|
+
tokens.append(buffer)
|
|
132
|
+
if inside_brackets:
|
|
133
|
+
raise ValueError(f"Invalid JSON path {path!r}: unclosed bracket")
|
|
134
|
+
return tokens
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from typing import Any, Literal, Protocol
|
|
3
|
+
|
|
4
|
+
from spud import raw_response_protocol
|
|
5
|
+
|
|
6
|
+
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TransportProtocol(Protocol):
|
|
10
|
+
def request(
|
|
11
|
+
self,
|
|
12
|
+
method: HttpMethod,
|
|
13
|
+
url: str,
|
|
14
|
+
params: Mapping[str, Any] | None = None,
|
|
15
|
+
headers: Mapping[str, str] | None = None,
|
|
16
|
+
json: Any = None,
|
|
17
|
+
) -> raw_response_protocol.RawResponseProtocol: ...
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spud-testing
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight Python toolkit for API testing
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: httpx
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest; extra == "dev"
|
|
11
|
+
Requires-Dist: mypy; extra == "dev"
|
|
12
|
+
Requires-Dist: ruff; extra == "dev"
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# SPUD
|
|
16
|
+
Stop planting the same test framework over and over again.
|
|
17
|
+
|
|
18
|
+
Spud is a Python testing framework for QA engineers who don't want to rebuild the same setup every time they join a new project.
|
|
19
|
+
|
|
20
|
+
It comes with the stuff you usually end up writing anyway, but doesn't lock you into one way of doing things. Use what you need, replace what you don't, or build on top of it.
|
|
21
|
+
|
|
22
|
+
Plant once. Grow tests.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
```bash
|
|
26
|
+
pip install spud-testing
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
```python
|
|
31
|
+
from spud import ApiClient
|
|
32
|
+
|
|
33
|
+
client = ApiClient(base_url="https://jsonplaceholder.typicode.com")
|
|
34
|
+
|
|
35
|
+
response = client.get("/users/1")
|
|
36
|
+
|
|
37
|
+
response.check.status(200).json_path("id", 1)
|
|
38
|
+
|
|
39
|
+
print(response.json())
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
- HTTP methods: GET, POST, PUT, PATCH, DELETE
|
|
44
|
+
- Configurable base URL and request headers
|
|
45
|
+
- Query parameters and JSON request bodies
|
|
46
|
+
- Pluggable HTTP transport (httpx by default)
|
|
47
|
+
- HTTP status assertions
|
|
48
|
+
- JSON path assertions with nested objects and arrays
|
|
49
|
+
- Strict JSON value comparison
|
|
50
|
+
- Chainable response assertions
|
|
51
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
spud/__init__.py,sha256=7MLLJyWFlVmWAkJyDIErFOsrhmvDv-MyAyh-nj3JyXA,274
|
|
2
|
+
spud/api_client.py,sha256=JDKxjFp0UjRNXx0KOPfr548smKCqhaL5hT-0aJYLEfw,2791
|
|
3
|
+
spud/api_response.py,sha256=C-w2vXGFL-WkNOTppSnx6JjMRP1X2Uqojj-vyzaUYYM,725
|
|
4
|
+
spud/endpoint.py,sha256=bKxoUSKa7Efnrkg99BB8_SYKwp4d2QZxRM5qS4aBe5g,445
|
|
5
|
+
spud/exceptions.py,sha256=BvxUGGCTlcSe7o7t2XwlLTsR4GKBjY_HMonMci3a-nU,47
|
|
6
|
+
spud/httpx_transport.py,sha256=YCz8D-ITfBBYs_8jv4DBtRaldNrF4bs-GCZI1g8O7FU,549
|
|
7
|
+
spud/raw_response_protocol.py,sha256=hfKaH0kfjvxYU_rmodDKsxjzFcBNPdxIXZqA4sHCQvM,351
|
|
8
|
+
spud/response_checks.py,sha256=ihAZf1J_VlQaB-ziFVQ0vpJdrHnpe_OLO93MS9Qh-iQ,4967
|
|
9
|
+
spud/transport_protocol.py,sha256=oIJWIjJ4U7Y0VoAPvKkNvwoWWdCHfWofu_Gq67aM65k,476
|
|
10
|
+
spud_testing-0.1.0.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
spud_testing-0.1.0.dist-info/METADATA,sha256=TGG0QYtdnLfzOfdEuL78_7LNrXQGYmOlBloC_H_GsqY,1390
|
|
12
|
+
spud_testing-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
13
|
+
spud_testing-0.1.0.dist-info/top_level.txt,sha256=sKnJPX1Z6zmvB6P2wc9pO7l_GNtZzeqLYSAAL-HTr44,5
|
|
14
|
+
spud_testing-0.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
spud
|