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 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
@@ -0,0 +1,2 @@
1
+ class SpudCheckError(AssertionError):
2
+ pass
@@ -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: ...
@@ -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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
File without changes
@@ -0,0 +1 @@
1
+ spud