schemathesis 4.0.0a12__py3-none-any.whl → 4.0.1__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.
Files changed (41) hide show
  1. schemathesis/__init__.py +9 -4
  2. schemathesis/auths.py +20 -30
  3. schemathesis/checks.py +5 -0
  4. schemathesis/cli/commands/run/__init__.py +9 -6
  5. schemathesis/cli/commands/run/handlers/output.py +13 -0
  6. schemathesis/cli/constants.py +1 -1
  7. schemathesis/config/_operations.py +16 -21
  8. schemathesis/config/_projects.py +5 -1
  9. schemathesis/core/errors.py +10 -17
  10. schemathesis/core/transport.py +81 -1
  11. schemathesis/engine/errors.py +1 -1
  12. schemathesis/generation/case.py +152 -28
  13. schemathesis/generation/hypothesis/builder.py +12 -12
  14. schemathesis/generation/overrides.py +11 -27
  15. schemathesis/generation/stateful/__init__.py +13 -0
  16. schemathesis/generation/stateful/state_machine.py +31 -108
  17. schemathesis/graphql/loaders.py +14 -4
  18. schemathesis/hooks.py +1 -4
  19. schemathesis/openapi/checks.py +82 -20
  20. schemathesis/openapi/generation/filters.py +9 -2
  21. schemathesis/openapi/loaders.py +14 -4
  22. schemathesis/pytest/lazy.py +4 -31
  23. schemathesis/pytest/plugin.py +21 -11
  24. schemathesis/schemas.py +153 -89
  25. schemathesis/specs/graphql/schemas.py +6 -6
  26. schemathesis/specs/openapi/_hypothesis.py +39 -14
  27. schemathesis/specs/openapi/checks.py +95 -34
  28. schemathesis/specs/openapi/expressions/nodes.py +1 -1
  29. schemathesis/specs/openapi/negative/__init__.py +5 -3
  30. schemathesis/specs/openapi/negative/mutations.py +2 -2
  31. schemathesis/specs/openapi/parameters.py +0 -3
  32. schemathesis/specs/openapi/schemas.py +6 -91
  33. schemathesis/specs/openapi/stateful/links.py +1 -63
  34. schemathesis/transport/requests.py +12 -1
  35. schemathesis/transport/serialization.py +0 -4
  36. schemathesis/transport/wsgi.py +7 -0
  37. {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/METADATA +8 -10
  38. {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/RECORD +41 -41
  39. {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/WHEEL +0 -0
  40. {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/entry_points.txt +0 -0
  41. {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/licenses/LICENSE +0 -0
@@ -42,7 +42,10 @@ def from_asgi(path: str, app: Any, *, config: SchemathesisConfig | None = None,
42
42
  client = asgi.get_client(app)
43
43
  response = load_from_url(client.post, url=path, **kwargs)
44
44
  schema = extract_schema_from_response(response, lambda r: r.json())
45
- return from_dict(schema=schema, config=config).configure(app=app, location=path)
45
+ loaded = from_dict(schema=schema, config=config)
46
+ loaded.app = app
47
+ loaded.location = path
48
+ return loaded
46
49
 
47
50
 
48
51
  def from_wsgi(path: str, app: Any, *, config: SchemathesisConfig | None = None, **kwargs: Any) -> GraphQLSchema:
@@ -71,7 +74,10 @@ def from_wsgi(path: str, app: Any, *, config: SchemathesisConfig | None = None,
71
74
  response = client.post(path=path, **kwargs)
72
75
  raise_for_status(response)
73
76
  schema = extract_schema_from_response(response, lambda r: r.json)
74
- return from_dict(schema=schema, config=config).configure(app=app, location=path)
77
+ loaded = from_dict(schema=schema, config=config)
78
+ loaded.app = app
79
+ loaded.location = path
80
+ return loaded
75
81
 
76
82
 
77
83
  def from_url(
@@ -107,7 +113,9 @@ def from_url(
107
113
  kwargs.setdefault("json", {"query": get_introspection_query()})
108
114
  response = load_from_url(requests.post, url=url, wait_for_schema=wait_for_schema, **kwargs)
109
115
  schema = extract_schema_from_response(response, lambda r: r.json())
110
- return from_dict(schema, config=config).configure(location=url)
116
+ loaded = from_dict(schema, config=config)
117
+ loaded.location = url
118
+ return loaded
111
119
 
112
120
 
113
121
  def from_path(
@@ -130,7 +138,9 @@ def from_path(
130
138
 
131
139
  """
132
140
  with open(path, encoding=encoding) as file:
133
- return from_file(file=file, config=config).configure(location=Path(path).absolute().as_uri())
141
+ loaded = from_file(file=file, config=config)
142
+ loaded.location = Path(path).absolute().as_uri()
143
+ return loaded
134
144
 
135
145
 
136
146
  def from_file(file: IO[str] | str, *, config: SchemathesisConfig | None = None) -> GraphQLSchema:
schemathesis/hooks.py CHANGED
@@ -231,10 +231,7 @@ class HookDispatcher:
231
231
  hook(context, *args, **kwargs)
232
232
 
233
233
  def unregister(self, hook: Callable) -> None:
234
- """Unregister a specific hook.
235
-
236
- :param hook: A hook function to unregister.
237
- """
234
+ """Unregister a specific hook."""
238
235
  # It removes this function from all places
239
236
  for hooks in self._hooks.values():
240
237
  hooks[:] = [item for item in hooks if item is not hook]
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import textwrap
4
- from dataclasses import dataclass, field
5
4
  from typing import TYPE_CHECKING, Any
6
5
 
7
6
  from schemathesis.config import OutputConfig
@@ -12,24 +11,6 @@ if TYPE_CHECKING:
12
11
  from jsonschema import ValidationError
13
12
 
14
13
 
15
- @dataclass
16
- class NegativeDataRejectionConfig:
17
- # 5xx will pass through
18
- allowed_statuses: list[str] = field(
19
- default_factory=lambda: ["400", "401", "403", "404", "406", "422", "428", "5xx"]
20
- )
21
-
22
-
23
- @dataclass
24
- class PositiveDataAcceptanceConfig:
25
- allowed_statuses: list[str] = field(default_factory=lambda: ["2xx", "401", "403", "404"])
26
-
27
-
28
- @dataclass
29
- class MissingRequiredHeaderConfig:
30
- allowed_statuses: list[str] = field(default_factory=lambda: ["406"])
31
-
32
-
33
14
  class UndefinedStatusCode(Failure):
34
15
  """Response has a status code that is not defined in the schema."""
35
16
 
@@ -323,7 +304,7 @@ class IgnoredAuth(Failure):
323
304
  *,
324
305
  operation: str,
325
306
  message: str,
326
- title: str = "Authentication declared but not enforced",
307
+ title: str = "API accepts requests without authentication",
327
308
  case_id: str | None = None,
328
309
  ) -> None:
329
310
  self.operation = operation
@@ -391,3 +372,84 @@ class RejectedPositiveData(Failure):
391
372
  @property
392
373
  def _unique_key(self) -> str:
393
374
  return str(self.status_code)
375
+
376
+
377
+ class MissingHeaderNotRejected(Failure):
378
+ """API did not reject request without required header."""
379
+
380
+ __slots__ = (
381
+ "operation",
382
+ "header_name",
383
+ "status_code",
384
+ "expected_statuses",
385
+ "message",
386
+ "title",
387
+ "case_id",
388
+ "severity",
389
+ )
390
+
391
+ def __init__(
392
+ self,
393
+ *,
394
+ operation: str,
395
+ header_name: str,
396
+ status_code: int,
397
+ expected_statuses: list[int],
398
+ message: str,
399
+ title: str = "Missing header not rejected",
400
+ case_id: str | None = None,
401
+ ) -> None:
402
+ self.operation = operation
403
+ self.header_name = header_name
404
+ self.status_code = status_code
405
+ self.expected_statuses = expected_statuses
406
+ self.message = message
407
+ self.title = title
408
+ self.case_id = case_id
409
+ self.severity = Severity.MEDIUM
410
+
411
+ @property
412
+ def _unique_key(self) -> str:
413
+ return self.header_name
414
+
415
+
416
+ class UnsupportedMethodResponse(Failure):
417
+ """API response for unsupported HTTP method is incorrect."""
418
+
419
+ __slots__ = (
420
+ "operation",
421
+ "method",
422
+ "status_code",
423
+ "allow_header_present",
424
+ "failure_reason",
425
+ "message",
426
+ "title",
427
+ "case_id",
428
+ "severity",
429
+ )
430
+
431
+ def __init__(
432
+ self,
433
+ *,
434
+ operation: str,
435
+ method: str,
436
+ status_code: int,
437
+ allow_header_present: bool | None = None,
438
+ failure_reason: str, # "wrong_status" or "missing_allow_header"
439
+ message: str,
440
+ title: str = "Unsupported method incorrect response",
441
+ case_id: str | None = None,
442
+ ) -> None:
443
+ self.operation = operation
444
+ self.method = method
445
+ self.status_code = status_code
446
+ self.allow_header_present = allow_header_present
447
+ self.failure_reason = failure_reason
448
+ self.message = message
449
+ self.title = title
450
+ self.case_id = case_id
451
+ self.severity = Severity.MEDIUM
452
+
453
+ @property
454
+ def _unique_key(self) -> str:
455
+ return self.failure_reason
@@ -29,8 +29,15 @@ def is_invalid_path_parameter(value: Any) -> bool:
29
29
  return (
30
30
  value in ("/", "")
31
31
  or contains_unicode_surrogate_pair(value)
32
- or isinstance(value, str)
33
- and ("/" in value or "}" in value or "{" in value)
32
+ or (
33
+ isinstance(value, str)
34
+ and (
35
+ ("/" in value or "}" in value or "{" in value)
36
+ # Avoid situations when the path parameter contains only NULL bytes
37
+ # Many webservers remove such bytes and as the result, the test can target a different API operation
38
+ or (len(value) == value.count("\x00"))
39
+ )
40
+ )
34
41
  )
35
42
 
36
43
 
@@ -43,7 +43,10 @@ def from_asgi(path: str, app: Any, *, config: SchemathesisConfig | None = None,
43
43
  response = load_from_url(client.get, url=path, **kwargs)
44
44
  content_type = detect_content_type(headers=response.headers, path=path)
45
45
  schema = load_content(response.text, content_type)
46
- return from_dict(schema=schema, config=config).configure(app=app, location=path)
46
+ loaded = from_dict(schema=schema, config=config)
47
+ loaded.app = app
48
+ loaded.location = path
49
+ return loaded
47
50
 
48
51
 
49
52
  def from_wsgi(path: str, app: Any, *, config: SchemathesisConfig | None = None, **kwargs: Any) -> BaseOpenAPISchema:
@@ -72,7 +75,10 @@ def from_wsgi(path: str, app: Any, *, config: SchemathesisConfig | None = None,
72
75
  raise_for_status(response)
73
76
  content_type = detect_content_type(headers=response.headers, path=path)
74
77
  schema = load_content(response.text, content_type)
75
- return from_dict(schema=schema, config=config).configure(app=app, location=path)
78
+ loaded = from_dict(schema=schema, config=config)
79
+ loaded.app = app
80
+ loaded.location = path
81
+ return loaded
76
82
 
77
83
 
78
84
  def from_url(
@@ -108,7 +114,9 @@ def from_url(
108
114
  response = load_from_url(requests.get, url=url, wait_for_schema=wait_for_schema, **kwargs)
109
115
  content_type = detect_content_type(headers=response.headers, path=url)
110
116
  schema = load_content(response.text, content_type)
111
- return from_dict(schema=schema, config=config).configure(location=url)
117
+ loaded = from_dict(schema=schema, config=config)
118
+ loaded.location = url
119
+ return loaded
112
120
 
113
121
 
114
122
  def from_path(
@@ -136,7 +144,9 @@ def from_path(
136
144
  with open(path, encoding=encoding) as file:
137
145
  content_type = detect_content_type(headers=None, path=str(path))
138
146
  schema = load_content(file.read(), content_type)
139
- return from_dict(schema=schema, config=config).configure(location=Path(path).absolute().as_uri())
147
+ loaded = from_dict(schema=schema, config=config)
148
+ loaded.location = Path(path).absolute().as_uri()
149
+ return loaded
140
150
 
141
151
 
142
152
  def from_file(file: IO[str] | str, *, config: SchemathesisConfig | None = None) -> BaseOpenAPISchema:
@@ -12,6 +12,7 @@ from pytest_subtests import SubTests
12
12
  from schemathesis.core.errors import InvalidSchema
13
13
  from schemathesis.core.result import Ok, Result
14
14
  from schemathesis.filters import FilterSet, FilterValue, MatcherFunc, RegexValue, is_deprecated
15
+ from schemathesis.generation import overrides
15
16
  from schemathesis.generation.hypothesis.builder import HypothesisTestConfig, HypothesisTestMode, create_test
16
17
  from schemathesis.generation.hypothesis.given import (
17
18
  GivenArgsMark,
@@ -22,7 +23,6 @@ from schemathesis.generation.hypothesis.given import (
22
23
  merge_given_args,
23
24
  validate_given_args,
24
25
  )
25
- from schemathesis.generation.overrides import Override, OverrideMark, check_no_override_mark
26
26
  from schemathesis.pytest.control_flow import fail_on_no_matches
27
27
  from schemathesis.schemas import BaseSchema
28
28
 
@@ -174,17 +174,10 @@ class LazySchema:
174
174
  node_id = request.node._nodeid
175
175
  settings = getattr(wrapped_test, "_hypothesis_internal_use_settings", None)
176
176
 
177
- as_strategy_kwargs: Callable[[APIOperation], dict[str, Any]] | None = None
177
+ def as_strategy_kwargs(_operation: APIOperation) -> dict[str, Any]:
178
+ override = overrides.for_operation(config=schema.config, operation=_operation)
178
179
 
179
- override = OverrideMark.get(test_func)
180
- if override is not None:
181
-
182
- def as_strategy_kwargs(_operation: APIOperation) -> dict[str, Any]:
183
- nonlocal override
184
-
185
- return {
186
- location: entry for location, entry in override.for_operation(_operation).items() if entry
187
- }
180
+ return {location: entry for location, entry in override.items() if entry}
188
181
 
189
182
  tests = list(
190
183
  get_all_tests(
@@ -224,26 +217,6 @@ class LazySchema:
224
217
  def given(self, *args: GivenInput, **kwargs: GivenInput) -> Callable:
225
218
  return given_proxy(*args, **kwargs)
226
219
 
227
- def override(
228
- self,
229
- *,
230
- query: dict[str, str] | None = None,
231
- headers: dict[str, str] | None = None,
232
- cookies: dict[str, str] | None = None,
233
- path_parameters: dict[str, str] | None = None,
234
- ) -> Callable[[Callable], Callable]:
235
- """Override Open API parameters with fixed values."""
236
-
237
- def _add_override(test: Callable) -> Callable:
238
- check_no_override_mark(test)
239
- override = Override(
240
- query=query or {}, headers=headers or {}, cookies=cookies or {}, path_parameters=path_parameters or {}
241
- )
242
- OverrideMark.set(test, override)
243
- return test
244
-
245
- return _add_override
246
-
247
220
 
248
221
  def _copy_marks(source: Callable, target: Callable) -> None:
249
222
  marks = getattr(source, "pytestmark", [])
@@ -26,6 +26,7 @@ from schemathesis.core.errors import (
26
26
  from schemathesis.core.failures import FailureGroup
27
27
  from schemathesis.core.marks import Mark
28
28
  from schemathesis.core.result import Ok, Result
29
+ from schemathesis.generation import overrides
29
30
  from schemathesis.generation.hypothesis.given import (
30
31
  GivenArgsMark,
31
32
  GivenKwargsMark,
@@ -34,7 +35,6 @@ from schemathesis.generation.hypothesis.given import (
34
35
  validate_given_args,
35
36
  )
36
37
  from schemathesis.generation.hypothesis.reporting import ignore_hypothesis_output
37
- from schemathesis.generation.overrides import OverrideMark
38
38
  from schemathesis.pytest.control_flow import fail_on_no_matches
39
39
  from schemathesis.schemas import APIOperation
40
40
 
@@ -110,6 +110,7 @@ class SchemathesisCase(PyCollector):
110
110
  This implementation is based on the original one in pytest, but with slight adjustments
111
111
  to produce tests out of hypothesis ones.
112
112
  """
113
+ from schemathesis.checks import load_all_checks
113
114
  from schemathesis.generation.hypothesis.builder import (
114
115
  HypothesisTestConfig,
115
116
  HypothesisTestMode,
@@ -117,6 +118,8 @@ class SchemathesisCase(PyCollector):
117
118
  make_async_test,
118
119
  )
119
120
 
121
+ load_all_checks()
122
+
120
123
  is_trio_test = False
121
124
  for mark in getattr(self.test_function, "pytestmark", []):
122
125
  if mark.name == "trio":
@@ -128,14 +131,22 @@ class SchemathesisCase(PyCollector):
128
131
  if self.is_invalid_test:
129
132
  funcobj = self.test_function
130
133
  else:
131
- override = OverrideMark.get(self.test_function)
134
+ as_strategy_kwargs = {}
135
+
136
+ auth = self.schema.config.auth_for(operation=operation)
137
+ if auth is not None:
138
+ from requests.auth import _basic_auth_str
139
+
140
+ as_strategy_kwargs["headers"] = {"Authorization": _basic_auth_str(*auth)}
141
+ headers = self.schema.config.headers_for(operation=operation)
142
+ if headers:
143
+ as_strategy_kwargs["headers"] = headers
144
+
145
+ override = overrides.for_operation(operation=operation, config=self.schema.config)
132
146
  if override is not None:
133
- as_strategy_kwargs = {}
134
- for location, entry in override.for_operation(operation).items():
147
+ for location, entry in override.items():
135
148
  if entry:
136
149
  as_strategy_kwargs[location] = entry
137
- else:
138
- as_strategy_kwargs = {}
139
150
  modes = []
140
151
  phases = self.schema.config.phases_for(operation=operation)
141
152
  if phases.examples.enabled:
@@ -150,6 +161,7 @@ class SchemathesisCase(PyCollector):
150
161
  test_func=self.test_function,
151
162
  config=HypothesisTestConfig(
152
163
  modes=modes,
164
+ settings=self.schema.config.get_hypothesis_settings(operation=operation),
153
165
  given_kwargs=self.given_kwargs,
154
166
  project=self.schema.config,
155
167
  as_strategy_kwargs=as_strategy_kwargs,
@@ -256,16 +268,14 @@ def pytest_exception_interact(node: Function, call: pytest.CallInfo, report: pyt
256
268
  total_frames = len(tb_entries)
257
269
 
258
270
  # Keep internal Schemathesis frames + one extra one from the caller
259
- keep_from_index = 0
271
+ skip_frames = 0
260
272
  for i in range(total_frames - 1, -1, -1):
261
273
  entry = tb_entries[i]
262
274
 
263
- if "validate_response" in str(entry):
264
- keep_from_index = max(0, i - 1)
275
+ if not str(entry.path).endswith("schemathesis/generation/case.py"):
276
+ skip_frames = i
265
277
  break
266
278
 
267
- skip_frames = keep_from_index
268
-
269
279
  report.longrepr = "".join(format_exception(call.excinfo.value, with_traceback=True, skip_frames=skip_frames))
270
280
 
271
281