Skip to content

Commit b9c8982

Browse files
committed
feat!: new schemathesis support
BREAKING CHANGE: Call* keywords have removed the auth argumement because it did not work properly anymore with latest Schemathesis versions. Instead use auth argument in library import and implement a class with Schemathesis auth section: https://schemathesis.readthedocs.io/en/stable/guides/auth/
1 parent 8b8c661 commit b9c8982

File tree

11 files changed

+1093
-718
lines changed

11 files changed

+1093
-718
lines changed

.github/workflows/release.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ jobs:
4949
uses: python-semantic-release/python-semantic-release@v10.4.1
5050
with:
5151
github_token: ${{ secrets.GITHUB_TOKEN }}
52-
force: minor
5352
- name: Create release
5453
run: |
5554
uv version ${{ steps.release.outputs.version }}

atest/.DS_Store

6 KB
Binary file not shown.

atest/library/AuthExtension.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from base64 import b64encode
2+
3+
import schemathesis
4+
from robot.api import logger
5+
6+
7+
@schemathesis.auth().apply_to(path_regex=r"/user/.+")
8+
class AuthExtension:
9+
def get(self, case, ctx):
10+
return b64encode("joulu:pukki".encode("utf-8")).decode("ascii")
11+
12+
def set(self, case, data, ctx):
13+
case.headers = case.headers or {}
14+
case.headers["Authorization"] = f"Basic {data}"
15+
logger.debug(f"Updated headers for case: {case.operation.method} {case.operation.path}")

atest/library/all_cases_auth.robot

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
*** Settings ***
2-
Variables authentication.py
3-
Library SchemathesisLibrary url=http://127.0.0.1/openapi.json
2+
Library SchemathesisLibrary url=http://127.0.0.1/openapi.json auth=${CURDIR}/AuthExtension.py
43

54
Test Template Wrapper
65

@@ -13,10 +12,5 @@ All Tests
1312
*** Keywords ***
1413
Wrapper
1514
[Arguments] ${case}
16-
IF ${{'${case.path}'.startswith('/user')}}
17-
VAR ${auth} ${BASIC_AUTH_TUPLE}
18-
ELSE
19-
VAR ${auth} ${None}
20-
END
21-
${r} = Call And Validate ${case} auth=${auth}
15+
${r} = Call And Validate ${case}
2216
Log ${r.json()}
Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
*** Settings ***
2-
Variables authentication.py
3-
Library SchemathesisLibrary url=http://127.0.0.1/openapi.json max_examples=4 headers=${BASIC_AUTH_HEADERS}
2+
Library SchemathesisLibrary url=http://127.0.0.1/openapi.json max_examples=4 auth=${CURDIR}/AuthExtension.py
43

54
Test Template Wrapper
65

@@ -13,11 +12,6 @@ All Tests
1312
*** Keywords ***
1413
Wrapper
1514
[Arguments] ${case}
16-
IF ${{'${case.path}'.startswith('/user')}}
17-
VAR &{headers} &{BASIC_AUTH_HEADERS}
18-
ELSE
19-
VAR &{headers}
20-
END
21-
${response} = Call ${case} headers=${headers}
15+
${response} = Call ${case}
2216
Validate Response ${case} ${response}
2317
Log ${response.json()}

atest/library/all_cases_from_file.robot

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
*** Settings ***
22
Variables authentication.py
3-
Library SchemathesisLibrary path=${CURDIR}/../specs/openapi.json
3+
Library SchemathesisLibrary path=${CURDIR}/../specs/openapi.json auth=${CURDIR}/AuthExtension.py
44

55
Test Template Wrapper
66

@@ -14,7 +14,8 @@ All Tests
1414
Wrapper
1515
[Arguments] ${case}
1616
IF ${{'${case.path}'.startswith('/user')}}
17-
VAR &{headers} &{BASIC_AUTH_HEADERS}
17+
VAR &{headers} key1=value1 key2=value2
18+
BuiltIn.Skip Skipping test case for /user path
1819
ELSE
1920
VAR &{headers}
2021
END

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,7 @@ ignore = [
103103

104104
[tool.ruff.format]
105105
quote-style = "double"
106+
107+
[tool.mypy]
108+
python_version = "3.10"
109+
files = "src/SchemathesisLibrary"

src/SchemathesisLibrary/__init__.py

Lines changed: 18 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -11,69 +11,25 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14-
from dataclasses import dataclass
15-
from pathlib import Path
16-
from typing import Any
14+
from typing import TYPE_CHECKING, Any
1715

1816
from DataDriver import DataDriver # type: ignore
19-
from DataDriver.AbstractReaderClass import AbstractReaderClass # type: ignore
20-
from DataDriver.ReaderConfig import TestCaseData # type: ignore
21-
from hypothesis import HealthCheck, Phase, Verbosity, given, settings
22-
from hypothesis import strategies as st
2317
from robot.api import logger
2418
from robot.api.deco import keyword
2519
from robot.result.model import TestCase as ResultTestCase # type: ignore
2620
from robot.result.model import TestSuite as ResultTestSuite # type: ignore
2721
from robot.running.model import TestCase, TestSuite # type: ignore
2822
from robotlibcore import DynamicCore # type: ignore
29-
from schemathesis import Case, openapi
23+
from schemathesis import Case
3024
from schemathesis.core import NotSet
31-
from schemathesis.core.result import Ok
3225
from schemathesis.core.transport import Response
3326

34-
__version__ = "0.53.0"
27+
from .schemathesisreader import Options, SchemathesisReader
3528

29+
if TYPE_CHECKING:
30+
from pathlib import Path
3631

37-
@dataclass
38-
class Options:
39-
max_examples: int
40-
headers: dict[str, Any] | None = None
41-
path: "Path|None" = None
42-
url: "str|None" = None
43-
44-
45-
class SchemathesisReader(AbstractReaderClass):
46-
options: "Options|None" = None
47-
48-
def get_data_from_source(self) -> list[TestCaseData]:
49-
if not self.options:
50-
raise ValueError("Options must be set before calling get_data_from_source.")
51-
url = self.options.url
52-
path = self.options.path
53-
if path and not Path(path).is_file():
54-
raise ValueError(f"Provided path '{path}' is not a valid file.")
55-
if path:
56-
schema = openapi.from_path(path)
57-
elif url:
58-
headers = self.options.headers or {}
59-
schema = openapi.from_url(url, headers=headers)
60-
else:
61-
raise ValueError("Either 'url' or 'path' must be provided to SchemathesisLibrary.")
62-
all_cases: list[TestCaseData] = []
63-
for op in schema.get_all_operations():
64-
if isinstance(op, Ok):
65-
# NOTE: (dd): `as_strategy` also accepts GenerationMode
66-
# It could be used to produce positive / negative tests
67-
strategy = op.ok().as_strategy().map(from_case) # type: ignore
68-
add_examples(strategy, all_cases, self.options.max_examples) # type: ignore
69-
return all_cases
70-
71-
72-
def from_case(case: Case) -> TestCaseData:
73-
return TestCaseData(
74-
test_case_name=f"{case.operation.label} - {case.id}",
75-
arguments={"${case}": case},
76-
)
32+
__version__ = "0.53.0"
7733

7834

7935
class SchemathesisLibrary(DynamicCore):
@@ -121,17 +77,21 @@ def __init__(
12177
max_examples: int = 5,
12278
path: "Path|None" = None,
12379
url: "str|None" = None,
80+
auth: str | None = None,
12481
) -> None:
12582
"""The SchemathesisLibrary can be initialized with the following arguments:
12683
12784
| =Argument= | =Description= |
128-
| `headers` | Optional HTTP headers to be used schema is downloaded from `url`. |
85+
| `headers` | Optional HTTP headers to be used when schema is downloaded from `url`. |
12986
| `max_examples` | Maximum number of examples to generate for each operation. Default is 5. |
13087
| `path` | Path to the OpenAPI schema file. Using either `path` or `url` is mandatory. |
13188
| `url` | URL where the OpenAPI schema can be downloaded. |
89+
| `auth` | Optional authentication class to be used passed for Schemathesis authentication when test cases are executed. |
13290
"""
13391
self.ROBOT_LIBRARY_LISTENER = self
134-
SchemathesisReader.options = Options(headers=headers, max_examples=max_examples, path=path, url=url)
92+
SchemathesisReader.options = Options(
93+
headers=headers, max_examples=max_examples, path=path, url=url, auth=auth
94+
)
13595
self.data_driver = DataDriver(reader_class=SchemathesisReader)
13696
DynamicCore.__init__(self, [])
13797

@@ -146,9 +106,8 @@ def call_and_validate(
146106
self,
147107
case: Case,
148108
*,
149-
auth: "Any|None" = None,
150-
base_url: "str|None" = None,
151-
headers: "dict[str, Any]|None" = None,
109+
base_url: str | None = None,
110+
headers: dict[str, Any] | None = None,
152111
) -> Response:
153112
"""Call and validate a Schemathesis case.
154113
@@ -157,7 +116,7 @@ def call_and_validate(
157116
"""
158117
self.info(f"Case: {case.path} | {case.method} | {case.path_parameters}")
159118
self._log_case(case, headers)
160-
response = case.call_and_validate(base_url=base_url, headers=headers, auth=auth)
119+
response = case.call_and_validate(base_url=base_url, headers=headers)
161120
self._log_request(response)
162121
self.debug(f"Response: {response.headers} | {response.status_code} | {response.text}")
163122
return response
@@ -167,9 +126,8 @@ def call(
167126
self,
168127
case: Case,
169128
*,
170-
auth: "Any|None" = None,
171-
base_url: "str|None" = None,
172-
headers: "dict[str, Any]|None" = None,
129+
base_url: str | None = None,
130+
headers: dict[str, Any] | None = None,
173131
) -> Response:
174132
"""Call a Schemathesis case without validation.
175133
@@ -182,7 +140,7 @@ def call(
182140
"""
183141
self.info(f"Calling case: {case.path} | {case.method} | {case.path_parameters}")
184142
self._log_case(case)
185-
response = case.call(base_url=base_url, headers=headers, auth=auth)
143+
response = case.call(base_url=base_url, headers=headers)
186144
self._log_request(response)
187145
return response
188146

@@ -222,19 +180,3 @@ def _log_request(self, resposen: Response) -> None:
222180
f"Request: {resposen.request.method} {resposen.request.url} "
223181
f"headers: {resposen.request.headers!r} body: {resposen.request.body!r}"
224182
)
225-
226-
227-
def add_examples(strategy: st.SearchStrategy, container: list[TestCaseData], max_examples: int) -> None:
228-
@given(strategy)
229-
@settings(
230-
database=None,
231-
max_examples=max_examples,
232-
deadline=None,
233-
verbosity=Verbosity.quiet,
234-
phases=(Phase.generate,),
235-
suppress_health_check=list(HealthCheck),
236-
)
237-
def example_generating_inner_function(ex: Any) -> None:
238-
container.append(ex)
239-
240-
example_generating_inner_function()
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Copyright 2025- Tatu Aalto
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
from dataclasses import dataclass
15+
from pathlib import Path
16+
from typing import Any
17+
18+
from DataDriver.AbstractReaderClass import AbstractReaderClass # type: ignore
19+
from DataDriver.ReaderConfig import TestCaseData # type: ignore
20+
from hypothesis import HealthCheck, Phase, Verbosity, given, settings
21+
from hypothesis import strategies as st
22+
from robot.api import logger
23+
from robot.utils.importer import Importer # type: ignore
24+
from schemathesis import Case, openapi
25+
from schemathesis.core.result import Ok
26+
27+
28+
@dataclass
29+
class Options:
30+
max_examples: int
31+
headers: dict[str, Any] | None = None
32+
path: "Path|None" = None
33+
url: str | None = None
34+
auth: str | None = None
35+
36+
37+
class SchemathesisReader(AbstractReaderClass):
38+
options: "Options|None" = None
39+
40+
def get_data_from_source(self) -> list[TestCaseData]:
41+
if not self.options:
42+
raise ValueError("Options must be set before calling get_data_from_source.")
43+
url = self.options.url
44+
path = self.options.path
45+
if path and not Path(path).is_file():
46+
raise ValueError(f"Provided path '{path}' is not a valid file.")
47+
if path:
48+
schema = openapi.from_path(path)
49+
elif url:
50+
headers = self.options.headers or {}
51+
schema = openapi.from_url(url, headers=headers)
52+
else:
53+
raise ValueError("Either 'url' or 'path' must be provided to SchemathesisLibrary.")
54+
all_cases: list[TestCaseData] = []
55+
if self.options.auth:
56+
import_extensions(self.options.auth)
57+
logger.info(f"Using auth extension from: {self.options.auth}")
58+
59+
for op in schema.get_all_operations():
60+
if isinstance(op, Ok):
61+
# NOTE: (dd): `as_strategy` also accepts GenerationMode
62+
# It could be used to produce positive / negative tests
63+
strategy = op.ok().as_strategy().map(from_case) # type: ignore
64+
add_examples(strategy, all_cases, self.options.max_examples) # type: ignore
65+
return all_cases
66+
67+
68+
def from_case(case: Case) -> TestCaseData:
69+
return TestCaseData(
70+
test_case_name=f"{case.operation.label} - {case.id}",
71+
arguments={"${case}": case},
72+
)
73+
74+
75+
def add_examples(strategy: st.SearchStrategy, container: list[TestCaseData], max_examples: int) -> None:
76+
@given(strategy)
77+
@settings(
78+
database=None,
79+
max_examples=max_examples,
80+
deadline=None,
81+
verbosity=Verbosity.quiet,
82+
phases=(Phase.generate,),
83+
suppress_health_check=list(HealthCheck),
84+
)
85+
def example_generating_inner_function(ex: Any) -> None:
86+
container.append(ex)
87+
88+
example_generating_inner_function()
89+
90+
91+
def import_extensions(library: str | Path) -> Any:
92+
"""Import any extensions for SchemathesisLibrary."""
93+
importer = Importer("test library")
94+
lib = importer.import_module(library)
95+
logger.info(f"Imported extension module: {lib}")
96+
return lib

tasks.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ def spec_file(ctx):
153153

154154

155155
@task(pre=[test_app, spec_file])
156-
def atest(ctx):
156+
def atest(ctx, suite: str | None = None):
157157
"""Run acceptance tests."""
158158
args = [
159159
"uv",
@@ -175,6 +175,32 @@ def atest(ctx):
175175
ctx.run(" ".join(args))
176176

177177

178+
@task(pre=[test_app, spec_file])
179+
def atest_lib(ctx, suite: str | None = None):
180+
"""Run library tests."""
181+
args = [
182+
"uv",
183+
"run",
184+
"robot",
185+
"--loglevel",
186+
"DEBUG:INFO",
187+
"--pythonpath",
188+
"./src",
189+
"--outputdir",
190+
ATEST_OUTPUT_DIR_LIB.as_posix(),
191+
]
192+
if suite:
193+
args.append(f"--suite")
194+
args.append(suite)
195+
args.append("atest/library")
196+
shutil.rmtree(ATEST_OUTPUT_DIR, ignore_errors=True)
197+
shutil.rmtree(ATEST_OUTPUT_DIR_LIB, ignore_errors=True)
198+
ATEST_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
199+
ATEST_OUTPUT_DIR_LIB.mkdir(parents=True, exist_ok=True)
200+
print(f"Running {args}")
201+
ctx.run(" ".join(args))
202+
203+
178204
@task
179205
def clean(ctx):
180206
"""Clean up the output and dist directories."""

0 commit comments

Comments
 (0)